diff --git a/src/__tests__/api/makeSequentialRequests.test.js b/src/__tests__/api/makeSequentialRequests.test.js deleted file mode 100644 index d8ef4c8af..000000000 --- a/src/__tests__/api/makeSequentialRequests.test.js +++ /dev/null @@ -1,251 +0,0 @@ -import { makeSequentialRequests } from "../../api/makeSequentialRequests"; -import * as callModule from "../../api/call"; - -jest.mock("../../api/call", () => ({ - ...jest.requireActual("../../api/call"), - countryApiCall: jest.fn(), -})); - -describe("makeSequentialRequests", () => { - beforeEach(() => { - jest.clearAllMocks(); - console.error = jest.fn(); // Prevent console error output during tests - }); - - describe("Given a list of successful requests", () => { - test("it should process all requests sequentially and return successful results", async () => { - const requests = [ - { countryId: "us", path: "/api/users", method: "GET" }, - { countryId: "ca", path: "/api/posts", method: "GET" }, - { countryId: "uk", path: "/api/comments", method: "GET" }, - ]; - - const mockResponses = [ - { data: "users data" }, - { data: "posts data" }, - { data: "comments data" }, - ]; - - // Mock successful responses - callModule.countryApiCall - .mockResolvedValueOnce(mockResponses[0]) - .mockResolvedValueOnce(mockResponses[1]) - .mockResolvedValueOnce(mockResponses[2]); - - const result = await makeSequentialRequests(requests); - - expect(callModule.countryApiCall).toHaveBeenCalledTimes(3); - expect(callModule.countryApiCall).toHaveBeenNthCalledWith( - 1, - "us", - "/api/users", - undefined, - "GET", - undefined, - undefined, - ); - expect(callModule.countryApiCall).toHaveBeenNthCalledWith( - 2, - "ca", - "/api/posts", - undefined, - "GET", - undefined, - undefined, - ); - expect(callModule.countryApiCall).toHaveBeenNthCalledWith( - 3, - "uk", - "/api/comments", - undefined, - "GET", - undefined, - undefined, - ); - - expect(result).toEqual({ - results: [ - { - status: "success", - requestIndex: 0, - requestSetup: requests[0], - response: mockResponses[0], - error: null, - }, - { - status: "success", - requestIndex: 1, - requestSetup: requests[1], - response: mockResponses[1], - error: null, - }, - { - status: "success", - requestIndex: 2, - requestSetup: requests[2], - response: mockResponses[2], - error: null, - }, - ], - summary: { - total: 3, - successes: 3, - errors: 0, - }, - }); - }); - - test("it should call onComplete callback after each request", async () => { - const requests = [ - { countryId: "us", path: "/api/users", method: "GET" }, - { countryId: "ca", path: "/api/posts", method: "GET" }, - ]; - - callModule.countryApiCall - .mockResolvedValueOnce({ data: "users data" }) - .mockResolvedValueOnce({ data: "posts data" }); - - const onComplete = jest.fn(); - - await makeSequentialRequests(requests, onComplete); - - expect(onComplete).toHaveBeenCalledTimes(2); - expect(onComplete).toHaveBeenNthCalledWith(1, { - current: 0, - total: 2, - successCount: 1, - errorCount: 0, - }); - expect(onComplete).toHaveBeenNthCalledWith(2, { - current: 1, - total: 2, - successCount: 2, - errorCount: 0, - }); - }); - }); - - describe("Given a list with mixed successful and failed requests", () => { - test("it should continue processing after failures and track error counts", async () => { - const requests = [ - { countryId: "us", path: "/api/users", method: "GET" }, - { countryId: "ca", path: "/api/posts", method: "GET" }, - { countryId: "uk", path: "/api/comments", method: "GET" }, - ]; - - const mockError = new Error("API Error"); - mockError.response = { status: 404, data: "Not Found" }; - - // First request succeeds, second fails, third succeeds - callModule.countryApiCall - .mockResolvedValueOnce({ data: "users data" }) - .mockRejectedValueOnce(mockError) - .mockResolvedValueOnce({ data: "comments data" }); - - const result = await makeSequentialRequests(requests); - - expect(callModule.countryApiCall).toHaveBeenCalledTimes(3); - expect(result.results[0].status).toBe("success"); - expect(result.results[1].status).toBe("error"); - expect(result.results[1].error).toEqual({ - message: "API Error", - statusCode: 404, - data: "Not Found", - }); - expect(result.results[2].status).toBe("success"); - expect(result.summary).toEqual({ - total: 3, - successes: 2, - errors: 1, - }); - expect(console.error).toHaveBeenCalledWith( - "Request 2 failed:", - "API Error", - ); - }); - }); - - describe("Given a request with secondAttempt flag", () => { - test("it should pass the secondAttempt parameter to the API call", async () => { - const requests = [ - { - countryId: "us", - path: "/api/users", - method: "GET", - secondAttempt: true, - }, - ]; - - callModule.countryApiCall.mockResolvedValueOnce({ data: "users data" }); - - await makeSequentialRequests(requests); - - expect(callModule.countryApiCall).toHaveBeenCalledWith( - "us", - "/api/users", - undefined, - "GET", - true, - undefined, - ); - }); - }); - - describe("Given a POST request with body", () => { - test("it should pass the body to the API call", async () => { - const requestBody = { name: "John", age: 30 }; - const requests = [ - { - countryId: "us", - path: "/api/users", - method: "POST", - body: requestBody, - }, - ]; - - callModule.countryApiCall.mockResolvedValueOnce({ data: "created" }); - - await makeSequentialRequests(requests); - - expect(callModule.countryApiCall).toHaveBeenCalledWith( - "us", - "/api/users", - requestBody, - "POST", - undefined, - undefined, - ); - }); - }); - - describe("Given an empty requests array", () => { - test("it should return empty results with zero counts", async () => { - const requests = []; - - const result = await makeSequentialRequests(requests); - - expect(callModule.countryApiCall).not.toHaveBeenCalled(); - expect(result).toEqual({ - results: [], - summary: { - total: 0, - successes: 0, - errors: 0, - }, - }); - }); - }); - - describe("Given an unexpected error in the main function", () => { - test("it should throw the error", async () => { - // Force an error in the main function by setting requests to null - const requests = null; - - await expect(makeSequentialRequests(requests)).rejects.toThrow(); - expect(console.error).toHaveBeenCalledWith( - "Sequential requests failed:", - expect.any(Error), - ); - }); - }); -}); diff --git a/src/__tests__/api/makeSequentialSimulationRequests.test.js b/src/__tests__/api/makeSequentialSimulationRequests.test.js new file mode 100644 index 000000000..9c405c4a1 --- /dev/null +++ b/src/__tests__/api/makeSequentialSimulationRequests.test.js @@ -0,0 +1,247 @@ +import * as callModule from "../../api/call"; +import { makeSequentialSimulationRequests } from "../../api/makeSequentialSimulationRequests"; + +jest.mock("../../api/call", () => ({ + ...jest.requireActual("../../api/call"), + asyncApiCall: jest.fn(), +})); + +describe("makeSequentialSimulationRequests", () => { + beforeEach(() => { + jest.clearAllMocks(); + console.error = jest.fn(); // Prevent console error output during tests + }); + + describe("Given a list of successful requests", () => { + test("it should process all requests sequentially and return successful results", async () => { + const requests = [ + { + year: 2020, + path: "/api/simulation", + body: { policy: "test" }, + interval: 1000, + firstInterval: 200, + }, + { + year: 2021, + path: "/api/simulation", + body: { policy: "test" }, + interval: 1000, + firstInterval: 200, + }, + { + year: 2022, + path: "/api/simulation", + body: { policy: "test" }, + interval: 1000, + firstInterval: 200, + }, + ]; + + const mockResponses = [ + { result: { budget: { impact: 1.5e9 } } }, + { result: { budget: { impact: 2.3e9 } } }, + { result: { budget: { impact: 3.1e9 } } }, + ]; + + // Mock successful responses + callModule.asyncApiCall + .mockResolvedValueOnce(mockResponses[0]) + .mockResolvedValueOnce(mockResponses[1]) + .mockResolvedValueOnce(mockResponses[2]); + + const result = await makeSequentialSimulationRequests(requests); + + expect(callModule.asyncApiCall).toHaveBeenCalledTimes(3); + expect(callModule.asyncApiCall).toHaveBeenNthCalledWith( + 1, + requests[0].path, + requests[0].body, + requests[0].interval, + requests[0].firstInterval, + ); + expect(callModule.asyncApiCall).toHaveBeenNthCalledWith( + 2, + requests[1].path, + requests[1].body, + requests[1].interval, + requests[1].firstInterval, + ); + expect(callModule.asyncApiCall).toHaveBeenNthCalledWith( + 3, + requests[2].path, + requests[2].body, + requests[2].interval, + requests[2].firstInterval, + ); + + expect(result).toEqual({ + results: [ + { + status: "success", + requestIndex: 0, + simulationRequestSetup: requests[0], + result: mockResponses[0].result, + error: null, + }, + { + status: "success", + requestIndex: 1, + simulationRequestSetup: requests[1], + result: mockResponses[1].result, + error: null, + }, + { + status: "success", + requestIndex: 2, + simulationRequestSetup: requests[2], + result: mockResponses[2].result, + error: null, + }, + ], + summary: { + total: 3, + successes: 3, + errors: 0, + }, + }); + }); + + test("it should call onComplete callback after each request", async () => { + const requests = [ + { + year: 2020, + path: "/api/simulation", + body: { policy: "test" }, + interval: 1000, + firstInterval: 200, + }, + { + year: 2021, + path: "/api/simulation", + body: { policy: "test" }, + interval: 1000, + firstInterval: 200, + }, + ]; + + callModule.asyncApiCall + .mockResolvedValueOnce({ result: { budget: { impact: 1.5e9 } } }) + .mockResolvedValueOnce({ result: { budget: { impact: 2.3e9 } } }); + + const onComplete = jest.fn(); + + await makeSequentialSimulationRequests(requests, onComplete); + + expect(onComplete).toHaveBeenCalledTimes(2); + expect(onComplete).toHaveBeenNthCalledWith(1, { + current: 0, + total: 2, + successCount: 1, + errorCount: 0, + }); + expect(onComplete).toHaveBeenNthCalledWith(2, { + current: 1, + total: 2, + successCount: 2, + errorCount: 0, + }); + }); + }); + + describe("Given a list with mixed successful and failed requests", () => { + test("it should continue processing after failures and track error counts", async () => { + const requests = [ + { + year: 2020, + path: "/api/simulation", + body: { policy: "test" }, + interval: 1000, + firstInterval: 200, + }, + { + year: 2021, + path: "/api/simulation", + body: { policy: "test" }, + interval: 1000, + firstInterval: 200, + }, + { + year: 2022, + path: "/api/simulation", + body: { policy: "test" }, + interval: 1000, + firstInterval: 200, + }, + ]; + + const mockError = new Error("API Error"); + mockError.response = { status: 404, data: "Not Found" }; + mockError.config = { + url: "/api/simulation", + data: JSON.stringify({ policy: "test" }), + }; + mockError.request = requests[1]; // Include the request setup in the error + + // First request succeeds, second fails, third succeeds + callModule.asyncApiCall + .mockResolvedValueOnce({ result: { budget: { impact: 1.5e9 } } }) + .mockRejectedValueOnce(mockError) + .mockResolvedValueOnce({ result: { budget: { impact: 3.1e9 } } }); + + const result = await makeSequentialSimulationRequests(requests); + + expect(callModule.asyncApiCall).toHaveBeenCalledTimes(3); + expect(result.results[0].status).toBe("success"); + expect(result.results[1].status).toBe("error"); + expect(result.results[1].error).toEqual({ + message: "API Error", + statusCode: 404, + data: "Not Found", + }); + expect(result.results[2].status).toBe("success"); + expect(result.summary).toEqual({ + total: 3, + successes: 2, + errors: 1, + }); + expect(console.error).toHaveBeenCalledWith( + "Request 2 failed:", + "API Error", + ); + }); + }); + + describe("Given an empty requests array", () => { + test("it should return empty results with zero counts", async () => { + const requests = []; + + const result = await makeSequentialSimulationRequests(requests); + + expect(callModule.asyncApiCall).not.toHaveBeenCalled(); + expect(result).toEqual({ + results: [], + summary: { + total: 0, + successes: 0, + errors: 0, + }, + }); + }); + }); + + describe("Given an unexpected error in the main function", () => { + test("it should throw the error", async () => { + // Force an error in the main function by setting requests to null + const requests = null; + + await expect( + makeSequentialSimulationRequests(requests), + ).rejects.toThrow(); + expect(console.error).toHaveBeenCalledWith( + "Sequential requests failed:", + expect.any(Error), + ); + }); + }); +}); diff --git a/src/__tests__/pages/policy/output/PolicyMobileCalculator.test.js b/src/__tests__/pages/policy/output/PolicyMobileCalculator.test.js index 63fa779b2..a38e4831a 100644 --- a/src/__tests__/pages/policy/output/PolicyMobileCalculator.test.js +++ b/src/__tests__/pages/policy/output/PolicyMobileCalculator.test.js @@ -13,6 +13,11 @@ import { impactKeys } from "../../../../pages/policy/output/ImpactTypes.jsx"; jest.mock("react-plotly.js", () => jest.fn()); +// Mock the utils module +jest.mock("../../../../pages/policy/output/utils", () => ({ + determineIfMultiYear: jest.fn().mockReturnValue(false), +})); + jest.mock("react-router-dom", () => ({ ...jest.requireActual("react-router-dom"), useSearchParams: jest.fn(), diff --git a/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js b/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js new file mode 100644 index 000000000..c4d3fa3e3 --- /dev/null +++ b/src/__tests__/pages/policy/output/budget/MultiYearBudgetaryImpact.test.js @@ -0,0 +1,240 @@ +// window.matchMedia used in Ant Design Table components +import { render, screen } from "@testing-library/react"; +import MultiYearBudgetaryImpact, { + getYearlyImpacts, + getYearRangeFromArray, + roundToBillions, + getFederalBudgetImpact, +} from "pages/policy/output/budget/MultiYearBudgetaryImpact"; +import "@testing-library/jest-dom"; +import data from "../../../../__setup__/data.json"; + +window.matchMedia = + window.matchMedia || + function () { + return { + matches: false, + addListener: function () {}, + removeListener: function () {}, + }; + }; + +describe("MultiYearBudgetaryImpact", () => { + const mockSingleYearResults = [ + { + simulationRequestSetup: { + year: 2020, + path: "/api/simulation", + body: null, + interval: 1000, + firstInterval: 200, + }, + result: { + budget: { + budgetary_impact: 5e9, + benefit_spending_impact: 6e9, + tax_revenue_impact: 7e9, + state_tax_revenue_impact: 8e9, + }, + }, + }, + { + simulationRequestSetup: { + year: 2021, + path: "/api/simulation", + body: null, + interval: 1000, + firstInterval: 200, + }, + result: { + budget: { + budgetary_impact: 9e9, + benefit_spending_impact: 10e9, + tax_revenue_impact: 11e9, + state_tax_revenue_impact: 12e9, + }, + }, + }, + ]; + + const mockImpact = { + budget: { + budgetary_impact: 13e9, + benefit_spending_impact: 14e9, + tax_revenue_impact: 15e9, + state_tax_revenue_impact: 16e9, + }, + }; + + describe("Utility Functions", () => { + test("getYearlyImpacts should correctly format yearly impacts", () => { + // Given + const singleYearResults = mockSingleYearResults; + const budgetKey = "budgetary_impact"; + const countryId = "us"; + + // When + const result = getYearlyImpacts(singleYearResults, countryId, budgetKey); + + // Then + expect(result).toEqual({ + 2020: String(mockSingleYearResults[0].result.budget[budgetKey] / 1e9), + 2021: String(mockSingleYearResults[1].result.budget[budgetKey] / 1e9), + }); + }); + test("getYearlyImpacts should correctly format yearly impacts with formula", () => { + // Given + const singleYearResults = mockSingleYearResults; + const formula = (budget) => + budget.tax_revenue_impact - budget.state_tax_revenue_impact; + const countryId = "us"; + const budgetKey = null; + const expectedImpact = singleYearResults.map((item) => + formula(item.result.budget), + ); + const expectedResult = { + 2020: String(expectedImpact[0] / 1e9), + 2021: String(expectedImpact[1] / 1e9), + }; + // When + const result = getYearlyImpacts( + singleYearResults, + countryId, + budgetKey, + formula, + ); + // Then + expect(result).toEqual(expectedResult); + }); + + test("getYearRangeFromArray should format year range correctly", () => { + // Given + const years = [2020, 2021, 2022, 2023, 2024]; + const yearsMerged = "2020-24"; + + // When + const result = getYearRangeFromArray(years); + + // Then + expect(result).toBe(yearsMerged); + }); + + test("roundToBillions should round numbers correctly", () => { + // Given + const number = 1.2345e9; + const decimals = 2; + + // When + const result = roundToBillions(number, decimals); + + // Then + expect(result).toBe("1.23"); + }); + }); + + describe("Component Behavior", () => { + const mockMetadata = data["metadataUS"]; + + test("should render with correct title", () => { + // Given + const props = { + impact: mockImpact, + singleYearResults: mockSingleYearResults, + metadata: mockMetadata, + policyLabel: "Test Policy", + region: "us", + }; + + // When + render(); + + // Then + expect( + screen.getByText("Test Policy in the US, 2020-21"), + ).toBeInTheDocument(); + }); + + test("should render table with correct columns", () => { + // Given + const props = { + impact: mockImpact, + singleYearResults: mockSingleYearResults, + metadata: mockMetadata, + policyLabel: "Test Policy", + region: "us", + }; + + // When + render(); + + // Then + expect( + screen.getByText("Net revenue impact (billions)"), + ).toBeInTheDocument(); + expect(screen.getByText("2020")).toBeInTheDocument(); + expect(screen.getByText("2021")).toBeInTheDocument(); + expect(screen.getByText("2020-21")).toBeInTheDocument(); + }); + + test("should render table with correct data", () => { + // Given + const props = { + impact: mockImpact, + singleYearResults: mockSingleYearResults, + metadata: mockMetadata, + policyLabel: "Test Policy", + region: "us", + }; + + const expectedHeaders = [ + "Federal tax", + "Benefits", + "Federal budget", + "State tax", + ]; + + // When + render(); + + // Make sure all headers render + expectedHeaders.forEach((header) => { + expect(screen.getByText(header)).toBeInTheDocument(); + }); + + // Check for expected values from mock impacts + expect( + screen.getByText(mockImpact.budget.budgetary_impact / 1e9), + ).toBeInTheDocument(); + expect( + screen.getByText(mockImpact.budget.benefit_spending_impact / 1e9), + ).toBeInTheDocument(); + expect( + screen.getByText( + mockSingleYearResults[0].result.budget.budgetary_impact / 1e9, + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + mockSingleYearResults[1].result.budget.budgetary_impact / 1e9, + ), + ).toBeInTheDocument(); + }); + }); + describe("Formula functions", () => { + test("getFederalBudgetImpact should return correct value", () => { + // Given + const budget = { + budgetary_impact: 5e9, + benefit_spending_impact: 6e9, + tax_revenue_impact: 7e9, + state_tax_revenue_impact: 4e9, + }; + + // When + const result = getFederalBudgetImpact(budget); + + // Then + expect(result).toBe(3e9); + }); + }); +}); diff --git a/src/__tests__/pages/policy/rightSidebar/MultiYearSelector.test.js b/src/__tests__/pages/policy/rightSidebar/MultiYearSelector.test.js new file mode 100644 index 000000000..5b2a828c4 --- /dev/null +++ b/src/__tests__/pages/policy/rightSidebar/MultiYearSelector.test.js @@ -0,0 +1,173 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { useSearchParams } from "react-router-dom"; +import MultiYearSelector, { + calculateDefaultSimLength, + findLastSimYearFromMetadata, + generateSimYearsMenuItems, + validateSimYears, +} from "pages/policy/rightSidebar/MultiYearSelector"; +import "@testing-library/jest-dom"; + +// Mock react-router-dom +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useSearchParams: jest.fn(), +})); + +// Mock useDisplayCategory +jest.mock("layout/Responsive", () => ({ + useDisplayCategory: () => "desktop", +})); + +describe("MultiYearSelector", () => { + const mockMetadata = { + countryId: "us", + economy_options: { + time_period: [ + { name: 2020 }, + { name: 2021 }, + { name: 2022 }, + { name: 2023 }, + { name: 2024 }, + ], + }, + }; + + const mockStartYear = 2020; + + beforeEach(() => { + // Reset mocks before each test + jest.clearAllMocks(); + useSearchParams.mockReturnValue([new URLSearchParams(), jest.fn()]); + }); + + describe("Utility Functions", () => { + test("findLastSimYearFromMetadata should return the last year from metadata", () => { + // Given + const metadata = mockMetadata; + + // When + const result = findLastSimYearFromMetadata(metadata); + + // Then + expect(result).toBe(2024); + }); + + test("calculateDefaultSimLength should return correct default length", () => { + // Given + const metadata = mockMetadata; + const startYear = mockStartYear; + + // When + const result = calculateDefaultSimLength(metadata, startYear); + + // Then + expect(result).toBe(5); // Default for US is 10, but limited by available years + }); + + test("validateSimYears should validate year range correctly", () => { + // Given + const metadata = mockMetadata; + const startYear = mockStartYear; + + // When/Then + expect(validateSimYears("3", metadata, startYear)).toBe(true); + expect(validateSimYears("0", metadata, startYear)).toBe(false); + expect(validateSimYears("6", metadata, startYear)).toBe(false); + expect(validateSimYears(null, metadata, startYear)).toBe(false); + }); + + test("generateSimYearsMenuItems should create correct menu items", () => { + // Given + const metadata = mockMetadata; + const startYear = mockStartYear; + + // When + const result = generateSimYearsMenuItems(metadata, startYear); + + // Then + expect(result).toHaveLength(5); + expect(result[0]).toEqual({ label: 1, value: 1 }); + expect(result[4]).toEqual({ label: 5, value: 5 }); + }); + }); + + describe("Component Behavior", () => { + test("should initialize with default values when no simYears in URL", () => { + // Given + useSearchParams.mockReturnValue([new URLSearchParams(), jest.fn()]); + + // When + render( + , + ); + + // Then + expect(screen.getByRole("switch")).not.toBeChecked(); + // Ant Design Select component uses title attribute to display the value + expect(screen.getByTitle("5")).toBeInTheDocument(); + }); + + test("should initialize with URL parameters when simYears is present", () => { + // Given + useSearchParams.mockReturnValue([ + new URLSearchParams("simYears=3"), + jest.fn(), + ]); + + // When + render( + , + ); + + // Then + expect(screen.getByRole("switch")).toBeChecked(); + expect(screen.getByTitle("3")).toBeInTheDocument(); + }); + + test("should update URL when switch is toggled on", () => { + // Given + const setSearchParams = jest.fn(); + useSearchParams.mockReturnValue([ + new URLSearchParams({ focus: "policyOutput.policyBreakdown" }), + setSearchParams, + ]); + + // When + render( + , + ); + fireEvent.click(screen.getByRole("switch")); + + // Then + expect(setSearchParams).toHaveBeenCalledWith(expect.any(Function)); + const searchParamsUpdate = setSearchParams.mock.calls[0][0]( + new URLSearchParams(), + ); + expect(searchParamsUpdate.get("simYears")).toBe("5"); + }); + + test("should update URL when years selection changes", () => { + // Given + const setSearchParams = jest.fn(); + useSearchParams.mockReturnValue([ + new URLSearchParams("simYears=3"), + setSearchParams, + ]); + + // When + render( + , + ); + fireEvent.mouseDown(screen.getByRole("combobox")); + fireEvent.click(screen.getByTitle("2")); + + // Then + expect(setSearchParams).toHaveBeenCalledWith(expect.any(Function)); + const searchParamsUpdate = setSearchParams.mock.calls[0][0]( + new URLSearchParams(), + ); + expect(searchParamsUpdate.get("simYears")).toBe("2"); + }); + }); +}); diff --git a/src/api/makeSequentialRequests.js b/src/api/makeSequentialSimulationRequests.js similarity index 51% rename from src/api/makeSequentialRequests.js rename to src/api/makeSequentialSimulationRequests.js index 8861cf59c..870ffb7f6 100644 --- a/src/api/makeSequentialRequests.js +++ b/src/api/makeSequentialSimulationRequests.js @@ -1,20 +1,23 @@ -import { countryApiCall } from "./call.js"; +import { + SocietyWideImpactUK, + SocietyWideImpactUS, +} from "../schemas/societyWideImpact.js"; +import { asyncApiCall } from "./call.js"; import * as yup from "yup"; -export const RequestSetup = yup.object({ - countryId: yup.string().required(), +export const SimulationRequestSetup = yup.object({ + year: yup.number().required(), path: yup.string().required(), - body: yup.object().notRequired(), - method: yup.string().notRequired(), - secondAttempt: yup.boolean().notRequired(), - fetchMethod: yup.mixed().notRequired(), // Note: yup has no function validator + body: yup.object().notRequired().default(null), + interval: yup.number().default(1000), + firstInterval: yup.number().default(200), }); -export const SequentialResult = yup.object({ +export const SequentialSimulationResult = yup.object({ status: yup.string().required(), requestIndex: yup.number().required(), - requestSetup: RequestSetup.required(), - response: yup.object().notRequired(), + simulationRequestSetup: SimulationRequestSetup.required(), + result: yup.mixed().oneOf([SocietyWideImpactUK, SocietyWideImpactUS]), error: yup .object({ message: yup.string().notRequired(), @@ -25,17 +28,31 @@ export const SequentialResult = yup.object({ .default(null), }); +export const SequentialSimulationResultCollection = yup.object({ + results: yup.array(SequentialSimulationResult).required(), + summary: yup + .object({ + total: yup.number().required(), + successes: yup.number().required(), + errors: yup.number().required(), + }) + .required(), +}); + /** - * Make sequential API requests, waiting for each to complete before starting the next - * @param {Array} requests - Array of RequestSetup objects; keys and values correspond with apiCall args + * Make sequential requests to the simulation worker and API, waiting for each to complete before starting the next + * @param {Array} requests - Array of SimulationRequestSetup objects; keys and values correspond with apiCall args * @param {Function} [onComplete = null] - Optional callback for when an individual request completes - * @returns {Promise} - Promise resolving to a formatted object containing the results of each request + * @returns {Promise} - Promise resolving to a formatted object containing the results of each request * and a summary of the request process * The return Object contains the following keys: * - results {Array}: Array of SequentialResult instances */ -export async function makeSequentialRequests(requests, onComplete = null) { +export async function makeSequentialSimulationRequests( + requests, + onComplete = null, +) { const results = []; let successCount = 0; let errorCount = 0; @@ -46,30 +63,28 @@ export async function makeSequentialRequests(requests, onComplete = null) { try { // Make the request and wait for it to complete - const response = await countryApiCall( - requestSetup.countryId, + const response = await asyncApiCall( requestSetup.path, requestSetup.body, - requestSetup.method, - requestSetup.secondAttempt, - requestSetup.fetchMethod, + requestSetup.interval, + requestSetup.firstInterval, ); - const validResult = SequentialResult.cast({ - status: "success", - requestIndex: i, - requestSetup: requestSetup, - response: response, - }); - - results.push(validResult); + results.push( + SequentialSimulationResult.cast({ + status: "success", + requestIndex: i, + simulationRequestSetup: SimulationRequestSetup.cast(requestSetup), + result: response.result, + }), + ); successCount++; } catch (error) { console.error(`Request ${i + 1} failed:`, error.message); results.push( - SequentialResult.cast({ + SequentialSimulationResult.cast({ status: "error", requestIndex: i, error: { @@ -77,7 +92,7 @@ export async function makeSequentialRequests(requests, onComplete = null) { statusCode: error.response?.status, data: error.response?.data, }, - requestSetup: requestSetup, + simulationRequestSetup: SimulationRequestSetup.cast(requestSetup), }), ); diff --git a/src/layout/Menu.jsx b/src/layout/Menu.jsx index b4cc817bd..e05d35b18 100644 --- a/src/layout/Menu.jsx +++ b/src/layout/Menu.jsx @@ -5,7 +5,7 @@ import { motion } from "framer-motion"; import { Tag } from "antd"; function MenuItem(props) { - const { name, label, selected, onSelect } = props; + const { name, label, selected, onSelect, disabled } = props; const betaBadge = BETA; const isBeta = label.includes(" (experimental)"); @@ -14,7 +14,7 @@ function MenuItem(props) { return ( onSelect(name)} - whileHover={{ backgroundColor: "white" }} + onClick={disabled ? () => undefined : () => onSelect(name)} + whileHover={!disabled && { backgroundColor: "white" }} transition={{ duration: 0.001 }} > -

+

{selected === name && ( BETA; const isBeta = label.includes(" (experimental)"); const adjustedLabel = isBeta ? label.slice(0, -14) : label; @@ -84,6 +91,7 @@ function MenuItemGroup(props) { if (child.children) { expandedChildren.push( undefined : toggleExpanded} style={{ - color: style.colors.BLACK, + color: disabled ? style.colors.DARK_GRAY : style.colors.BLACK, paddingTop: 5, paddingBottom: 5, - cursor: "pointer", + cursor: disabled ? "not-allowed" : "pointer", paddingLeft: 10, borderRadius: 8, display: "flex", alignItems: "center", flexDirection: "row", }} - whileHover={{ backgroundColor: "white" }} + whileHover={!disabled && { backgroundColor: "white" }} >

{selected === name && ( ; diff --git a/src/pages/BlogPage.jsx b/src/pages/BlogPage.jsx index 8f5a96736..b2c067c40 100644 --- a/src/pages/BlogPage.jsx +++ b/src/pages/BlogPage.jsx @@ -125,7 +125,6 @@ function NotebookCell({ data }) { outputCellComponent = null; } else { const outputType = Object.keys(outputCell)[0]; - console.log(outputType); if (outputType === "text/plain") { outputCellComponent = ( @@ -213,7 +212,6 @@ function NotebookOutputMarkdown({ data }) { } function NotebookOutputPlotly({ data }) { - console.log(data); const title = data.layout?.title?.text; const displayCategory = useDisplayCategory(); diff --git a/src/pages/PolicyPage.jsx b/src/pages/PolicyPage.jsx index f024e71b5..9a81d0514 100644 --- a/src/pages/PolicyPage.jsx +++ b/src/pages/PolicyPage.jsx @@ -20,6 +20,7 @@ import SearchParamNavButton from "../controls/SearchParamNavButton"; import style from "../style"; import DeprecationModal from "../modals/DeprecationModal"; import { impactKeys } from "../pages/policy/output/ImpactTypes.jsx"; +import { determineIfMultiYear } from "./policy/output/utils.js"; export function ParameterSearch(props) { const { metadata, callback } = props; @@ -110,6 +111,7 @@ export default function PolicyPage(props) { const [searchParams, setSearchParams] = useSearchParams(); const focus = searchParams.get("focus") || ""; + const isMultiYear = determineIfMultiYear(searchParams); const isOutput = focus.includes("policyOutput"); // Evaluate if policy is deprecated or not @@ -188,6 +190,7 @@ export default function PolicyPage(props) { // Check if the current focus is within validFocusValues if ( focus === "policyOutput.policyBreakdown" || + (isMultiYear && focus === "policyOutput.budgetaryImpact") || focus === "policyOutput.codeReproducibility" || validFocusValues.includes(stripped_focus) ) { diff --git a/src/pages/UserProfilePage.jsx b/src/pages/UserProfilePage.jsx index 0fb3398a7..2e132c82d 100644 --- a/src/pages/UserProfilePage.jsx +++ b/src/pages/UserProfilePage.jsx @@ -444,8 +444,6 @@ function UserProfileSection(props) { function PolicySimulationCard(props) { const { metadata, userPolicy, keyValue } = props; - console.log(userPolicy); - const CURRENT_API_VERSION = metadata?.version; const geography = metadata.economy_options.region.filter( diff --git a/src/pages/policy/PolicyRightSidebar.jsx b/src/pages/policy/PolicyRightSidebar.jsx index 52f0cf99b..5f539cba9 100644 --- a/src/pages/policy/PolicyRightSidebar.jsx +++ b/src/pages/policy/PolicyRightSidebar.jsx @@ -26,6 +26,10 @@ import { defaultForeverYear, defaultStartDate } from "../../data/constants"; import Collapsible from "../../layout/Collapsible"; import { formatFullDate } from "../../lang/format"; import useCountryId from "../../hooks/useCountryId"; +import MultiYearSelector, { + MULTI_YEAR_SELECTOR_PERMITTED_COUNTRIES, +} from "./rightSidebar/MultiYearSelector"; +import { determineIfMultiYear } from "./output/utils"; function RegionSelector(props) { const { metadata } = props; @@ -838,6 +842,8 @@ export default function PolicyRightSidebar(props) { const stateAbbreviation = focus.split(".")[2]; const hasHousehold = searchParams.get("household") !== null; + const isMultiYear = determineIfMultiYear(searchParams); + let dataset = searchParams.get("dataset"); const options = metadata.economy_options.region.map((stateAbbreviation) => { @@ -909,7 +915,11 @@ export default function PolicyRightSidebar(props) { }); } else { let newSearch = copySearchParams(searchParams); - newSearch.set("focus", "policyOutput.policyBreakdown"); + if (isMultiYear) { + newSearch.set("focus", "policyOutput.budgetaryImpact"); + } else { + newSearch.set("focus", "policyOutput.policyBreakdown"); + } setSearchParams(newSearch, { replace: true }); } }; @@ -1140,6 +1150,14 @@ export default function PolicyRightSidebar(props) { timePeriod={timePeriod} /> )} + {MULTI_YEAR_SELECTOR_PERMITTED_COUNTRIES.includes( + metadata.countryId, + ) && ( + + )} {metadata.countryId === "uk" && ( diff --git a/src/pages/policy/output/Display.jsx b/src/pages/policy/output/Display.jsx index 20c03666a..e5e278ded 100644 --- a/src/pages/policy/output/Display.jsx +++ b/src/pages/policy/output/Display.jsx @@ -12,13 +12,14 @@ import PolicyReproducibility from "./PolicyReproducibility"; import useMobile from "layout/Responsive"; import ErrorPage from "layout/ErrorPage"; import ResultActions from "layout/ResultActions"; -import { downloadCsv } from "./utils"; +import { determineIfMultiYear, downloadCsv } from "./utils"; import { useReactToPrint } from "react-to-print"; import PolicyBreakdown from "./PolicyBreakdown"; import { Helmet } from "react-helmet"; import useCountryId from "../../../hooks/useCountryId"; import BottomImpactDescription from "../../../layout/BottomImpactDescription"; import { Link } from "react-router-dom"; +import MultiYearBudgetaryImpact from "./budget/MultiYearBudgetaryImpact"; /** * @@ -152,7 +153,14 @@ function getPolicyLabel(policy) { * performing actions such as downloading data and sharing results */ export function DisplayImpact(props) { - const { impact, policy, metadata, showPolicyImpactPopup } = props; + const { + impact, + multiYearImpact, + singleYearResults, + policy, + metadata, + showPolicyImpactPopup, + } = props; const countryId = useCountryId(); const urlParams = new URLSearchParams(window.location.search); const focus = urlParams.get("focus"); @@ -164,6 +172,8 @@ export function DisplayImpact(props) { const filename = impactType + `${policyLabel}`; let pane, downloadCsvFn; + const isMultiYear = determineIfMultiYear(urlParams); + if (impactType === "analysis") { pane = ( ); + } else if (isMultiYear && impactType === "budgetaryImpact") { + pane = ( + + ); } else if (impactType === "policyBreakdown") { pane = ( { + /** + * Setup multi-year request objects required by makeSequentialRequests + * @param {String} countryId + * @param {Number} reformPolicyId + * @param {Number} baselinePolicyId + * @param {String} region + * @param {String | Number} timePeriod + * @param {String} version + * @param {String | Number} maxHouseholds + * @param {String} dataset + * @param {String | Number} simYears + * @returns {Array} Array of RequestSetup objects + */ + function setupMultiYearRequests( + countryId, + reformPolicyId, + baselinePolicyId, + region, + timePeriod, + version, + maxHouseholds, + dataset, + simYears, + ) { + const INTERVAL = 1_000; // Chosen to match single-sim run interval lengths + let requests = []; + + const datasetInput = dataset ? `&dataset=${dataset}` : ""; + const maxHouseholdInput = maxHouseholds + ? `&max_households=${maxHouseholds}` + : ""; + + for (let i = 0; i < simYears; i++) { + const yearOfSim = Number(timePeriod) + i; + const url = + `/${countryId}/economy/${reformPolicyId}/over/` + + `${baselinePolicyId}?region=${region}&time_period=${yearOfSim}` + + `&version=${version}${maxHouseholdInput}${datasetInput}`; + + requests.push( + SimulationRequestSetup.cast({ + year: yearOfSim, + path: url, + interval: INTERVAL, + firstInterval: INTERVAL, + }), + ); + } + + return requests; + } + + /** + * Runs multi-year requests + * @param {String} countryId + * @param {Number} reformPolicyId + * @param {Number} baselinePolicyId + * @param {String} region + * @param {String | Number} timePeriod + * @param {String} version + * @param {String | Number} maxHouseholds + * @param {String} dataset + * @param {Number} simYears + * @returns {Object, SocietyWideImpact>} Object containing singleYearResults and aggregatedResult + */ + async function runMultiYearRequests( + countryId, + reformPolicyId, + baselinePolicyId, + region, + timePeriod, + version, + maxHouseholds, + dataset, + simYears, + ) { + // Set up requests array + const requests = setupMultiYearRequests( + countryId, + reformPolicyId, + baselinePolicyId, + region, + timePeriod, + version, + maxHouseholds, + dataset, + simYears, + ); + + // Make sequential requests + const collection = await makeSequentialSimulationRequests(requests); + + const singleYearResults = collection.results.map((item) => item); + const singleYearImpacts = singleYearResults.map((item) => item.result); + + // Aggregate budgetary impacts and place into Impact-like object with budget key + const aggregatedResult = { + budget: aggregateMultiYearBudgets(countryId, singleYearImpacts), + }; + + return { + singleYearResults: singleYearResults, + aggregatedResult: aggregatedResult, + }; + } + if ( areObjectsSame(policy?.reform?.data, policyRef.current?.reform?.data) && areObjectsSame( @@ -81,7 +197,33 @@ export function FetchAndDisplayImpact(props) { `${baselinePolicyId}?region=${region}&time_period=${timePeriod}` + `&version=${selectedVersion}${maxHouseholdString}${datasetString}`; setImpact(null); + setMultiYearImpact(null); + setSingleYearResults(null); setError(null); + // If user requests valid multi-year value, make sequential requests + if (isMultiYear) { + runMultiYearRequests( + metadata.countryId, + reformPolicyId, + baselinePolicyId, + region, + timePeriod, + selectedVersion, + maxHouseholds, + dataset, + simYears, + ) + .then((aggregatedData) => { + setMultiYearImpact(aggregatedData.aggregatedResult); + setSingleYearResults(aggregatedData.singleYearResults); + }) + .catch((err) => { + setError(err); + }); + + return; + } + // start counting (but stop when the API call finishes) const interval = setInterval(() => { setSecondsElapsed((secondsElapsed) => secondsElapsed + 1); @@ -163,6 +305,7 @@ export function FetchAndDisplayImpact(props) { reformPolicyId, baselinePolicyId, maxHouseholds, + simYears, ]); useEffect(() => { @@ -184,7 +327,10 @@ export function FetchAndDisplayImpact(props) { return ; } - if (!impact) { + if ( + (!isMultiYear && !impact) || + (isMultiYear && !multiYearImpact && !singleYearResults) + ) { return ( { + return item.simulationRequestSetup.year; + }); + + const columns = [ + { + title: `Net revenue impact (billions)`, + dataIndex: "netRevenueImpact", + key: "netRevenueImpact", + }, + ...years.map((year) => mapFinancialYearToColumn(year, metadata.countryId)), + { + title: getYearRangeFromArray(years, metadata.countryId), + dataIndex: "yearRange", + key: "yearRange", + }, + ]; + + const countryDataSource = dataSourcesByCountry[region]; + + const dataSources = countryDataSource.map((item) => { + return { + netRevenueImpact: item.header, + ...getYearlyImpacts( + singleYearResults, + metadata.countryId, + item.budgetKey ? item.budgetKey : null, + item.formula ? item.formula : null, + ), + yearRange: roundToBillions( + item.budgetKey + ? impact.budget[item.budgetKey] + : item.formula(impact.budget), + roundingPrecisionByCountry[metadata.countryId] || + roundingPrecisionByCountry.default, + ), + }; + }); + + const displayPeriod = getYearRangeFromArray(years, metadata.countryId); + + const regionObj = metadata.economy_options.region.find( + (elem) => elem.name === region, + ); + + let regionLabel = "undefined region"; + if (regionObj) { + regionLabel = regionObj.label; + } + + const recordStylingOverrides = (record, index) => { + let styles = {}; + + if ( + record.netRevenueImpact === "Federal budget" || + record.netRevenueImpact === "Total" + ) { + styles.fontWeight = "bold"; + } + + if (record.netRevenueImpact === "State tax") { + styles.fontStyle = "italic"; + } + + return { + style: styles, + }; + }; + + const title = `${policyLabel} in ${regionLabel}, ${displayPeriod}`; + + return ( +

+

+ {title} +

+ + + ); +} + +/** + * Create yearly impacts structured data for chart component + * @param {SequentialSimulationResult} singleYearResults + * @param {String} countryId + * @param {String | null} budgetKey The specific budgetary impact key for a table row to use, unless formula is provided + * @param {Callable | null} formula If necessary, a more complex formula drawn from the budgetary impact object to use instead of the budgetKey + * @returns {Object} The yearly impacts object expected by Ant Design Table component + */ +export function getYearlyImpacts( + singleYearResults, + countryId, + budgetKey = null, + formula = null, +) { + const yearlyImpacts = {}; + singleYearResults.forEach((item) => { + const year = item.simulationRequestSetup.year; + let impact = null; + if (formula) { + impact = formula(item.result.budget); + } else { + impact = item.result.budget[budgetKey]; + } + + yearlyImpacts[year] = roundToBillions( + impact, + roundingPrecisionByCountry[countryId] || + roundingPrecisionByCountry.default, + ); + }); + return yearlyImpacts; +} + +export function getYearRangeFromArray(years, country) { + const financialYearType = + financialYearTypeByCountry[country] || financialYearTypeByCountry.default; + + const startYear = years[0]; + let endYear = years[years.length - 1]; + + // If the financial year type is mixed, we need to add one to the end year + if (financialYearType === "mixed") { + endYear++; + } + + const endYearLastTwoDigits = endYear.toString().slice(-2); + + return `${startYear}-${endYearLastTwoDigits}`; +} + +export function roundToBillions(number, decimals = 0) { + return (number / 1e9).toFixed(decimals); +} + +export function getFederalBudgetImpact(budget) { + return budget.tax_revenue_impact - budget.state_tax_revenue_impact; +} diff --git a/src/pages/policy/output/tree.js b/src/pages/policy/output/tree.js index 71803106f..1bce140d3 100644 --- a/src/pages/policy/output/tree.js +++ b/src/pages/policy/output/tree.js @@ -1,6 +1,9 @@ +import { determineIfMultiYear } from "./utils"; + export const policyOutputs = { policyBreakdown: "Overview", netIncome: "Budgetary impact", + budgetaryImpact: "Budgetary impact", detailedBudgetaryImpact: "Budgetary impact by program", decileAverageImpact: "Absolute impact by income decile", wealthDecileAverageImpact: "Absolute impact by wealth decile", @@ -51,6 +54,8 @@ export function getPolicyOutputTree(countryId, searchParams = {}) { ? searchParams.get("uk_local_areas_beta") === "true" : false; + const isMultiYear = determineIfMultiYear(searchParams); + const tree = [ { name: "policyOutput", @@ -59,50 +64,63 @@ export function getPolicyOutputTree(countryId, searchParams = {}) { { name: "policyOutput.policyBreakdown", label: "Overview", + disabled: isMultiYear, }, - { - name: "policyOutput.budgetaryImpact", - label: "Budgetary impact", - children: [ - { - name: "policyOutput.budgetaryImpact.overall", - label: "Overall", - }, - countryId === "uk" && { - name: "policyOutput.budgetaryImpact.byProgram", - label: "By program", + isMultiYear + ? { + name: "policyOutput.budgetaryImpact", + label: "Budgetary impact", + } + : { + name: "policyOutput.budgetaryImpact", + label: "Budgetary impact", + children: [ + { + name: "policyOutput.budgetaryImpact.overall", + label: "Overall", + }, + countryId === "uk" && { + name: "policyOutput.budgetaryImpact.byProgram", + label: "By program", + }, + ].filter((x) => x), }, - ].filter((x) => x), - }, { name: "policyOutput.distributionalImpact", label: "Distributional impact", + disabled: isMultiYear, children: [ { name: "policyOutput.distributionalImpact.incomeDecile", label: "By income decile", + disabled: isMultiYear, children: [ { name: "policyOutput.distributionalImpact.incomeDecile.relative", label: "Relative", + disabled: isMultiYear, }, { name: "policyOutput.distributionalImpact.incomeDecile.average", label: "Average", + disabled: isMultiYear, }, ], }, countryId === "uk" && { name: "policyOutput.distributionalImpact.wealthDecile", label: "By wealth decile", + disabled: isMultiYear, children: [ { name: "policyOutput.distributionalImpact.wealthDecile.relative", label: "Relative", + disabled: isMultiYear, }, { name: "policyOutput.distributionalImpact.wealthDecile.average", label: "Average", + disabled: isMultiYear, }, ], }, @@ -111,14 +129,17 @@ export function getPolicyOutputTree(countryId, searchParams = {}) { { name: "policyOutput.winnersAndLosers", label: "Winners and losers", + disabled: isMultiYear, children: [ { name: "policyOutput.winnersAndLosers.incomeDecile", label: "By income decile", + disabled: isMultiYear, }, countryId === "uk" && { name: "policyOutput.winnersAndLosers.wealthDecile", label: "By wealth decile", + disabled: isMultiYear, }, uk_local_areas_beta && { name: "policyOutput.winnersAndLosers.constituencies", @@ -129,36 +150,44 @@ export function getPolicyOutputTree(countryId, searchParams = {}) { { name: "policyOutput.povertyImpact", label: "Poverty impact", + disabled: isMultiYear, children: [ { name: "policyOutput.povertyImpact.regular", label: "Regular poverty", + disabled: isMultiYear, children: [ { name: "policyOutput.povertyImpact.regular.byAge", label: "By age", + disabled: isMultiYear, }, countryId === "us" && { name: "policyOutput.povertyImpact.regular.byRace", label: "By race", + disabled: isMultiYear, }, { name: "policyOutput.povertyImpact.regular.byGender", label: "By gender", + disabled: isMultiYear, }, ].filter((x) => x), }, { name: "policyOutput.povertyImpact.deep", label: "Deep poverty", + disabled: isMultiYear, children: [ { name: "policyOutput.povertyImpact.deep.byAge", label: "By age", + disabled: isMultiYear, }, { name: "policyOutput.povertyImpact.deep.byGender", label: "By gender", + disabled: isMultiYear, }, ], }, @@ -167,22 +196,27 @@ export function getPolicyOutputTree(countryId, searchParams = {}) { { name: "policyOutput.inequalityImpact", label: "Inequality impact", + disabled: isMultiYear, }, { name: "policyOutput.cliffImpact", label: "Cliff impact", + disabled: isMultiYear, }, uk_local_areas_beta && { name: "policyOutput.constituencies", label: "Parliamentary constituencies (experimental)", + disabled: isMultiYear, children: [ { name: "policyOutput.constituencies.relative", label: "Relative change", + disabled: isMultiYear, }, { name: "policyOutput.constituencies.average", label: "Average change", + disabled: isMultiYear, }, ], }, @@ -192,70 +226,86 @@ export function getPolicyOutputTree(countryId, searchParams = {}) { countryId === "uk" ? "Labour supply impact (experimental)" : "Labor supply impact (experimental)", + disabled: isMultiYear, children: [ uk_local_areas_beta && { name: "policyOutput.constituencies.laborSupplyFTEs", label: "By parliamentary constituency", + disabled: isMultiYear, }, countryId === "us" && { name: "policyOutput.laborSupplyImpact.hours", label: "Hours worked", + disabled: isMultiYear, }, { name: "policyOutput.laborSupplyImpact.earnings", label: "Earnings", + disabled: isMultiYear, children: [ { name: "policyOutput.laborSupplyImpact.earnings.overall", label: "Overall", + disabled: isMultiYear, children: [ { name: "policyOutput.laborSupplyImpact.earnings.overall.relative", label: "Relative", + disabled: isMultiYear, }, { name: "policyOutput.laborSupplyImpact.earnings.overall.absolute", label: "Absolute", + disabled: isMultiYear, }, ], }, { name: "policyOutput.laborSupplyImpact.earnings.byDecile", label: "By decile", + disabled: isMultiYear, children: [ { name: "policyOutput.laborSupplyImpact.earnings.byDecile.relative", label: "Relative", + disabled: isMultiYear, children: [ { name: "policyOutput.laborSupplyImpact.earnings.byDecile.relative.total", label: "Total", + disabled: isMultiYear, }, { name: "policyOutput.laborSupplyImpact.earnings.byDecile.relative.income", label: "Income effect", + disabled: isMultiYear, }, { name: "policyOutput.laborSupplyImpact.earnings.byDecile.relative.substitution", label: "Substitution effect", + disabled: isMultiYear, }, ], }, { name: "policyOutput.laborSupplyImpact.earnings.byDecile.absolute", label: "Absolute", + disabled: isMultiYear, children: [ { name: "policyOutput.laborSupplyImpact.earnings.byDecile.absolute.total", label: "Total", + disabled: isMultiYear, }, { name: "policyOutput.laborSupplyImpact.earnings.byDecile.absolute.income", label: "Income effect", + disabled: isMultiYear, }, { name: "policyOutput.laborSupplyImpact.earnings.byDecile.absolute.substitution", label: "Substitution effect", + disabled: isMultiYear, }, ], }, @@ -268,10 +318,12 @@ export function getPolicyOutputTree(countryId, searchParams = {}) { { name: "policyOutput.analysis", label: "AI summary (experimental)", + disabled: isMultiYear, }, { name: "policyOutput.codeReproducibility", label: "Reproduce in Python", + disabled: isMultiYear, }, ].filter((x) => x), }, diff --git a/src/pages/policy/output/utils.js b/src/pages/policy/output/utils.js index a093bc898..66afd1230 100644 --- a/src/pages/policy/output/utils.js +++ b/src/pages/policy/output/utils.js @@ -57,10 +57,24 @@ function copyLink() { message.info("Link copied to clipboard"); } +/** + * Determines if a given policy output is multi-year based on search params + * @param {Object} searchParams - The search params object + * @returns {boolean} - True if the policy output is multi-year, false otherwise + */ +function determineIfMultiYear(searchParams) { + return ( + searchParams.get("simYears") && + !Number.isNaN(searchParams.get("simYears")) && + searchParams.get("simYears") > 1 + ); +} + export { avgChangeDirection, plotLayoutFont, downloadPng, downloadCsv, copyLink, + determineIfMultiYear, }; diff --git a/src/pages/policy/rightSidebar/MultiYearSelector.jsx b/src/pages/policy/rightSidebar/MultiYearSelector.jsx new file mode 100644 index 000000000..4fca0f5dc --- /dev/null +++ b/src/pages/policy/rightSidebar/MultiYearSelector.jsx @@ -0,0 +1,162 @@ +import { useSearchParams } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { Select, Switch } from "antd"; +import { useDisplayCategory } from "../../../layout/Responsive"; + +const DEFAULT_SIM_LENGTH = { + us: 10, + uk: 5, + default: 5, +}; + +export const MULTI_YEAR_SELECTOR_PERMITTED_COUNTRIES = ["us", "uk"]; + +export default function MultiYearSelector(props) { + const { metadata, startYear } = props; + + const defaultSimLength = calculateDefaultSimLength(metadata, startYear); + + const [searchParams, setSearchParams] = useSearchParams(); + const inboundSimYears = searchParams.get("simYears"); + const isInboundSimYearsValid = validateSimYears( + inboundSimYears, + metadata, + startYear, + ); + const [simLength, setSimLength] = useState( + isInboundSimYearsValid ? inboundSimYears : defaultSimLength, + ); + const [isMultiYearActive, setIsMultiYearActive] = useState( + !!searchParams.get("simYears"), + ); + const dC = useDisplayCategory(); + + const simYearsMenuItems = generateSimYearsMenuItems(metadata, startYear); + + useEffect(() => { + setSimLength(isInboundSimYearsValid ? inboundSimYears : defaultSimLength); + setIsMultiYearActive(!!searchParams.get("simYears")); + }, [ + startYear, + searchParams, + isInboundSimYearsValid, + inboundSimYears, + defaultSimLength, + ]); + + function handleSwitchChange(checked) { + const isOnOutput = searchParams.get("focus").includes("policyOutput"); + + setIsMultiYearActive(checked); + + if (checked) { + setSearchParams((prev) => { + const newSearch = new URLSearchParams(prev); + newSearch.set("simYears", simLength); + if (isOnOutput) { + newSearch.set("focus", "policyOutput.budgetaryImpact"); + } + return newSearch; + }); + return; + } + + setSearchParams((prev) => { + const newSearch = new URLSearchParams(prev); + newSearch.delete("simYears"); + if (isOnOutput) { + newSearch.set("focus", "policyOutput.policyBreakdown"); + } + return newSearch; + }); + return; + } + + function handleSimYearsChange(value) { + setSimLength(value); + if (isMultiYearActive) { + setSearchParams((prev) => { + const newSearch = new URLSearchParams(prev); + newSearch.set("simYears", value); + return newSearch; + }); + } + } + + return ( +
+ +

+ Extend impacts over +

+