From bb206300da872e7145bc4f0b6e08e2c7cba0c1d8 Mon Sep 17 00:00:00 2001 From: Jeff Johnson Date: Mon, 3 Nov 2025 11:42:51 -0800 Subject: [PATCH 1/5] fix: add nullToUndefined helper to transform API response types - Created nullToUndefined helper that recursively converts null to undefined - Updated PayrollEditEmployee and PayrollConfiguration to use the helper - Fixes type mismatch between API responses (null) and requests (undefined) - Added comprehensive test coverage (11 tests) - Upgraded @gusto/embedded-api from 0.8.1 to 0.10.0 - All tests, linting, and build passing --- package-lock.json | 195 ++++++++++-------- package.json | 2 +- .../PayrollConfiguration.tsx | 5 +- .../PayrollEditEmployee.tsx | 5 +- src/helpers/nullToUndefined.test.ts | 116 +++++++++++ src/helpers/nullToUndefined.ts | 27 +++ 6 files changed, 263 insertions(+), 87 deletions(-) create mode 100644 src/helpers/nullToUndefined.test.ts create mode 100644 src/helpers/nullToUndefined.ts diff --git a/package-lock.json b/package-lock.json index 065abc4b6..bff5a4c16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.17.0", "license": "MIT", "dependencies": { - "@gusto/embedded-api": "^0.8.1", + "@gusto/embedded-api": "^0.10.0", "@hookform/error-message": "^2.0.1", "@hookform/resolvers": "^5.2.2", "@internationalized/date": "^3.10.0", @@ -453,35 +453,51 @@ "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@cacheable/memoize": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@cacheable/memoize/-/memoize-2.0.2.tgz", - "integrity": "sha512-wPrr7FUiq3Qt4yQyda2/NcOLTJCFcQSU3Am2adP+WLy+sz93/fKTokVTHmtz+rjp4PD7ee0AEOeRVNN6IvIfsg==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@cacheable/memoize/-/memoize-2.0.3.tgz", + "integrity": "sha512-hl9wfQgpiydhQEIv7fkjEzTGE+tcosCXLKFDO707wYJ/78FVOlowb36djex5GdbSyeHnG62pomYLMuV/OT8Pbw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@cacheable/utils": "^2.0.2" + "@cacheable/utils": "^2.0.3" } }, "node_modules/@cacheable/memory": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.2.tgz", - "integrity": "sha512-sJTITLfeCI1rg7P3ssaGmQryq235EGT8dXGcx6oZwX5NRnKq9IE6lddlllcOl+oXW+yaeTRddCjo0xrfU6ZySA==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.4.tgz", + "integrity": "sha512-cCmJKCKlT1t7hNBI1+gFCwmKFd9I4pS3zqBeNGXTSODnpa0EeDmORHY8oEMTuozfdg3cgsVh8ojLaPYb6eC7Cg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@cacheable/memoize": "^2.0.1", - "@cacheable/utils": "^2.0.2", - "@keyv/bigmap": "^1.0.2", - "hookified": "^1.12.1", - "keyv": "^5.5.2" + "@cacheable/utils": "^2.2.0", + "@keyv/bigmap": "^1.1.0", + "hookified": "^1.12.2", + "keyv": "^5.5.3" + } + }, + "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.1.0.tgz", + "integrity": "sha512-MX7XIUNwVRK+hjZcAbNJ0Z8DREo+Weu9vinBOjGU1thEi9F6vPhICzBbk4CCf3eEefKRz7n6TfZXwUFZTSgj8Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "hookified": "^1.12.2" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.5.3" } }, "node_modules/@cacheable/memory/node_modules/keyv": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.2.tgz", - "integrity": "sha512-TXcFHbmm/z7MGd1u9ASiCSfTS+ei6Z8B3a5JHzx3oPa/o7QzWVtPRpc4KGER5RR469IC+/nfg4U5YLIuDUua2g==", + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.3.tgz", + "integrity": "sha512-h0Un1ieD+HUrzBH6dJXhod3ifSghk5Hw/2Y4/KHBziPlZecrFyE9YOTPU6eOs0V9pYl8gOs86fkr/KN8lUX39A==", "dev": true, "license": "MIT", "peer": true, @@ -490,12 +506,26 @@ } }, "node_modules/@cacheable/utils": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.0.2.tgz", - "integrity": "sha512-JTFM3raFhVv8LH95T7YnZbf2YoE9wEtkPPStuRF9a6ExZ103hFvs+QyCuYJ6r0hA9wRtbzgZtwUCoDWxssZd4Q==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.2.0.tgz", + "integrity": "sha512-7xaQayO3msdVcxXLYcLU5wDqJBNdQcPPPHr6mdTEIQI7N7TbtSVVTpWOTfjyhg0L6AQwQdq7miKdWtTDBoBldQ==", "dev": true, "license": "MIT", - "peer": true + "peer": true, + "dependencies": { + "keyv": "^5.5.3" + } + }, + "node_modules/@cacheable/utils/node_modules/keyv": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.3.tgz", + "integrity": "sha512-h0Un1ieD+HUrzBH6dJXhod3ifSghk5Hw/2Y4/KHBziPlZecrFyE9YOTPU6eOs0V9pYl8gOs86fkr/KN8lUX39A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@keyv/serialize": "^1.1.1" + } }, "node_modules/@commitlint/cli": { "version": "20.1.0", @@ -1605,11 +1635,11 @@ } }, "node_modules/@gusto/embedded-api": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@gusto/embedded-api/-/embedded-api-0.8.1.tgz", - "integrity": "sha512-qSMsaXidbTDz4zoF3bX4jrELY3YtqjpXjFyFb6BIGo1Xh8DRxCvGdtoSttV2OF/Og2bdr5C+C1RK0sAjjpselQ==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@gusto/embedded-api/-/embedded-api-0.10.0.tgz", + "integrity": "sha512-0myG3+9ea8rnyRkdGGV4YRFetNGhk9ThTNAiof4kzG96ff0yLCZOBmS2joIUksfPGGAq03Sb1wtITLwg19EUaw==", "dependencies": { - "zod": "^3.20.0" + "zod": "^3.25.0 || ^4.0.0" }, "peerDependencies": { "@tanstack/react-query": "^5", @@ -2206,20 +2236,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@keyv/bigmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.0.2.tgz", - "integrity": "sha512-KR03xkEZlAZNF4IxXgVXb+uNIVNvwdh8UwI0cnc7WI6a+aQcDp8GL80qVfeB4E5NpsKJzou5jU0r6yLSSbMOtA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "hookified": "^1.12.1" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/@keyv/serialize": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", @@ -5779,9 +5795,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.90.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.2.tgz", - "integrity": "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==", + "version": "5.90.6", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.6.tgz", + "integrity": "sha512-AnZSLF26R8uX+tqb/ivdrwbVdGemdEDm1Q19qM6pry6eOZ6bEYiY7mWhzXT1YDIPTNEVcZ5kYP9nWjoxDLiIVw==", "license": "MIT", "peer": true, "funding": { @@ -5790,13 +5806,13 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.2.tgz", - "integrity": "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw==", + "version": "5.90.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", + "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", "license": "MIT", "peer": true, "dependencies": { - "@tanstack/query-core": "5.90.2" + "@tanstack/query-core": "5.90.6" }, "funding": { "type": "github", @@ -7540,24 +7556,25 @@ } }, "node_modules/cacheable": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.0.2.tgz", - "integrity": "sha512-dWjhLx8RWnPsAWVKwW/wI6OJpQ/hSVb1qS0NUif8TR9vRiSwci7Gey8x04kRU9iAF+Rnbtex5Kjjfg/aB5w8Pg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.1.1.tgz", + "integrity": "sha512-LmF4AXiSNdiRbI2UjH8pAp9NIXxeQsTotpEaegPiDcnN0YPygDJDV3l/Urc0mL72JWdATEorKqIHEx55nDlONg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@cacheable/memoize": "^2.0.2", - "@cacheable/memory": "^2.0.2", - "@cacheable/utils": "^2.0.2", - "hookified": "^1.12.1", - "keyv": "^5.5.2" + "@cacheable/memoize": "^2.0.3", + "@cacheable/memory": "^2.0.3", + "@cacheable/utils": "^2.1.0", + "hookified": "^1.12.2", + "keyv": "^5.5.3", + "qified": "^0.5.0" } }, "node_modules/cacheable/node_modules/keyv": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.2.tgz", - "integrity": "sha512-TXcFHbmm/z7MGd1u9ASiCSfTS+ei6Z8B3a5JHzx3oPa/o7QzWVtPRpc4KGER5RR469IC+/nfg4U5YLIuDUua2g==", + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.3.tgz", + "integrity": "sha512-h0Un1ieD+HUrzBH6dJXhod3ifSghk5Hw/2Y4/KHBziPlZecrFyE9YOTPU6eOs0V9pYl8gOs86fkr/KN8lUX39A==", "dev": true, "license": "MIT", "peer": true, @@ -11008,9 +11025,9 @@ "license": "MIT" }, "node_modules/hookified": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.12.1.tgz", - "integrity": "sha512-xnKGl+iMIlhrZmGHB729MqlmPoWBznctSQTYCpFKqNsCgimJQmithcW0xSQMMFzYnV2iKUh25alswn6epgxS0Q==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.12.2.tgz", + "integrity": "sha512-aokUX1VdTpI0DUsndvW+OiwmBpKCu/NgRsSSkuSY0zq8PY6Q6a+lmOfAFDXAAOtBqJELvcWY9L1EVtzjbQcMdg==", "dev": true, "license": "MIT", "peer": true @@ -15745,6 +15762,20 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.5.1.tgz", + "integrity": "sha512-+BtFN3dCP+IaFA6IYNOu/f/uK1B8xD2QWyOeCse0rjtAebBmkzgd2d1OAXi3ikAzJMIBSdzZDNZ3wZKEUDQs5w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "hookified": "^1.12.2" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -15802,9 +15833,9 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", - "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", "peer": true, "engines": { @@ -15948,16 +15979,16 @@ } }, "node_modules/react-dom": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", - "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", "peer": true, "dependencies": { - "scheduler": "^0.26.0" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.1.1" + "react": "^19.2.0" } }, "node_modules/react-error-boundary": { @@ -17103,9 +17134,9 @@ } }, "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT", "peer": true }, @@ -17877,9 +17908,9 @@ } }, "node_modules/stylelint": { - "version": "16.24.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.24.0.tgz", - "integrity": "sha512-7ksgz3zJaSbTUGr/ujMXvLVKdDhLbGl3R/3arNudH7z88+XZZGNLMTepsY28WlnvEFcuOmUe7fg40Q3lfhOfSQ==", + "version": "16.25.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.25.0.tgz", + "integrity": "sha512-Li0avYWV4nfv1zPbdnxLYBGq4z8DVZxbRgx4Kn6V+Uftz1rMoF1qiEI3oL4kgWqyYgCgs7gT5maHNZ82Gk03vQ==", "dev": true, "funding": [ { @@ -17898,13 +17929,13 @@ "@csstools/css-tokenizer": "^3.0.4", "@csstools/media-query-list-parser": "^4.0.3", "@csstools/selector-specificity": "^5.0.0", - "@dual-bundle/import-meta-resolve": "^4.1.0", + "@dual-bundle/import-meta-resolve": "^4.2.1", "balanced-match": "^2.0.0", "colord": "^2.9.3", "cosmiconfig": "^9.0.0", "css-functions-list": "^3.2.3", "css-tree": "^3.1.0", - "debug": "^4.4.1", + "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", "file-entry-cache": "^10.1.4", @@ -17968,14 +17999,14 @@ } }, "node_modules/stylelint/node_modules/flat-cache": { - "version": "6.1.14", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.14.tgz", - "integrity": "sha512-ExZSCSV9e7v/Zt7RzCbX57lY2dnPdxzU/h3UE6WJ6NtEMfwBd8jmi1n4otDEUfz+T/R+zxrFDpICFdjhD3H/zw==", + "version": "6.1.18", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.18.tgz", + "integrity": "sha512-JUPnFgHMuAVmLmoH9/zoZ6RHOt5n9NlUw/sDXsTbROJ2SFoS2DS4s+swAV6UTeTbGH/CAsZIE6M8TaG/3jVxgQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "cacheable": "^2.0.1", + "cacheable": "^2.1.0", "flatted": "^3.3.3", "hookified": "^1.12.0" } @@ -18735,9 +18766,9 @@ } }, "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", "peer": true, "bin": { diff --git a/package.json b/package.json index 9aae4efb1..0314c34dc 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "typescript": "^5.8.3" }, "dependencies": { - "@gusto/embedded-api": "^0.8.1", + "@gusto/embedded-api": "^0.10.0", "@hookform/error-message": "^2.0.1", "@hookform/resolvers": "^5.2.2", "@internationalized/date": "^3.10.0", diff --git a/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx b/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx index e01a13766..e9a7b68d6 100644 --- a/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx +++ b/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx @@ -19,6 +19,7 @@ import { useComponentDictionary, useI18n } from '@/i18n' import { useBase } from '@/components/Base' import type { PaginationItemsPerPage } from '@/components/Common/PaginationControl/PaginationControlTypes' import { useDateFormatter } from '@/hooks/useDateFormatter' +import { nullToUndefined } from '@/helpers/nullToUndefined' const isCalculating = (processingRequest?: PayrollProcessingRequest | null) => processingRequest?.status === PayrollProcessingRequestStatus.Calculating @@ -157,11 +158,11 @@ export const Root = ({ paymentMethod, ...compensation }: PayrollEmployeeCompensationsType): PayrollUpdateEmployeeCompensations => { - return { + return nullToUndefined({ ...compensation, ...(paymentMethod && paymentMethod !== 'Historical' ? { paymentMethod } : {}), memo: compensation.memo || undefined, - } + }) } const onToggleExclude = async (employeeCompensation: PayrollEmployeeCompensationsType) => { onEvent(componentEvents.RUN_PAYROLL_EMPLOYEE_SKIP, { diff --git a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx index e17f78dbe..a7da6a2f8 100644 --- a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx +++ b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx @@ -10,6 +10,7 @@ import type { BaseComponentInterface } from '@/components/Base/Base' import { BaseComponent } from '@/components/Base/Base' import { useComponentDictionary } from '@/i18n' import { useBase } from '@/components/Base/useBase' +import { nullToUndefined } from '@/helpers/nullToUndefined' interface PayrollEditEmployeeProps extends BaseComponentInterface<'Payroll.PayrollEditEmployee'> { employeeId: string @@ -53,11 +54,11 @@ export const Root = ({ paymentMethod, ...compensation }: PayrollEmployeeCompensationsType): PayrollUpdateEmployeeCompensations => { - return { + return nullToUndefined({ ...compensation, ...(paymentMethod && paymentMethod !== 'Historical' ? { paymentMethod } : {}), memo: compensation.memo || undefined, - } + }) } const onSave = async (updatedCompensation: PayrollEmployeeCompensationsType) => { diff --git a/src/helpers/nullToUndefined.test.ts b/src/helpers/nullToUndefined.test.ts new file mode 100644 index 000000000..c081a463c --- /dev/null +++ b/src/helpers/nullToUndefined.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest' +import { nullToUndefined } from './nullToUndefined' + +describe('nullToUndefined', () => { + it('converts null to undefined', () => { + expect(nullToUndefined(null)).toBe(undefined) + }) + + it('preserves undefined', () => { + expect(nullToUndefined(undefined)).toBe(undefined) + }) + + it('preserves primitive values', () => { + expect(nullToUndefined('string')).toBe('string') + expect(nullToUndefined(123)).toBe(123) + expect(nullToUndefined(true)).toBe(true) + expect(nullToUndefined(false)).toBe(false) + }) + + it('converts null in simple objects', () => { + const input = { + name: 'test', + description: null, + count: 42, + } + const result = nullToUndefined(input) + expect(result).toEqual({ + name: 'test', + description: undefined, + count: 42, + }) + }) + + it('converts null in nested objects', () => { + const input = { + outer: { + inner: { + value: null, + other: 'kept', + }, + }, + } + const result = nullToUndefined(input) + expect(result).toEqual({ + outer: { + inner: { + value: undefined, + other: 'kept', + }, + }, + }) + }) + + it('converts null in arrays', () => { + const input = ['a', null, 'b', null] + const result = nullToUndefined(input) + expect(result).toEqual(['a', undefined, 'b', undefined]) + }) + + it('converts null in arrays of objects', () => { + const input = [ + { name: 'first', description: null }, + { name: 'second', description: 'has description' }, + { name: 'third', description: null }, + ] + const result = nullToUndefined(input) + expect(result).toEqual([ + { name: 'first', description: undefined }, + { name: 'second', description: 'has description' }, + { name: 'third', description: undefined }, + ]) + }) + + it('handles complex nested structures', () => { + const input = { + employee: { + name: 'John', + reimbursements: [ + { amount: '100', description: null, uuid: null }, + { amount: '200', description: 'Travel', uuid: 'abc-123' }, + ], + benefits: null, + }, + paymentMethod: 'Direct Deposit', + } + const result = nullToUndefined(input) + expect(result).toEqual({ + employee: { + name: 'John', + reimbursements: [ + { amount: '100', description: undefined, uuid: undefined }, + { amount: '200', description: 'Travel', uuid: 'abc-123' }, + ], + benefits: undefined, + }, + paymentMethod: 'Direct Deposit', + }) + }) + + it('handles empty objects and arrays', () => { + expect(nullToUndefined({})).toEqual({}) + expect(nullToUndefined([])).toEqual([]) + }) + + it('preserves empty strings', () => { + const input = { value: '' } + const result = nullToUndefined(input) + expect(result).toEqual({ value: '' }) + }) + + it('preserves zero values', () => { + const input = { count: 0, amount: 0 } + const result = nullToUndefined(input) + expect(result).toEqual({ count: 0, amount: 0 }) + }) +}) diff --git a/src/helpers/nullToUndefined.ts b/src/helpers/nullToUndefined.ts new file mode 100644 index 000000000..3f2e4fc9a --- /dev/null +++ b/src/helpers/nullToUndefined.ts @@ -0,0 +1,27 @@ +type NullToUndefined = T extends null + ? undefined + : T extends object + ? { [K in keyof T]: NullToUndefined } + : T + +export function nullToUndefined(value: T): NullToUndefined { + if (value === null) { + return undefined as NullToUndefined + } + + if (Array.isArray(value)) { + return value.map(item => nullToUndefined(item)) as NullToUndefined + } + + if (typeof value === 'object') { + const result: Record = {} + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + result[key] = nullToUndefined(value[key]) + } + } + return result as NullToUndefined + } + + return value as NullToUndefined +} From b044522aefa8767441d391847b001d120b64d994 Mon Sep 17 00:00:00 2001 From: Jeff Johnson Date: Tue, 4 Nov 2025 11:23:49 -0800 Subject: [PATCH 2/5] fix: remove nullToUNdefined --- .../PayrollConfiguration.tsx | 8 +- .../PayrollEditEmployee.tsx | 8 +- src/helpers/nullToUndefined.test.ts | 116 ------------------ src/helpers/nullToUndefined.ts | 27 ---- 4 files changed, 10 insertions(+), 149 deletions(-) delete mode 100644 src/helpers/nullToUndefined.test.ts delete mode 100644 src/helpers/nullToUndefined.ts diff --git a/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx b/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx index e9a7b68d6..bf4ed5612 100644 --- a/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx +++ b/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx @@ -19,7 +19,6 @@ import { useComponentDictionary, useI18n } from '@/i18n' import { useBase } from '@/components/Base' import type { PaginationItemsPerPage } from '@/components/Common/PaginationControl/PaginationControlTypes' import { useDateFormatter } from '@/hooks/useDateFormatter' -import { nullToUndefined } from '@/helpers/nullToUndefined' const isCalculating = (processingRequest?: PayrollProcessingRequest | null) => processingRequest?.status === PayrollProcessingRequestStatus.Calculating @@ -158,11 +157,14 @@ export const Root = ({ paymentMethod, ...compensation }: PayrollEmployeeCompensationsType): PayrollUpdateEmployeeCompensations => { - return nullToUndefined({ + // TODO: API should handle null values properly without client-side transformation + // https://gusto.atlassian.net/browse/GWS-5811 + // Convert null memo to undefined as API may reject null + return { ...compensation, ...(paymentMethod && paymentMethod !== 'Historical' ? { paymentMethod } : {}), memo: compensation.memo || undefined, - }) + } } const onToggleExclude = async (employeeCompensation: PayrollEmployeeCompensationsType) => { onEvent(componentEvents.RUN_PAYROLL_EMPLOYEE_SKIP, { diff --git a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx index a7da6a2f8..1e60ab381 100644 --- a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx +++ b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx @@ -10,7 +10,6 @@ import type { BaseComponentInterface } from '@/components/Base/Base' import { BaseComponent } from '@/components/Base/Base' import { useComponentDictionary } from '@/i18n' import { useBase } from '@/components/Base/useBase' -import { nullToUndefined } from '@/helpers/nullToUndefined' interface PayrollEditEmployeeProps extends BaseComponentInterface<'Payroll.PayrollEditEmployee'> { employeeId: string @@ -54,11 +53,14 @@ export const Root = ({ paymentMethod, ...compensation }: PayrollEmployeeCompensationsType): PayrollUpdateEmployeeCompensations => { - return nullToUndefined({ + // TODO: API should handle null values properly without client-side transformation + // https://gusto.atlassian.net/browse/GWS-5811 + // Convert null memo to undefined as API may reject null + return { ...compensation, ...(paymentMethod && paymentMethod !== 'Historical' ? { paymentMethod } : {}), memo: compensation.memo || undefined, - }) + } } const onSave = async (updatedCompensation: PayrollEmployeeCompensationsType) => { diff --git a/src/helpers/nullToUndefined.test.ts b/src/helpers/nullToUndefined.test.ts deleted file mode 100644 index c081a463c..000000000 --- a/src/helpers/nullToUndefined.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { nullToUndefined } from './nullToUndefined' - -describe('nullToUndefined', () => { - it('converts null to undefined', () => { - expect(nullToUndefined(null)).toBe(undefined) - }) - - it('preserves undefined', () => { - expect(nullToUndefined(undefined)).toBe(undefined) - }) - - it('preserves primitive values', () => { - expect(nullToUndefined('string')).toBe('string') - expect(nullToUndefined(123)).toBe(123) - expect(nullToUndefined(true)).toBe(true) - expect(nullToUndefined(false)).toBe(false) - }) - - it('converts null in simple objects', () => { - const input = { - name: 'test', - description: null, - count: 42, - } - const result = nullToUndefined(input) - expect(result).toEqual({ - name: 'test', - description: undefined, - count: 42, - }) - }) - - it('converts null in nested objects', () => { - const input = { - outer: { - inner: { - value: null, - other: 'kept', - }, - }, - } - const result = nullToUndefined(input) - expect(result).toEqual({ - outer: { - inner: { - value: undefined, - other: 'kept', - }, - }, - }) - }) - - it('converts null in arrays', () => { - const input = ['a', null, 'b', null] - const result = nullToUndefined(input) - expect(result).toEqual(['a', undefined, 'b', undefined]) - }) - - it('converts null in arrays of objects', () => { - const input = [ - { name: 'first', description: null }, - { name: 'second', description: 'has description' }, - { name: 'third', description: null }, - ] - const result = nullToUndefined(input) - expect(result).toEqual([ - { name: 'first', description: undefined }, - { name: 'second', description: 'has description' }, - { name: 'third', description: undefined }, - ]) - }) - - it('handles complex nested structures', () => { - const input = { - employee: { - name: 'John', - reimbursements: [ - { amount: '100', description: null, uuid: null }, - { amount: '200', description: 'Travel', uuid: 'abc-123' }, - ], - benefits: null, - }, - paymentMethod: 'Direct Deposit', - } - const result = nullToUndefined(input) - expect(result).toEqual({ - employee: { - name: 'John', - reimbursements: [ - { amount: '100', description: undefined, uuid: undefined }, - { amount: '200', description: 'Travel', uuid: 'abc-123' }, - ], - benefits: undefined, - }, - paymentMethod: 'Direct Deposit', - }) - }) - - it('handles empty objects and arrays', () => { - expect(nullToUndefined({})).toEqual({}) - expect(nullToUndefined([])).toEqual([]) - }) - - it('preserves empty strings', () => { - const input = { value: '' } - const result = nullToUndefined(input) - expect(result).toEqual({ value: '' }) - }) - - it('preserves zero values', () => { - const input = { count: 0, amount: 0 } - const result = nullToUndefined(input) - expect(result).toEqual({ count: 0, amount: 0 }) - }) -}) diff --git a/src/helpers/nullToUndefined.ts b/src/helpers/nullToUndefined.ts deleted file mode 100644 index 3f2e4fc9a..000000000 --- a/src/helpers/nullToUndefined.ts +++ /dev/null @@ -1,27 +0,0 @@ -type NullToUndefined = T extends null - ? undefined - : T extends object - ? { [K in keyof T]: NullToUndefined } - : T - -export function nullToUndefined(value: T): NullToUndefined { - if (value === null) { - return undefined as NullToUndefined - } - - if (Array.isArray(value)) { - return value.map(item => nullToUndefined(item)) as NullToUndefined - } - - if (typeof value === 'object') { - const result: Record = {} - for (const key in value) { - if (Object.prototype.hasOwnProperty.call(value, key)) { - result[key] = nullToUndefined(value[key]) - } - } - return result as NullToUndefined - } - - return value as NullToUndefined -} From 2a4a0ec06b9cea3fb9792e19cf36671cffb6f3ca Mon Sep 17 00:00:00 2001 From: Jeff Johnson Date: Tue, 4 Nov 2025 11:39:56 -0800 Subject: [PATCH 3/5] fix: update muttations --- .../PayrollConfiguration/PayrollConfiguration.tsx | 15 ++++++++++++--- .../PayrollEditEmployee/PayrollEditEmployee.tsx | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx b/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx index bf4ed5612..2942109c0 100644 --- a/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx +++ b/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx @@ -158,12 +158,21 @@ export const Root = ({ ...compensation }: PayrollEmployeeCompensationsType): PayrollUpdateEmployeeCompensations => { // TODO: API should handle null values properly without client-side transformation - // https://gusto.atlassian.net/browse/GWS-5811 - // Convert null memo to undefined as API may reject null + // GWS-5811 + const { reimbursements, ...rest } = compensation as { + reimbursements?: Array<{ description: string | null }> + } & typeof compensation + return { - ...compensation, + ...rest, ...(paymentMethod && paymentMethod !== 'Historical' ? { paymentMethod } : {}), memo: compensation.memo || undefined, + ...(reimbursements && { + reimbursements: reimbursements.map(r => ({ + ...r, + description: r.description ?? undefined, + })), + }), } } const onToggleExclude = async (employeeCompensation: PayrollEmployeeCompensationsType) => { diff --git a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx index 1e60ab381..9c80aebbf 100644 --- a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx +++ b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx @@ -54,12 +54,21 @@ export const Root = ({ ...compensation }: PayrollEmployeeCompensationsType): PayrollUpdateEmployeeCompensations => { // TODO: API should handle null values properly without client-side transformation - // https://gusto.atlassian.net/browse/GWS-5811 - // Convert null memo to undefined as API may reject null + // GWS-5811 + const { reimbursements, ...rest } = compensation as { + reimbursements?: Array<{ description: string | null }> + } & typeof compensation + return { - ...compensation, + ...rest, ...(paymentMethod && paymentMethod !== 'Historical' ? { paymentMethod } : {}), memo: compensation.memo || undefined, + ...(reimbursements && { + reimbursements: reimbursements.map(r => ({ + ...r, + description: r.description ?? undefined, + })), + }), } } From 78a16cd592678f285084333c00a9b65bd68dbaae Mon Sep 17 00:00:00 2001 From: Jeff Johnson Date: Tue, 4 Nov 2025 12:41:42 -0800 Subject: [PATCH 4/5] fix: update types, and inlining of reimbursements --- .../PayrollConfiguration/PayrollConfiguration.tsx | 10 ++++------ .../PayrollEditEmployee/PayrollEditEmployee.tsx | 10 ++++------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx b/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx index 2942109c0..556a267c2 100644 --- a/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx +++ b/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx @@ -155,22 +155,20 @@ export const Root = ({ } const transformEmployeeCompensation = ({ paymentMethod, + reimbursements, ...compensation }: PayrollEmployeeCompensationsType): PayrollUpdateEmployeeCompensations => { // TODO: API should handle null values properly without client-side transformation // GWS-5811 - const { reimbursements, ...rest } = compensation as { - reimbursements?: Array<{ description: string | null }> - } & typeof compensation - return { - ...rest, + ...compensation, ...(paymentMethod && paymentMethod !== 'Historical' ? { paymentMethod } : {}), memo: compensation.memo || undefined, ...(reimbursements && { reimbursements: reimbursements.map(r => ({ - ...r, + amount: r.amount, description: r.description ?? undefined, + uuid: r.uuid ?? undefined, })), }), } diff --git a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx index 9c80aebbf..e1ebb5a8a 100644 --- a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx +++ b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx @@ -51,22 +51,20 @@ export const Root = ({ const transformEmployeeCompensation = ({ paymentMethod, + reimbursements, ...compensation }: PayrollEmployeeCompensationsType): PayrollUpdateEmployeeCompensations => { // TODO: API should handle null values properly without client-side transformation // GWS-5811 - const { reimbursements, ...rest } = compensation as { - reimbursements?: Array<{ description: string | null }> - } & typeof compensation - return { - ...rest, + ...compensation, ...(paymentMethod && paymentMethod !== 'Historical' ? { paymentMethod } : {}), memo: compensation.memo || undefined, ...(reimbursements && { reimbursements: reimbursements.map(r => ({ - ...r, + amount: r.amount, description: r.description ?? undefined, + uuid: r.uuid ?? undefined, })), }), } From 603d2aa0ed55631db9b16de11fc1624765a6e5e3 Mon Sep 17 00:00:00 2001 From: Jeff Johnson Date: Tue, 4 Nov 2025 14:01:39 -0800 Subject: [PATCH 5/5] fix: pr fixes --- .../PayrollConfiguration/PayrollConfiguration.tsx | 9 --------- .../Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx | 9 --------- 2 files changed, 18 deletions(-) diff --git a/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx b/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx index 556a267c2..668671c55 100644 --- a/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx +++ b/src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx @@ -158,19 +158,10 @@ export const Root = ({ reimbursements, ...compensation }: PayrollEmployeeCompensationsType): PayrollUpdateEmployeeCompensations => { - // TODO: API should handle null values properly without client-side transformation - // GWS-5811 return { ...compensation, ...(paymentMethod && paymentMethod !== 'Historical' ? { paymentMethod } : {}), memo: compensation.memo || undefined, - ...(reimbursements && { - reimbursements: reimbursements.map(r => ({ - amount: r.amount, - description: r.description ?? undefined, - uuid: r.uuid ?? undefined, - })), - }), } } const onToggleExclude = async (employeeCompensation: PayrollEmployeeCompensationsType) => { diff --git a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx index e1ebb5a8a..790a6b082 100644 --- a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx +++ b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx @@ -54,19 +54,10 @@ export const Root = ({ reimbursements, ...compensation }: PayrollEmployeeCompensationsType): PayrollUpdateEmployeeCompensations => { - // TODO: API should handle null values properly without client-side transformation - // GWS-5811 return { ...compensation, ...(paymentMethod && paymentMethod !== 'Historical' ? { paymentMethod } : {}), memo: compensation.memo || undefined, - ...(reimbursements && { - reimbursements: reimbursements.map(r => ({ - amount: r.amount, - description: r.description ?? undefined, - uuid: r.uuid ?? undefined, - })), - }), } }