diff --git a/metadata_fetch.mjs b/metadata_fetch.mjs index e562a972f..56fcff570 100644 --- a/metadata_fetch.mjs +++ b/metadata_fetch.mjs @@ -2,10 +2,6 @@ import fetch from "node-fetch"; import fs from "fs"; import path from "path"; -// const fetch = require('node-fetch') -// const fs = require('fs'); -// const path = require('path'); - let metadataUS = null; let metadataUK = null; let metadataCA = null; @@ -18,9 +14,42 @@ const filePath = path.join( "data.json", ); +/** + * Replaces Response.json(), which unfortunately has no + * native way of passing a reviver + * + * This function is copied, instead of imported, due to + * limitations of Jest within ES6+ module environment + * @param {Response} response The response object + * @returns {Promise} The JSON object, parsed with custom reviver + */ +function wrappedResponseJson(response) { + return new Promise((resolve, reject) => { + response.text().then((text) => { + resolve(wrappedJsonParse(text)); + }); + }); +} + +function wrappedJsonParse() { + return JSON.parse(...arguments, JsonReviver); +} + +function JsonReviver(key, value) { + if (value === "Infinity") { + return Infinity; + } + if (value === "-Infinity") { + return -Infinity; + } + return value; +} + async function fetchMetadata(countryId) { const res = await fetch(`https://api.policyengine.org/${countryId}/metadata`); - const metadataRaw = await res.json(); + // For the time being, this is being kept as res.json(), unlike + // the rest of the repo, due to Jest's challenges with ES6 modules + const metadataRaw = await wrappedResponseJson(res); const metadata = metadataRaw.result; return metadata; } @@ -34,6 +63,8 @@ let jsonData = { metadataUK: metadataUK, metadataCA: metadataCA, }; +// For the time being, this is being kept as res.json(), unlike +// the rest of the repo, due to Jest's challenges with ES6 modules jsonData = JSON.stringify(jsonData); fs.writeFile(filePath, jsonData, (err) => { diff --git a/src/PolicyEngine.jsx b/src/PolicyEngine.jsx index 5db74bf1a..8ed2c72f2 100644 --- a/src/PolicyEngine.jsx +++ b/src/PolicyEngine.jsx @@ -45,6 +45,7 @@ import RedirectBlogPost from "./routing/RedirectBlogPost"; import { StatusPage } from "./pages/StatusPage"; import ManifestosComparison from "./applets/ManifestosComparison"; import CTCComparison from "./applets/CTCComparison"; +import { wrappedResponseJson } from "./data/wrappedJson"; const PolicyPage = lazy(() => import("./pages/PolicyPage")); const HouseholdPage = lazy(() => import("./pages/HouseholdPage")); @@ -133,7 +134,7 @@ export default function PolicyEngine() { useEffect(() => { if (metadata) { countryApiCall(countryId, `/policy/${baselinePolicyId}`) - .then((res) => res.json()) + .then((res) => wrappedResponseJson(res)) .then((dataHolder) => { if (dataHolder.result.label === "None") { dataHolder.result.label = null; @@ -151,7 +152,7 @@ export default function PolicyEngine() { useEffect(() => { if (metadata) { countryApiCall(countryId, `/policy/${reformPolicyId}`) - .then((res) => res.json()) + .then((res) => wrappedResponseJson(res)) .then((dataHolder) => { if (dataHolder.result.label === "None") { dataHolder.result.label = null; @@ -168,7 +169,7 @@ export default function PolicyEngine() { useEffect(() => { if (searchParams.get("renamed") && reformPolicyId) { countryApiCall(countryId, `/policy/${reformPolicyId}`) - .then((res) => res.json()) + .then((res) => wrappedResponseJson(res)) .then((dataHolder) => { setReformPolicy({ data: dataHolder.result.policy_json, diff --git a/src/__tests__/pages/APIDocumentationPage.test.js b/src/__tests__/pages/APIDocumentationPage.test.js index a1f88c1b9..513a42638 100644 --- a/src/__tests__/pages/APIDocumentationPage.test.js +++ b/src/__tests__/pages/APIDocumentationPage.test.js @@ -95,6 +95,7 @@ describe("APIDocumentationPage", () => { Promise.resolve({ message: "successfully resolved", }), + text: () => Promise.resolve('{"message": "successfully resolved"}'), }), ); @@ -134,6 +135,7 @@ describe("APIDocumentationPage", () => { Promise.resolve({ message: "successfully resolved", }), + text: () => Promise.resolve('{"message": "successfully resolved"}'), }), ); @@ -173,6 +175,7 @@ describe("APIDocumentationPage", () => { Promise.resolve({ message: "successfully resolved", }), + text: () => Promise.resolve('{"message": "successfully resolved"}'), }), ); diff --git a/src/__tests__/pages/policy/PolicyRightSidebar.test.js b/src/__tests__/pages/policy/PolicyRightSidebar.test.js index 01264e2c2..ed620daa2 100644 --- a/src/__tests__/pages/policy/PolicyRightSidebar.test.js +++ b/src/__tests__/pages/policy/PolicyRightSidebar.test.js @@ -240,6 +240,8 @@ describe("SinglePolicyChange", () => { const testCountryId = "us"; const testValue = 3; const testParamLabel = "maxwell"; + // Test against changes to IRS income tax bracket 1 + const testParamName = "gov.irs.income.bracket.rates.1"; test("Should display simple, single-year policies correctly", () => { // This must be declared here, and not in describe @@ -249,15 +251,12 @@ describe("SinglePolicyChange", () => { const testStartDate = "2024-01-01"; const testEndDate = "2024-12-31"; - const index = Math.floor(Object.keys(allParams).length / 2); - const desiredParamName = Object.keys(allParams)[index]; - const { getByText } = render( { const testStartDate = "2024-01-01"; const testEndDate = "2025-12-31"; - const index = Math.floor(Object.keys(allParams).length / 2); - const desiredParamName = Object.keys(allParams)[index]; const { getByText } = render( { const testStartDate = "2024-01-01"; const testEndDate = defaultForeverYear.concat("-12-31"); - const index = Math.floor(Object.keys(allParams).length / 2); - const desiredParamName = Object.keys(allParams)[index]; const { getByText } = render( { const testStartDate = "2024-01-02"; const testEndDate = defaultForeverYear.concat("-12-31"); - const index = Math.floor(Object.keys(allParams).length / 2); - const desiredParamName = Object.keys(allParams)[index]; const { getByText } = render( { const testStartDate = "2024-01-02"; const testEndDate = "2025-12-30"; - const index = Math.floor(Object.keys(allParams).length / 2); - const desiredParamName = Object.keys(allParams)[index]; const { getByText } = render( { const testStartDate = "2024-01-02"; const testEndDate = "2025-12-31"; - const index = Math.floor(Object.keys(allParams).length / 2); - const desiredParamName = Object.keys(allParams)[index]; const { getByText } = render( { const testStartDate = "2024-01-01"; const testEndDate = "2025-12-30"; - const index = Math.floor(Object.keys(allParams).length / 2); - const desiredParamName = Object.keys(allParams)[index]; const { getByText } = render( { const testStartDate = "2024-01-01"; const testEndDate = "2024-01-01"; - const index = Math.floor(Object.keys(allParams).length / 2); - const desiredParamName = Object.keys(allParams)[index]; const { getByText } = render( Promise.resolve(testSearchResults), + text: () => Promise.resolve(JSON.stringify(testSearchResults)), }; const testStackingPolicy = { @@ -36,6 +40,14 @@ const testStackingPolicyWrapper = { policy_json: testStackingPolicy, }, }), + text: () => + Promise.resolve( + JSON.stringify({ + result: { + policy_json: testStackingPolicy, + }, + }), + ), }; const mockCountryApiCall = jest.fn(); diff --git a/src/api/call.js b/src/api/call.js index 6e2a7db91..1fbffcb31 100644 --- a/src/api/call.js +++ b/src/api/call.js @@ -1,5 +1,6 @@ import { buildParameterTree } from "./parameters"; import { buildVariableTree, getTreeLeavesInOrder } from "./variables"; +import { wrappedJsonStringify, wrappedResponseJson } from "../data/wrappedJson"; const POLICYENGINE_API = "https://api.policyengine.org"; @@ -19,7 +20,7 @@ export function apiCall(path, body, method, secondAttempt = false) { headers: { "Content-Type": "application/json", }, - body: body ? JSON.stringify(body) : null, + body: body ? wrappedJsonStringify(body) : null, }).then((response) => { // If the response is a 500, try again once. if (response.status === 500 && !secondAttempt) { @@ -87,7 +88,7 @@ export async function updateMetadata(countryId) { return null; } - const dataHolder = await res.json(); + const dataHolder = await wrappedResponseJson(res); let data = dataHolder.result; const variableTree = buildVariableTree( diff --git a/src/api/parameters.js b/src/api/parameters.js index c570a8216..47728da8d 100644 --- a/src/api/parameters.js +++ b/src/api/parameters.js @@ -1,6 +1,7 @@ import { IntervalMap } from "algorithms/IntervalMap"; import { countryApiCall } from "./call"; import { cmpDates } from "lang/stringDates"; +import { wrappedResponseJson } from "../data/wrappedJson"; export function buildParameterTree(parameters) { let tree = {}; @@ -86,7 +87,7 @@ export function getNewPolicyId(countryId, newPolicyData, newPolicyLabel) { submission.label = newPolicyLabel; } return countryApiCall(countryId, "/policy", submission, "POST") - .then((response) => response.json()) + .then((response) => wrappedResponseJson(response)) .then((data) => { let result = {}; if (data.status === "ok") { diff --git a/src/api/userPolicies.js b/src/api/userPolicies.js index 1d8f0343b..108152cac 100644 --- a/src/api/userPolicies.js +++ b/src/api/userPolicies.js @@ -1,3 +1,4 @@ +import { wrappedResponseJson } from "../data/wrappedJson"; import { apiCall } from "./call"; const USER_POLICY_ENDPOINT = "/user_policy"; @@ -17,7 +18,7 @@ export async function postUserPolicy(countryId, policyToAdd) { policyToAdd, "POST", ); - const resJson = await res.json(); + const resJson = await wrappedResponseJson(res); // If the record already exists... if (res.status === 200 && resJson.status === "ok") { // Update the API version and updated_date fields @@ -49,7 +50,7 @@ export async function updateUserPolicy(countryId, policyToAdd) { policyToAdd, "PUT", ); - const resJson = await res.json(); + const resJson = await wrappedResponseJson(res); if (resJson.status !== "ok") { console.error("Error while POSTing user policy:"); console.error(resJson.message); diff --git a/src/api/variables.js b/src/api/variables.js index f53758524..6968808e4 100644 --- a/src/api/variables.js +++ b/src/api/variables.js @@ -2,6 +2,7 @@ import { countryApiCall } from "./call"; import { capitalize } from "../lang/format"; import { defaultHouseholds } from "../data/defaultHouseholds"; import { defaultYear } from "data/constants"; +import { wrappedResponseJson } from "../data/wrappedJson"; export function removePerson(situation, name) { // Remove a person from the situation @@ -401,7 +402,7 @@ export function getDefaultHouseholdId(metadata) { { data: defaultHousehold }, "POST", ) - .then((res) => res.json()) + .then((res) => wrappedResponseJson(res)) .then((dataHolder) => { return dataHolder.result.household_id; }); diff --git a/src/controls/StableInputNumber.jsx b/src/controls/StableInputNumber.jsx index e3e6f87a3..238b3b253 100644 --- a/src/controls/StableInputNumber.jsx +++ b/src/controls/StableInputNumber.jsx @@ -1,5 +1,4 @@ import { InputNumber } from "antd"; -import { useState } from "react"; /** * @@ -35,13 +34,11 @@ import { useState } from "react"; * @returns a StableInputNumber component */ export default function StableInputNumber(props) { - const { defaultValue, onPressEnter, onBlur, ...others } = props; - const [value, setValue] = useState(defaultValue); - delete others.onChange; + const { defaultValue, onPressEnter, onBlur, value, ...others } = props; return ( onPressEnter?.(e, value)} onBlur={(e) => onBlur?.(e, value)} {...others} diff --git a/src/data/reformDefinitionCode.js b/src/data/reformDefinitionCode.js index 29cea3181..cdf44ad0b 100644 --- a/src/data/reformDefinitionCode.js +++ b/src/data/reformDefinitionCode.js @@ -1,6 +1,7 @@ import { optimiseHousehold } from "../api/variables"; import { defaultYear } from "./constants"; import { DEFAULT_DATASETS } from "./countries"; +import { wrappedJsonStringify } from "./wrappedJson"; export function getReproducibilityCodeBlock( type, @@ -58,7 +59,7 @@ export function getBaselineCode(policy, metadata) { ) { return []; } - let json_str = JSON.stringify(policy.baseline.data, null, 2); + let json_str = wrappedJsonStringify(policy.baseline.data, null, 2); json_str = sanitizeStringToPython(json_str); let lines = [""].concat(json_str.split("\n")); lines[1] = "baseline = Reform.from_dict({" + lines[0]; @@ -71,7 +72,7 @@ export function getReformCode(policy, metadata) { if (!policy?.baseline?.data || Object.keys(policy.reform.data).length === 0) { return []; } - let json_str = JSON.stringify(policy.reform.data, null, 2); + let json_str = wrappedJsonStringify(policy.reform.data, null, 2); json_str = sanitizeStringToPython(json_str); let lines = [""].concat(json_str.split("\n")); lines[1] = "reform = Reform.from_dict({" + lines[0]; @@ -261,8 +262,7 @@ export function doesParamNameContainNumber(paramName) { /** * Utility function to sanitize a string and ensure that it's valid Python; - * currently converts JS 'null', 'true', and 'false' to Python - * 'None', 'True', and 'False' + * currently converts JS 'null', 'true', 'false', '"Infinity"', and '"-Infinity"' to Python * @param {String} string * @returns {String} */ @@ -270,5 +270,7 @@ export function sanitizeStringToPython(string) { return string .replace(/true/g, "True") .replace(/false/g, "False") - .replace(/null/g, "None"); + .replace(/null/g, "None") + .replace(/"Infinity"/g, ".inf") + .replace(/"-Infinity"/g, "-.inf"); } diff --git a/src/data/wrappedJson.js b/src/data/wrappedJson.js new file mode 100644 index 000000000..df0654eab --- /dev/null +++ b/src/data/wrappedJson.js @@ -0,0 +1,55 @@ +/* + This file is used to wrap JSON.parse and JSON.stringify functions; + defined against MDN standards; see + https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse + for details on JSON.parse() and + https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify + for JSON.stringify(). + + These will need to be used for any instance that involves + passing around policies, as +inf and -inf are valid values, + and JSON does not support them natively. + +*/ + +function JsonReplacer(key, value) { + if (value === Infinity) { + return "Infinity"; + } + if (value === -Infinity) { + return "-Infinity"; + } + return value; +} + +function JsonReviver(key, value) { + if (value === "Infinity") { + return Infinity; + } + if (value === "-Infinity") { + return -Infinity; + } + return value; +} + +export function wrappedJsonParse() { + return JSON.parse(...arguments, JsonReviver); +} + +export function wrappedJsonStringify() { + return JSON.stringify(...arguments, JsonReplacer); +} + +/** + * Replaces Response.json(), which unfortunately has no + * native way of passing a reviver + * @param {Response} response The response object + * @returns {Promise} The JSON object, parsed with custom reviver + */ +export function wrappedResponseJson(response) { + return new Promise((resolve, reject) => { + response.text().then((text) => { + resolve(wrappedJsonParse(text)); + }); + }); +} diff --git a/src/hooks/useLocalStorage.js b/src/hooks/useLocalStorage.js index 5f68f5faf..1f93c5962 100644 --- a/src/hooks/useLocalStorage.js +++ b/src/hooks/useLocalStorage.js @@ -1,12 +1,13 @@ // Taken from https://designcode.io/react-hooks-handbook-uselocalstorage-hook import { useEffect, useState } from "react"; +import { wrappedJsonParse, wrappedJsonStringify } from "../data/wrappedJson"; export default function useLocalStorage(key, defaultValue) { const [value, setValue] = useState(() => { let currentValue = null; try { - currentValue = JSON.parse( + currentValue = wrappedJsonParse( localStorage.getItem(key) || String(defaultValue), ); } catch (error) { @@ -17,7 +18,7 @@ export default function useLocalStorage(key, defaultValue) { }); useEffect(() => { - localStorage.setItem(key, JSON.stringify(value)); + localStorage.setItem(key, wrappedJsonStringify(value)); }, [value, key]); return [value, setValue]; diff --git a/src/layout/MarkdownFormatter.jsx b/src/layout/MarkdownFormatter.jsx index 30ee7b12b..b46832029 100644 --- a/src/layout/MarkdownFormatter.jsx +++ b/src/layout/MarkdownFormatter.jsx @@ -6,6 +6,7 @@ import React, { useState, useRef, useEffect } from "react"; import style from "../style"; import useDisplayCategory from "../hooks/useDisplayCategory"; import Plot from "react-plotly.js"; +import { wrappedJsonParse } from "../data/wrappedJson"; function Td({ children }) { const displayCategory = useDisplayCategory(); @@ -125,9 +126,9 @@ export function HighlightedBlock({ data, leftContent, rightContent }) { export function PlotlyChartCode({ data, backgroundColor }) { let plotlyData = null; try { - plotlyData = JSON.parse(data); + plotlyData = wrappedJsonParse(data); } catch { - plotlyData = JSON.parse(data[0]); + plotlyData = wrappedJsonParse(data[0]); } const title = plotlyData.layout?.title?.text; const displayCategory = useDisplayCategory(); diff --git a/src/modals/DeprecationModal.jsx b/src/modals/DeprecationModal.jsx index 6f95d56b7..9ebbd4326 100644 --- a/src/modals/DeprecationModal.jsx +++ b/src/modals/DeprecationModal.jsx @@ -7,6 +7,7 @@ import { getNewPolicyId } from "../api/parameters"; import { useSearchParams } from "react-router-dom"; import { copySearchParams } from "../api/call"; import style from "../style"; +import { wrappedJsonParse, wrappedJsonStringify } from "../data/wrappedJson"; export default function DeprecationModal(props) { const { oldPolicy, countryVersion, metadata, deprecatedParams } = props; @@ -153,7 +154,7 @@ export default function DeprecationModal(props) { } export function removeDeprecatedParams(metadata, policy) { - const newPolicy = JSON.parse(JSON.stringify(policy)); + const newPolicy = wrappedJsonParse(wrappedJsonStringify(policy)); const baselineAndReform = Object.values(newPolicy); for (const item of baselineAndReform) { diff --git a/src/pages/APIDocumentationPage.jsx b/src/pages/APIDocumentationPage.jsx index 84ac829ca..354ac64bf 100644 --- a/src/pages/APIDocumentationPage.jsx +++ b/src/pages/APIDocumentationPage.jsx @@ -11,6 +11,7 @@ import { Input, Card, Divider, Tag, Drawer } from "antd"; import { Helmet } from "react-helmet"; import { defaultYear } from "data/constants"; import useDisplayCategory from "../hooks/useDisplayCategory"; +import { wrappedResponseJson } from "../data/wrappedJson"; export const exampleInputs = { us: { @@ -445,7 +446,7 @@ function APIEndpoint({ body: JSON.stringify(exampleInputJson), }, ); - const resJson = await res.json(); + const resJson = await wrappedResponseJson(res); setOutputJson(resJson); } catch (err) { console.error(err); diff --git a/src/pages/HouseholdPage.jsx b/src/pages/HouseholdPage.jsx index c9cafdd64..7cfc6f4bb 100644 --- a/src/pages/HouseholdPage.jsx +++ b/src/pages/HouseholdPage.jsx @@ -27,6 +27,7 @@ import MobileCalculatorPage from "../layout/MobileCalculatorPage.jsx"; import RecreateHouseholdPopup from "./household/output/RecreateHouseholdPopup.jsx"; import TaxYear from "./household/input/TaxYear"; import { Helmet } from "react-helmet"; +import { wrappedResponseJson } from "../data/wrappedJson.js"; export default function HouseholdPage(props) { const { @@ -100,7 +101,7 @@ export default function HouseholdPage(props) { console.error("Back-end error while attempting to get household"); } - const resJSON = await res.json(); + const resJSON = await wrappedResponseJson(res); dataHolder = { input: resJSON.result.household_json, }; @@ -158,7 +159,7 @@ export default function HouseholdPage(props) { (metadata ? metadata.current_law_id : "current-law") }`, ) - .then((res) => res.json()) + .then((res) => wrappedResponseJson(res)) .then((dataHolder) => { if (dataHolder.status === "error") { setLoading(false); @@ -178,7 +179,7 @@ export default function HouseholdPage(props) { countryId, `/household/${householdId}/policy/${policy.reform.id}`, ) - .then((res) => res.json()) + .then((res) => wrappedResponseJson(res)) .then((dataHolder) => { if (dataHolder.status === "error") { setLoading(false); diff --git a/src/pages/StatusPage.jsx b/src/pages/StatusPage.jsx index 5b8d84189..9073c8d34 100644 --- a/src/pages/StatusPage.jsx +++ b/src/pages/StatusPage.jsx @@ -13,6 +13,7 @@ import { COUNTRY_NAMES, } from "../data/countries"; import { Helmet } from "react-helmet"; +import { wrappedResponseJson } from "../data/wrappedJson"; function ApiStatus({ apiStatus, apiCategory, countryNames }) { return ( @@ -77,7 +78,7 @@ export function StatusPage() { Object.keys(body).length > 0 ? api(path, body) : api(country, path); calledApi - .then((res) => res.json()) + .then((res) => wrappedResponseJson(res)) .then((res) => { const endTime = Date.now(); const latency = endTime - startTime; diff --git a/src/pages/UserProfilePage.jsx b/src/pages/UserProfilePage.jsx index aad174706..497fc0861 100644 --- a/src/pages/UserProfilePage.jsx +++ b/src/pages/UserProfilePage.jsx @@ -26,6 +26,7 @@ import { COUNTRY_NAMES } from "../data/countries"; import moment from "moment"; import { formatCurrencyAbbr } from "../lang/format"; import ErrorPage from "../layout/ErrorPage"; +import { wrappedResponseJson } from "../data/wrappedJson"; const STATES = { EMPTY: "empty", @@ -562,7 +563,7 @@ function UsernameDisplayAndEditor(props) { try { const res = await apiCall(USER_PROFILE_PATH, body, "PUT"); - const resJson = await res.json(); + const resJson = await wrappedResponseJson(res); if (resJson.status === "ok") { const data = await apiCall( `/${countryId}/user_profile?user_id=${accessedUserProfile.user_id}`, diff --git a/src/pages/household/input/VariableEditor.jsx b/src/pages/household/input/VariableEditor.jsx index d90042ce5..488c93cbc 100644 --- a/src/pages/household/input/VariableEditor.jsx +++ b/src/pages/household/input/VariableEditor.jsx @@ -14,6 +14,7 @@ import gtag from "../../../api/analytics"; import { useEffect } from "react"; import StableInputNumber from "controls/StableInputNumber"; import { defaultYear } from "data/constants"; +import { useState } from "react"; export default function VariableEditor(props) { const [searchParams] = useSearchParams(); @@ -185,6 +186,7 @@ function HouseholdVariableEntityInput(props) { setEdited, singleEntity, } = props; + const submitValue = (value) => { value = Number.isNaN(+value) ? value : +value; let newHousehold = JSON.parse(JSON.stringify(householdInput)); @@ -246,6 +248,9 @@ function HouseholdVariableEntityInput(props) { defaultValue = variable.possibleValues[0]; } } + + const [value, setValue] = useState(defaultValue); + const mobile = useMobile(); let control; @@ -281,6 +286,8 @@ function HouseholdVariableEntityInput(props) { defaultValue={defaultValue} onPressEnter={onPressEnter} onBlur={onPressEnter} + value={value} + onChange={(value) => setValue(value)} /> ); } else if (variable.valueType === "bool") { diff --git a/src/pages/household/output/EarningsVariation.jsx b/src/pages/household/output/EarningsVariation.jsx index a680b2351..cfcacb9b9 100644 --- a/src/pages/household/output/EarningsVariation.jsx +++ b/src/pages/household/output/EarningsVariation.jsx @@ -10,6 +10,7 @@ import BaselineOnlyChart from "./EarningsVariation/BaselineOnlyChart"; import BaselineAndReformChart from "./EarningsVariation/BaselineAndReformChart"; import { getValueFromHousehold } from "../../../api/variables"; import { Helmet } from "react-helmet"; +import { wrappedResponseJson } from "../../../data/wrappedJson"; export default function EarningsVariation(props) { const { @@ -96,7 +97,7 @@ export default function EarningsVariation(props) { household: householdData, policy: policy.baseline.data, }) - .then((res) => res.json()) + .then((res) => wrappedResponseJson(res)) .then((data) => { setBaselineNetIncome(data.result); }) @@ -110,7 +111,7 @@ export default function EarningsVariation(props) { household: householdData, policy: policy.reform.data, }) - .then((res) => res.json()) + .then((res) => wrappedResponseJson(res)) .then((data) => { setReformNetIncome(data.result); }) diff --git a/src/pages/household/output/MarginalTaxRates.jsx b/src/pages/household/output/MarginalTaxRates.jsx index 0f80303d9..ec8f94bf2 100644 --- a/src/pages/household/output/MarginalTaxRates.jsx +++ b/src/pages/household/output/MarginalTaxRates.jsx @@ -19,6 +19,7 @@ import useMobile from "layout/Responsive"; import Screenshottable from "layout/Screenshottable"; import { localeCode } from "lang/format"; import { Helmet } from "react-helmet"; +import { wrappedResponseJson } from "../../../data/wrappedJson"; export default function MarginalTaxRates(props) { const { @@ -88,7 +89,7 @@ export default function MarginalTaxRates(props) { household: householdData, policy: policy.baseline.data, }) - .then((res) => res.json()) + .then((res) => wrappedResponseJson(res)) .then((data) => { setBaselineMtr(data.result); }) @@ -102,7 +103,7 @@ export default function MarginalTaxRates(props) { household: householdData, policy: policy.reform.data, }) - .then((res) => res.json()) + .then((res) => wrappedResponseJson(res)) .then((data) => { setReformMtr(data.result); }) diff --git a/src/pages/policy/PolicySearch.jsx b/src/pages/policy/PolicySearch.jsx index 585087dad..f6f655296 100644 --- a/src/pages/policy/PolicySearch.jsx +++ b/src/pages/policy/PolicySearch.jsx @@ -10,6 +10,7 @@ import { } from "@ant-design/icons"; import { getNewPolicyId } from "../../api/parameters"; import style from "../../style"; +import { wrappedResponseJson } from "../../data/wrappedJson"; export default function PolicySearch(props) { const { metadata, target, policy, width, displayStack } = props; @@ -66,7 +67,7 @@ export default function PolicySearch(props) { setIsError(true); setIsStackerLoading(false); } else { - const resJson = await res.json(); + const resJson = await wrappedResponseJson(res); const policyToStack = resJson.result; // Reconcile policies; when conflicts occur, defer to newer policy @@ -107,7 +108,7 @@ export default function PolicySearch(props) { metadata.countryId, `/policies?query=${searchText}&unique_only=true`, ); - const resJson = await res.json(); + const resJson = await wrappedResponseJson(res); setPolicies( resJson.result.map((item) => { return { diff --git a/src/pages/policy/input/ParameterEditor.jsx b/src/pages/policy/input/ParameterEditor.jsx index 2d221f3cb..b0038bbaf 100644 --- a/src/pages/policy/input/ParameterEditor.jsx +++ b/src/pages/policy/input/ParameterEditor.jsx @@ -4,6 +4,7 @@ import { Alert, Button, DatePicker, + InputNumber, Popover, Segmented, Space, @@ -24,8 +25,8 @@ import { import { IntervalMap } from "algorithms/IntervalMap"; import { cmpDates, nextDay, prevDay } from "lang/stringDates"; import moment from "dayjs"; -import StableInputNumber from "controls/StableInputNumber"; -import { UndoOutlined } from "@ant-design/icons"; +import { LeftOutlined, RightOutlined, UndoOutlined } from "@ant-design/icons"; +import style from "../../../style"; const { RangePicker } = DatePicker; /** @@ -341,8 +342,9 @@ function ValueSetter(props) { policy, } = props; - const [searchParams, setSearchParams] = useSearchParams(); const startValue = reformMap.get(startDate); + const [searchParams, setSearchParams] = useSearchParams(); + const [value, setValue] = useState(startValue); const parameter = metadata.parameters[parameterName]; function changeHandler(value) { @@ -377,6 +379,18 @@ function ValueSetter(props) { } } + const isPercent = parameter.unit === "/1"; + const scale = isPercent ? 100 : 1; + const isCurrency = Object.keys(currencyMap).includes(parameter.unit); + const maximumFractionDigits = isCurrency ? 2 : 16; + + // This is necessary because technically, ValueSetter does not + // unmount when we change between parameters, leading to the possibility + // for a stale "value" state in this controlled component + useEffect(() => { + setValue(Number(startValue) * scale); + }, [parameterName, startValue, scale]); + if (parameter.unit === "bool" || parameter.unit === "abolition") { return (
@@ -388,44 +402,120 @@ function ValueSetter(props) {
); } else { - const isPercent = parameter.unit === "/1"; - const scale = isPercent ? 100 : 1; - const isCurrency = Object.keys(currencyMap).includes(parameter.unit); - const maximumFractionDigits = isCurrency ? 2 : 16; return ( - { - const n = +value; - const isInteger = Number.isInteger(n); - return n.toLocaleString(localeCode(metadata.countryId), { - minimumFractionDigits: userTyping || isInteger ? 0 : 2, - maximumFractionDigits: userTyping ? 16 : maximumFractionDigits, - }); - }} - defaultValue={Number(startValue) * scale} - onPressEnter={(_, value) => - changeHandler(+value.toFixed(maximumFractionDigits) / scale) - } - /> + + { + const n = +value; + const isInteger = Number.isInteger(n); + return n.toLocaleString(localeCode(metadata.countryId), { + minimumFractionDigits: userTyping || isInteger ? 0 : 2, + maximumFractionDigits: userTyping ? 16 : maximumFractionDigits, + }); + }} + defaultValue={Number(startValue) * scale} + value={value} + onChange={(value) => setValue(value)} + onPressEnter={() => { + changeHandler(+value.toFixed(maximumFractionDigits) / scale); + }} + /> + {!isPercent && ( + + )} + ); } } +function AdvancedValueSetter(props) { + const { changeHandler, setValue } = props; + + const [isExpanded, setIsExpanded] = useState(false); + + function handleExpand() { + setIsExpanded((prev) => !prev); + } + + function handleValueInput(value) { + setValue(value); + changeHandler(value); + } + + return ( + <> + {isExpanded ? ( + + + + + + + + + + + + ) : ( + + + + )} + + ); +} + /** * Checks whether or not an input date is a boundary date - * the first or last day of a fixed period (e.g., Jan. 1 or diff --git a/src/pages/policy/output/FetchAndDisplayImpact.jsx b/src/pages/policy/output/FetchAndDisplayImpact.jsx index df266a1e5..e7a2c3143 100644 --- a/src/pages/policy/output/FetchAndDisplayImpact.jsx +++ b/src/pages/policy/output/FetchAndDisplayImpact.jsx @@ -12,6 +12,7 @@ import { defaultYear } from "data/constants"; import { areObjectsSame } from "../../../data/areObjectsSame"; import { updateUserPolicy } from "../../../api/userPolicies"; import useCountryId from "../../../hooks/useCountryId"; +import { wrappedResponseJson } from "../../../data/wrappedJson"; // import LoadingCentered from "layout/LoadingCentered"; /** @@ -92,7 +93,7 @@ export function FetchAndDisplayImpact(props) { setSecondsElapsed((secondsElapsed) => secondsElapsed + 1); }, 1000); apiCall(url, null) - .then((res) => res.json()) + .then((res) => wrappedResponseJson(res)) .then((intermediateData) => { if (averageImpactTime === 20) { setAverageImpactTime(intermediateData.average_time || 20);