diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 1d41be75aa..d476f78e5c 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -7,7 +7,8 @@ WORKDIR /home/app COPY --chown=app:app ./package.json ./bun.lockb /home/app/ -RUN bun install --production --frozen-lockfile +# RUN bun install --production --frozen-lockfile +RUN bun install COPY --chown=app:app index.html /home/app/ COPY --chown=app:app src /home/app/src diff --git a/frontend/bun.lockb b/frontend/bun.lockb index f5f30a04cc..10926b48c8 100755 Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ diff --git a/frontend/package.json b/frontend/package.json index 66c27507bc..e734387977 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,111 +1,113 @@ { - "name": "opre-ops", - "version": "1.0.1", - "license": "CC0-1.0", - "private": true, - "type": "module", - "dependencies": { - "@azure/storage-blob": "^12.24.0", - "@fortawesome/fontawesome-svg-core": "6.5.2", - "@fortawesome/free-regular-svg-icons": "6.5.2", - "@fortawesome/free-solid-svg-icons": "6.5.2", - "@fortawesome/react-fontawesome": "0.2.2", - "@nivo/bar": "0.87.0", - "@nivo/core": "0.87.0", - "@nivo/pie": "0.87.0", - "@reduxjs/toolkit": "2.2.8", - "@uswds/uswds": "3.9.0", - "axios": "1.7.7", - "clsx": "2.1.1", - "crypto-random-string": "5.0.0", - "js-cookie": "3.0.5", - "jwt-decode": "4.0.0", - "lodash": "4.17.21", - "react": "18.3.1", - "react-currency-format": "1.1.0", - "react-dom": "18.3.1", - "react-markdown": "9.0.1", - "react-modal": "3.16.1", - "react-redux": "9.1.2", - "react-router-dom": "6.27.0", - "react-select": "5.8.1", - "sass": "1.79.6", - "sass-loader": "14.2.1", - "vest": "5.4.3", - "@eslint/compat": "1.2.0", - "@eslint/js": "9.12.0", - "@vitejs/plugin-react": "4.3.2", - "eslint": "9.12.0", - "eslint-config-prettier": "9.1.0", - "eslint-plugin-cypress": "4.0.0", - "eslint-plugin-import": "2.29.1", - "eslint-plugin-jest": "28.8.3", - "eslint-plugin-jsx-a11y": "6.10.0", - "eslint-plugin-prettier": "5.2.1", - "eslint-plugin-react": "7.37.1", - "eslint-plugin-react-hooks": "5.0.0", - "eslint-plugin-react-refresh": "0.4.12", - "eslint-plugin-testing-library": "6.3.2", - "jose": "5.9.4", - "jsdom": "25.0.1", - "vite": "5.4.9", - "vite-jsconfig-paths": "2.0.1", - "vite-plugin-babel-macros": "1.0.6", - "vite-plugin-eslint": "1.8.1", - "vite-plugin-svgr": "4.2.0" - }, - "overrides": { - "rollup": "4.24.0" - }, - "devDependencies": { - "@testing-library/jest-dom": "6.5.0", - "@testing-library/react": "16.0.1", - "@testing-library/user-event": "14.5.2", - "@types/testing-library__jest-dom": "6.0.0", - "@types/testing-library__react": "10.2.0", - "@vitest/coverage-istanbul": "2.1.3", - "@vitest/ui": "2.1.3", - "axe-core": "4.10.1", - "cypress": "13.13.3", - "cypress-axe": "1.5.0", - "cypress-localstorage-commands": "2.2.6", - "globals": "15.9.0", - "history": "5.3.0", - "msw": "2.3.5", - "prettier": "3.3.3", - "redux-mock-store": "1.5.4", - "@uswds/compile": "1.2.0", - "vitest": "2.1.3" - }, - "scripts": { - "start": "vite", - "start:debug": "vite --inspect=0.0.0.0:9229", - "build": "vite build", - "test": "vitest", - "test:coverage": "vitest --coverage", - "test:ui": "vitest --ui --coverage.enabled=true", - "test:e2e:interactive": "cypress open --config-file ./cypress.config.js", - "test:e2e": "cypress run --config-file ./cypress.config.js --headless", - "test:e2e:debug": "DEBUG=cypress:* cypress run --config-file ./cypress.config.js --headless", - "lint": "eslint './src/**'", - "cypress:open": "cypress open", - "uswds:update": "bunx gulp compile" - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] - }, - "babelMacros": { - "fontawesome-svg-core": { - "license": "free" + "name": "opre-ops", + "version": "1.0.1", + "license": "CC0-1.0", + "private": true, + "type": "module", + "dependencies": { + "@azure/storage-blob": "12.24.0", + "@eslint/compat": "1.2.0", + "@eslint/js": "9.12.0", + "@fortawesome/fontawesome-svg-core": "6.5.2", + "@fortawesome/free-regular-svg-icons": "6.5.2", + "@fortawesome/free-solid-svg-icons": "6.5.2", + "@fortawesome/react-fontawesome": "0.2.2", + "@nivo/bar": "0.87.0", + "@nivo/core": "0.87.0", + "@nivo/pie": "0.87.0", + "@reduxjs/toolkit": "2.2.8", + "@uswds/uswds": "3.9.0", + "@vitejs/plugin-react": "4.3.2", + "axios": "1.7.7", + "clsx": "2.1.1", + "crypto-random-string": "5.0.0", + "eslint": "9.12.0", + "eslint-config-prettier": "9.1.0", + "eslint-plugin-cypress": "4.0.0", + "eslint-plugin-import": "2.29.1", + "eslint-plugin-jest": "28.8.3", + "eslint-plugin-jsx-a11y": "6.10.0", + "eslint-plugin-prettier": "5.2.1", + "eslint-plugin-react": "7.37.1", + "eslint-plugin-react-hooks": "5.0.0", + "eslint-plugin-react-refresh": "0.4.12", + "eslint-plugin-testing-library": "6.3.2", + "jose": "5.9.4", + "js-cookie": "3.0.5", + "jsdom": "25.0.1", + "jwt-decode": "4.0.0", + "lodash": "4.17.21", + "react": "18.3.1", + "react-currency-format": "1.1.0", + "react-dom": "18.3.1", + "react-markdown": "9.0.1", + "react-modal": "3.16.1", + "react-redux": "9.1.2", + "react-router-dom": "6.27.0", + "react-select": "5.8.1", + "react-slider": "2.0.6", + "sass": "1.79.6", + "sass-loader": "14.2.1", + "styled-components": "6.1.13", + "vest": "5.4.3", + "vite": "5.4.9", + "vite-jsconfig-paths": "2.0.1", + "vite-plugin-babel-macros": "1.0.6", + "vite-plugin-eslint": "1.8.1", + "vite-plugin-svgr": "4.2.0" + }, + "overrides": { + "rollup": "4.24.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "6.5.0", + "@testing-library/react": "16.0.1", + "@testing-library/user-event": "14.5.2", + "@types/testing-library__jest-dom": "6.0.0", + "@types/testing-library__react": "10.2.0", + "@vitest/coverage-istanbul": "2.1.3", + "@vitest/ui": "2.1.3", + "axe-core": "4.10.1", + "cypress": "13.13.3", + "cypress-axe": "1.5.0", + "cypress-localstorage-commands": "2.2.6", + "globals": "15.9.0", + "history": "5.3.0", + "msw": "2.3.5", + "prettier": "3.3.3", + "redux-mock-store": "1.5.4", + "@uswds/compile": "1.2.0", + "vitest": "2.1.3" + }, + "scripts": { + "start": "vite", + "start:debug": "vite --inspect=0.0.0.0:9229", + "build": "vite build", + "test": "vitest", + "test:coverage": "vitest --coverage", + "test:ui": "vitest --ui --coverage.enabled=true", + "test:e2e:interactive": "cypress open --config-file ./cypress.config.js", + "test:e2e": "cypress run --config-file ./cypress.config.js --headless", + "test:e2e:debug": "DEBUG=cypress:* cypress run --config-file ./cypress.config.js --headless", + "lint": "eslint './src/**'", + "cypress:open": "cypress open", + "uswds:update": "bunx gulp compile" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "babelMacros": { + "fontawesome-svg-core": { + "license": "free" + } } - } } diff --git a/frontend/src/api/opsAPI.js b/frontend/src/api/opsAPI.js index 6bc78b60bd..ac4c02a2f6 100644 --- a/frontend/src/api/opsAPI.js +++ b/frontend/src/api/opsAPI.js @@ -24,7 +24,8 @@ export const opsApi = createApi({ "ServicesComponents", "ChangeRequests", "Divisions", - "Documents" + "Documents", + "Cans" ], baseQuery: fetchBaseQuery({ baseUrl: `${BACKEND_DOMAIN}/api/v1/`, diff --git a/frontend/src/components/CANs/CANFYBudgetRangeSlider/CANFYBudgetRangeSlider.jsx b/frontend/src/components/CANs/CANFYBudgetRangeSlider/CANFYBudgetRangeSlider.jsx new file mode 100644 index 0000000000..9b409b5e13 --- /dev/null +++ b/frontend/src/components/CANs/CANFYBudgetRangeSlider/CANFYBudgetRangeSlider.jsx @@ -0,0 +1,104 @@ +import React from "react"; +import DoubleRangeSlider from "../../UI/DoubleRangeSlider"; +import CurrencyFormat from "react-currency-format"; +/** + * @typedef {Object} CANFYBudgetRangeSliderProps + * @property {[number, number]} fyBudgetRange - The min and max of the fiscal year budget range + * @property {string} [legendClassname] - CSS class for the legend + * @property {[number, number]} budget - The current budget range + * @property {function([number, number]): void} setBudget - Function to update the budget + */ + +/** + * @description CANFYBudgetRangeSlider component + * @component + * @param {CANFYBudgetRangeSliderProps} props + * @returns {JSX.Element} - The CAN FY Budget Range Slider component + */ +const CANFYBudgetRangeSlider = ({ fyBudgetRange, legendClassname = "usa-label margin-top-0", budget, setBudget }) => { + const [minValue, maxValue] = budget; + const [fyBudgetMin, fyBudgetMax] = fyBudgetRange; + const [sliderValue, setSliderValue] = React.useState([0, 100]); + /** + * Calculate percentage of a value within a range + * @param {number} value - The value to calculate percentage for + * @param {number} min - The minimum value of the range + * @param {number} max - The maximum value of the range + * @returns {number} The calculated percentage + */ + const calculatePercentage = (value, min, max) => { + return ((value - min) / (max - min)) * 100; + }; + /** + * Calculate value based on percentage within a range + * @param {number} percentage - The percentage to calculate value for + * @param {number} min - The minimum value of the range + * @param {number} max - The maximum value of the range + * @returns {number} The calculated value + */ + const calculateValue = (percentage, min, max) => { + return Math.round(min + (percentage / 100) * (max - min)); + }; + + /** + * Calculate the new budget range based on slider values + * @param {[number, number]} newRange - The new range from the slider + */ + const calculateBudgetRange = (newRange) => { + const [minPercentage, maxPercentage] = newRange; + const selectedMinFYBudget = calculateValue(minPercentage, fyBudgetMin, fyBudgetMax); + const selectedMaxFYBudget = calculateValue(maxPercentage, fyBudgetMin, fyBudgetMax); + + setBudget([selectedMinFYBudget, selectedMaxFYBudget]); + setSliderValue(newRange); + }; + + React.useEffect(() => { + const minPercentage = calculatePercentage(minValue, fyBudgetMin, fyBudgetMax); + const maxPercentage = calculatePercentage(maxValue, fyBudgetMin, fyBudgetMax); + + setSliderValue([minPercentage, maxPercentage]); + }, [budget, fyBudgetRange]); + + return ( + <> +
+ +
+
+ +
+ +
+ + + - + + +
+ + ); +}; + +export default CANFYBudgetRangeSlider; diff --git a/frontend/src/components/CANs/CANFYBudgetRangeSlider/index.js b/frontend/src/components/CANs/CANFYBudgetRangeSlider/index.js new file mode 100644 index 0000000000..608bcedba0 --- /dev/null +++ b/frontend/src/components/CANs/CANFYBudgetRangeSlider/index.js @@ -0,0 +1 @@ +export { default } from "./CANFYBudgetRangeSlider.jsx"; diff --git a/frontend/src/components/CANs/CANTable/CANTable.jsx b/frontend/src/components/CANs/CANTable/CANTable.jsx index 36a3e7180b..6963ee0663 100644 --- a/frontend/src/components/CANs/CANTable/CANTable.jsx +++ b/frontend/src/components/CANs/CANTable/CANTable.jsx @@ -16,15 +16,10 @@ import styles from "./style.module.css"; * @returns {JSX.Element} */ const CANTable = ({ cans, fiscalYear }) => { - // Filter CANs by fiscal year - const filteredCANsByFiscalYear = React.useMemo(() => { - if (!fiscalYear) return cans; - return cans.filter((can) => can.funding_details.fiscal_year === fiscalYear); - }, [cans, fiscalYear]); // TODO: once in prod, change this to 25 const CANS_PER_PAGE = 10; const [currentPage, setCurrentPage] = React.useState(1); - let cansPerPage = [...filteredCANsByFiscalYear]; + let cansPerPage = [...cans]; cansPerPage = cansPerPage.slice((currentPage - 1) * CANS_PER_PAGE, currentPage * CANS_PER_PAGE); if (cansPerPage.length === 0) { @@ -52,7 +47,7 @@ const CANTable = ({ cans, fiscalYear }) => { ))} - {filteredCANsByFiscalYear.length > CANS_PER_PAGE && ( + {cans.length > CANS_PER_PAGE && ( {displayActivePeriod(activePeriod)} {obligateBy} {convertCodeForDisplay("methodOfTransfer", transfer)} - - - + {fyBudget === 0 ? ( + TBD + ) : ( + + + + )} ( + +); +const StyledSlider = styled(ReactSlider)` + width: 100%; + height: 25px; + z-index: 0; +`; + +const Thumb = ({ key, ...props }) => ( + +); + +const Track = ({ key, ...props }) => ( + +); + +const StyledTrack = styled.div` + top: 5px; + bottom: 0; + background: white; + border-radius: 999px; + border: 1px solid black; +`; + +const StyledThumb = styled.div` + height: 30px; + line-height: 30px; + width: 30px; + background-color: whitesmoke; + border: 2px solid gray; + border-radius: 50%; + cursor: grab; +`; +export default DoubleRangeSlider; diff --git a/frontend/src/components/UI/DoubleRangeSlider/index.js b/frontend/src/components/UI/DoubleRangeSlider/index.js new file mode 100644 index 0000000000..f6ff31b58f --- /dev/null +++ b/frontend/src/components/UI/DoubleRangeSlider/index.js @@ -0,0 +1 @@ +export { default } from "./DoubleRangeSlider"; diff --git a/frontend/src/components/UI/FilterButton/FilterButton.jsx b/frontend/src/components/UI/FilterButton/FilterButton.jsx index 974368e5e5..2446bca39e 100644 --- a/frontend/src/components/UI/FilterButton/FilterButton.jsx +++ b/frontend/src/components/UI/FilterButton/FilterButton.jsx @@ -9,9 +9,10 @@ import customStyles from "./FilterButton.module.css"; * @param {Function} props.applyFilter - A function to call after clicking the Apply button. * @param {Function} props.resetFilter - A function to call after clicking the Reset button. * @param {Object []} props.fieldsetList - An array of fieldsets to display in the modal. + * @param {boolean} props.disabled - Whether the button is disabled. * @returns {JSX.Element} - The procurement shop select element. */ -export const FilterButton = ({ applyFilter, resetFilter, fieldsetList }) => { +export const FilterButton = ({ applyFilter, resetFilter, fieldsetList, disabled = false }) => { const [showModal, setShowModal] = React.useState(false); const handleApplyFilter = () => { @@ -35,10 +36,11 @@ export const FilterButton = ({ applyFilter, resetFilter, fieldsetList }) => { !showModal ? "usa-button--outline text-primary" : "bg-primary-darker" } display-flex flex-align-center margin-right-0 ${customStyles.filterButton}`} onClick={() => (showModal ? setShowModal(false) : setShowModal(true))} + disabled={disabled} > diff --git a/frontend/src/pages/cans/list/CANFilterButton/CANFiilterButton.jsx b/frontend/src/pages/cans/list/CANFilterButton/CANFiilterButton.jsx index be34469d18..67ad39dd8f 100644 --- a/frontend/src/pages/cans/list/CANFilterButton/CANFiilterButton.jsx +++ b/frontend/src/pages/cans/list/CANFilterButton/CANFiilterButton.jsx @@ -4,6 +4,7 @@ import CANPortfolioComboBox from "../../../../components/CANs/CANPortfolioComboB import CANTransferComboBox from "../../../../components/CANs/CANTransferComboBox"; import FilterButton from "../../../../components/UI/FilterButton"; import useCANFilterButton from "./CANFilterButton.hooks"; +import CANFYBudgetRangeSlider from "../../../../components/CANs/CANFYBudgetRangeSlider"; /** * @typedef {import('./CANFilterTypes').FilterOption} FilterOption @@ -14,11 +15,23 @@ import useCANFilterButton from "./CANFilterButton.hooks"; * @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. + * @param {[number, number]} props.fyBudgetRange - The fiscal year budget range. + * @param {boolean} props.disabled - Whether the button is disabled. * @returns {JSX.Element} - The CAN filter button. */ -export const CANFilterButton = ({ filters, setFilters, portfolioOptions }) => { - const { activePeriod, setActivePeriod, transfer, setTransfer, portfolio, setPortfolio, applyFilter, resetFilter } = - useCANFilterButton(filters, setFilters); +export const CANFilterButton = ({ filters, setFilters, portfolioOptions, fyBudgetRange, disabled }) => { + const { + activePeriod, + setActivePeriod, + transfer, + setTransfer, + portfolio, + setPortfolio, + budget, + setBudget, + applyFilter, + resetFilter + } = useCANFilterButton(filters, setFilters, fyBudgetRange); const fieldStyles = "usa-fieldset margin-bottom-205"; const legendStyles = "usa-legend font-sans-3xs margin-top-0 padding-bottom-1 text-base-dark"; @@ -56,6 +69,17 @@ export const CANFilterButton = ({ filters, setFilters, portfolioOptions }) => { legendClassname={legendStyles} overrideStyles={{ width: "22.7rem" }} /> + , +
+
]; @@ -66,6 +90,7 @@ export const CANFilterButton = ({ filters, setFilters, portfolioOptions }) => { applyFilter={applyFilter} resetFilter={resetFilter} fieldsetList={fieldsetList} + disabled={disabled} /> ); }; diff --git a/frontend/src/pages/cans/list/CANFilterButton/CANFilterButton.hooks.js b/frontend/src/pages/cans/list/CANFilterButton/CANFilterButton.hooks.js index 27d3269931..e30fdf99be 100644 --- a/frontend/src/pages/cans/list/CANFilterButton/CANFilterButton.hooks.js +++ b/frontend/src/pages/cans/list/CANFilterButton/CANFilterButton.hooks.js @@ -3,12 +3,14 @@ import React from "react"; /** * A filter for CANs list. * @param {import ('./CANFilterTypes').Filters} filters - The current filters. + * @param{[number, number]} fyBudgetRange - The fiscal year budget range. * @param {Function} setFilters - A function to call to set the filters. */ -export const useCANFilterButton = (filters, setFilters) => { +export const useCANFilterButton = (filters, setFilters, fyBudgetRange) => { const [activePeriod, setActivePeriod] = React.useState([]); const [transfer, setTransfer] = React.useState([]); const [portfolio, setPortfolio] = React.useState([]); + const [budget, setBudget] = React.useState([]); // The useEffect() hook calls below are used to set the state appropriately when the filter tags (X) are clicked. React.useEffect(() => { @@ -29,13 +31,23 @@ export const useCANFilterButton = (filters, setFilters) => { } }, [filters.portfolio]); + React.useEffect(() => { + if (fyBudgetRange !== undefined) { + setBudget(fyBudgetRange); + } + if (filters.budget && Array.isArray(filters.budget) && filters.budget.length === 2) { + setBudget([filters.budget[0], filters.budget[1]]); + } + }, [fyBudgetRange, filters.budget]); + const applyFilter = () => { setFilters((prevState) => { return { ...prevState, activePeriod: activePeriod, transfer: transfer, - portfolio: portfolio + portfolio: portfolio, + budget: budget }; }); }; @@ -43,11 +55,9 @@ export const useCANFilterButton = (filters, setFilters) => { setFilters({ activePeriod: [], transfer: [], - portfolio: [] + portfolio: [], + budget: [] }); - setActivePeriod([]); - setTransfer([]); - setPortfolio([]); }; return { @@ -57,6 +67,8 @@ export const useCANFilterButton = (filters, setFilters) => { setTransfer, portfolio, setPortfolio, + budget, + setBudget, applyFilter, resetFilter }; diff --git a/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.hooks.js b/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.hooks.js index d7432c1119..40191c8952 100644 --- a/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.hooks.js +++ b/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.hooks.js @@ -9,6 +9,7 @@ import { useState, useEffect, useCallback } from "react"; * @property {FilterItem[]} activePeriod * @property {FilterItem[]} portfolio * @property {FilterItem[]} transfer + * @property {[number, number]} budget */ /** @@ -20,9 +21,10 @@ import { useState, useEffect, useCallback } from "react"; /** * Custom hook for managing tags list * @param {Filters} filters + * @param {[number, number]} fyBudgetRange * @returns {Tag[]} */ -export const useTagsList = (filters) => { +export const useTagsList = (filters, fyBudgetRange) => { const [tagsList, setTagsList] = useState([]); /** @@ -31,16 +33,26 @@ export const useTagsList = (filters) => { */ const updateTags = useCallback( (filterKey, filterName) => { - if (!Array.isArray(filters[filterKey])) return; + if (filterKey === "budget") { + if (Array.isArray(filters.budget) && filters.budget.length === 2) { + const [min, max] = filters.budget; + setTagsList((prevState) => [ + ...prevState.filter((t) => t.filter !== filterName), + { tagText: `$${min} - $${max}`, filter: filterName } + ]); + } else { + setTagsList((prevState) => prevState.filter((t) => t.filter !== filterName)); + } + } else if (Array.isArray(filters[filterKey])) { + const selectedTags = filters[filterKey].map((item) => ({ + tagText: item.title, + filter: filterName + })); - const selectedTags = filters[filterKey].map((item) => ({ - tagText: item.title, - filter: filterName - })); - - setTagsList((prevState) => [...prevState.filter((t) => t.filter !== filterName), ...selectedTags]); + setTagsList((prevState) => [...prevState.filter((t) => t.filter !== filterName), ...selectedTags]); + } }, - [filters] + [filters, fyBudgetRange] ); useEffect(() => { @@ -55,6 +67,10 @@ export const useTagsList = (filters) => { updateTags("transfer", "transfer"); }, [filters.transfer, updateTags]); + useEffect(() => { + updateTags("budget", "budget"); + }, [filters.budget, updateTags]); + return tagsList; }; @@ -83,6 +99,12 @@ export const removeFilter = (tag, setFilters) => { transfer: prevState.transfer.filter((transfer) => transfer.title !== tag.tagText) })); break; + case "budget": + setFilters((prevState) => ({ + ...prevState, + budget: [] + })); + 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 821bfcb849..35765e403d 100644 --- a/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.jsx +++ b/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.jsx @@ -8,15 +8,14 @@ import { useTagsList, removeFilter } from "./CANFilterTags.hooks"; * @param {Object} props - The component props. * @param {import('./CANFilterTags.hooks').Filters} props.filters - The current filters. * @param {() => void} props.setFilters - A function to call to set the filters. + * @param {[number, number]} props.fyBudgetRange - The initial budget range. * @returns {JSX.Element|null} The filter tags component or null if no tags. */ -export const CANFilterTags = ({ filters, setFilters }) => { - const tagsList = useTagsList(filters); +const CANFilterTags = ({ filters, setFilters, fyBudgetRange }) => { + const tagsList = useTagsList(filters, fyBudgetRange); const tagsListByFilter = _.groupBy(tagsList, "filter"); - const tagsListByFilterMerged = Object.values(tagsListByFilter) - .flat() - .sort((a, b) => a.tagText.localeCompare(b.tagText)); + const tagsListByFilterMerged = Object.values(tagsListByFilter).flat(); if (tagsList.length === 0) { return null; diff --git a/frontend/src/pages/cans/list/CanList.helpers.js b/frontend/src/pages/cans/list/CanList.helpers.js index 74c1b7534a..29b5244983 100644 --- a/frontend/src/pages/cans/list/CanList.helpers.js +++ b/frontend/src/pages/cans/list/CanList.helpers.js @@ -72,6 +72,7 @@ const sortCANs = (cans) => { */ const applyAdditionalFilters = (cans, filters) => { let filteredCANs = cans; + console.log({ filters, cans }); // Filter by active period if (filters.activePeriod && filters.activePeriod.length > 0) { @@ -94,6 +95,15 @@ const applyAdditionalFilters = (cans, filters) => { ) ); } + + if (filters.budget && filters.budget.length > 0) { + filteredCANs = filteredCANs.filter((can) => { + return can.funding_budgets.some((budget) => { + return budget.budget >= filters.budget[0] && budget.budget <= filters.budget[1]; + }); + }); + } + // TODO: Add other filters here // Example: // if (filters.someOtherFilter && filters.someOtherFilter.length > 0) { @@ -126,3 +136,18 @@ export const getPortfolioOptions = (cans) => { title: portfolio })); }; + +export const getSortedFYBudgets = (cans) => { + if (!cans || cans.length === 0) { + return []; + } + + const funding_budgets = cans.reduce((acc, can) => { + acc.add(can.funding_budgets); + return acc; + }, new Set()); + + return Array.from(funding_budgets) + .flatMap((itemArray) => itemArray.map((item) => item.budget)) + .sort((a, b) => a - b); +}; diff --git a/frontend/src/pages/cans/list/CanList.jsx b/frontend/src/pages/cans/list/CanList.jsx index f12e1e5964..0cf8dd700b 100644 --- a/frontend/src/pages/cans/list/CanList.jsx +++ b/frontend/src/pages/cans/list/CanList.jsx @@ -10,8 +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, getSortedFYBudgets } from "./CanList.helpers"; import CANFilterTags from "./CANFilterTags"; -import { getPortfolioOptions, sortAndFilterCANs } from "./CanList.helpers"; /** * Page for the CAN List. @@ -29,10 +29,17 @@ const CanList = () => { const [filters, setFilters] = React.useState({ activePeriod: [], transfer: [], - portfolio: [] + portfolio: [], + budget: [] }); - const sortedCANs = sortAndFilterCANs(canList, myCANsUrl, activeUser, filters) || []; + const filteredCANsByFiscalYear = React.useMemo(() => { + if (!fiscalYear || !canList) return []; + return canList.filter((can) => can.funding_details.fiscal_year === fiscalYear); + }, [canList, fiscalYear]); + const sortedCANs = sortAndFilterCANs(filteredCANsByFiscalYear, myCANsUrl, activeUser, filters) || []; const portfolioOptions = getPortfolioOptions(canList); + const sortedFYBudgets = getSortedFYBudgets(filteredCANsByFiscalYear); + const [minFYBudget, maxFYBudget] = [sortedFYBudgets[0], sortedFYBudgets[sortedFYBudgets.length - 1]]; if (isLoading) { return ( @@ -44,7 +51,6 @@ const CanList = () => { if (isError) { return ; } - const CANFiscalYearSelect = () => { return ( { filters={filters} setFilters={setFilters} portfolioOptions={portfolioOptions} + fyBudgetRange={[minFYBudget, maxFYBudget]} + disabled={sortedCANs.length === 0} /> } FYSelect={} @@ -84,6 +92,7 @@ const CanList = () => { } />