diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 8da2849358..d34e6c28eb 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.25.0", + "version": "7.25.1-fb-parseComma.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.25.0", + "version": "7.25.1-fb-parseComma.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", @@ -24,6 +24,7 @@ "immutable": "~3.8.2", "normalizr": "~3.6.2", "numeral": "~2.0.6", + "papaparse": "5.5.3", "react": "~18.3.1", "react-color": "~2.19.3", "react-datepicker": "~7.6.0", @@ -2524,71 +2525,6 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", - "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.2", - "debug": "^4.3.1", - "minimatch": "^10.2.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", - "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.1.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", - "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.1.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, "node_modules/@floating-ui/core": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", @@ -3809,6 +3745,188 @@ "typescript-eslint": "8.56.1" } }, + "node_modules/@labkey/eslint-config/node_modules/@eslint/config-array": { + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.3", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@labkey/eslint-config/node_modules/@eslint/config-helpers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@labkey/eslint-config/node_modules/@eslint/core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@labkey/eslint-config/node_modules/@eslint/object-schema": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@labkey/eslint-config/node_modules/@eslint/plugin-kit": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@labkey/eslint-config/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@labkey/eslint-config/node_modules/eslint": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", + "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.2", + "@eslint/config-helpers": "^0.5.2", + "@eslint/core": "^1.1.0", + "@eslint/plugin-kit": "^0.6.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.1", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.1.1", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.1", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/@labkey/eslint-config/node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@labkey/eslint-config/node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@labkey/eslint-config/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -4151,19 +4269,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/watcher/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@peculiar/asn1-cms": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", @@ -5994,6 +6099,19 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -8288,62 +8406,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", - "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.2", - "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.0", - "@eslint/plugin-kit": "^0.6.0", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.1", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.1.1", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.1", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, "node_modules/eslint-config-prettier": { "version": "10.1.8", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", @@ -8641,25 +8703,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-scope": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", - "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/eslint-visitor-keys": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", @@ -8673,48 +8716,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/espree": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", - "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -11942,19 +11943,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/jest-validate": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", @@ -12518,6 +12506,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -13194,6 +13195,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -13365,13 +13372,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -15840,19 +15847,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -17019,6 +17013,19 @@ "node": ">= 6" } }, + "node_modules/webpack-dev-server/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/webpack-dev-server/node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", diff --git a/packages/components/package.json b/packages/components/package.json index de75a67788..520ad20082 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.25.0", + "version": "7.25.1-fb-parseComma.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ @@ -67,6 +67,7 @@ "immutable": "~3.8.2", "normalizr": "~3.6.2", "numeral": "~2.0.6", + "papaparse": "5.5.3", "react": "~18.3.1", "react-color": "~2.19.3", "react-datepicker": "~7.6.0", diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 0a550f0493..697680c886 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -66,12 +66,13 @@ import { isNonNegativeFloat, isNonNegativeInteger, isSetEqual, + joinMultiValueForExport, makeCommaSeparatedString, - parseCsvString, parseScientificInt, pronoun, quoteValueWithDelimiters, setIsTestEnv, + splitMultiValueForImport, uncapitalizeFirstChar, valueIsEmpty, withTransformedKeys, @@ -1506,6 +1507,7 @@ export { JavaDocsLink, JobOperation, joinDateTimeFormat, + joinMultiValueForExport, Key, LabelColorRenderer, LabelHelpTip, @@ -1540,7 +1542,6 @@ export { MAX_EDITABLE_GRID_ROWS, MAX_SELECTION_ACTION_ROWS, MEASUREMENT_UNITS, - UNITS_KIND, MemberType, MenuDivider, MenuHeader, @@ -1578,7 +1579,6 @@ export { ParentEntityRequiredColumns, ParentImportAliasRenderer, parseCellKey, - parseCsvString, parseDate, parseEntityParentKey, parseScientificInt, @@ -1702,6 +1702,7 @@ export { spliceURL, SplitButton, splitDateTimeFormat, + splitMultiValueForImport, STORAGE_UNIQUE_ID_CONCEPT_URI, StorageAmountInput, StorageStatusRenderer, @@ -1727,6 +1728,7 @@ export { UnidentifiedPill, UNIQUE_ID_FIND_FIELD, UnitModel, + UNITS_KIND, updateCellKeySampleIdMap, updateCellValuesForSampleIds, updateColumnLookup, diff --git a/packages/components/src/internal/components/editable/actions.test.ts b/packages/components/src/internal/components/editable/actions.test.ts index 7fefffa7bc..fe48af0c9e 100644 --- a/packages/components/src/internal/components/editable/actions.test.ts +++ b/packages/components/src/internal/components/editable/actions.test.ts @@ -34,6 +34,7 @@ import { genCellKey } from './utils'; import sampleSetQueryInfoJSON from '../../../test/data/sampleSetAllFieldTypes-getQueryDetails.json'; import { MockEditableGridLoader } from './utils.test'; import { MULTI_CHOICE_TYPE } from '../domainproperties/PropDescType'; +import { joinMultiValueForExport } from '../../util/utils'; describe('column mutation actions', () => { const queryInfo = QueryInfo.fromJsonForTests(sampleSet2QueryInfo); @@ -859,7 +860,7 @@ describe('insertPastedData', () => { fieldKey: mvtc, jsonType: 'ARRAY', rangeURI: MULTI_CHOICE_TYPE.rangeURI, - validValues: ['a', 'ab', 'cc', 'cD', 'A,B', 'de'], + validValues: ['a', 'ab', 'cc', 'cD', 'A,B', 'de', 'A', 'B', 'C', 'A,B,C', '"A"', '"A",B', '"A,B,C"'], }), }, }); @@ -1136,6 +1137,20 @@ describe('insertPastedData', () => { ); }); + test('pasting multi-line value', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(fkOne, 0)], + selectedColIdx: 0, + selectedRowIdx: 2, + }); + const changes = await validateAndInsertPastedData(em, '"line1\nline2"', undefined, true, true, undefined, true); + const cellValues = changes.cellValues; + expect(cellValues.get(genCellKey(fkOne, 2))).toEqual(List([{ display: 'line1\nline2', raw: 'line1\nline2' }])); + + const cellMessages = changes.cellMessages; + expect(cellMessages.get(genCellKey(fkOne, 2))).toBeUndefined(); + }); + test('pasting multi values', async () => { const em = baseEditorModel.applyChanges({ selectionCells: [genCellKey(mvtc, 0)], @@ -1175,6 +1190,244 @@ describe('insertPastedData', () => { expect(cellMessages.get(genCellKey(mvtc, 1))).toBeUndefined(); expect(cellMessages.get(genCellKey(mvtc, 2))).toEqual({ message: 'Could not find "bad"' }); }); + + test('pasting string values with special characters, fromDragFill false', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(fkOne, 0), genCellKey(fkOne, 1), genCellKey(fkOne, 2)], + selectedColIdx: 0, + selectedRowIdx: 2, + }); + const changes = await validateAndInsertPastedData( + em, + 'hello world\n"hello, world"\n"say ""hello"""', + undefined, + true, + true, + undefined, + true + ); + const cellValues = changes.cellValues; + // Space is preserved as-is + expect(cellValues.get(genCellKey(fkOne, 0))).toEqual(List([{ display: 'hello world', raw: 'hello world' }])); + // Quoted comma: without fromDragFill, CSV quoting is NOT stripped + expect(cellValues.get(genCellKey(fkOne, 1))).toEqual( + List([{ display: '"hello, world"', raw: '"hello, world"' }]) + ); + // Escaped double quotes: without fromDragFill, CSV escaping is NOT processed + expect(cellValues.get(genCellKey(fkOne, 2))).toEqual( + List([{ display: '"say ""hello"""', raw: '"say ""hello"""' }]) + ); + }); + + test('drag fill string values with special characters, fromDragFill true', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [ + genCellKey(fkOne, 0), + genCellKey(fkOne, 1), + genCellKey(fkOne, 2), + genCellKey(fkOne, 3), + genCellKey(fkOne, 4), + genCellKey(fkOne, 5), + ], + selectedColIdx: 0, + selectedRowIdx: 5, + }); + const changes = await validateAndInsertPastedData( + em, + 'hello world\n"hello, world"\n"say ""hello"""', + undefined, + true, + true, + undefined, + false, + [[genCellKey(fkOne, 3), genCellKey(fkOne, 4), genCellKey(fkOne, 5)]], + true + ); + const cellValues = changes.cellValues; + // Original values unchanged + expect(cellValues.get(genCellKey(fkOne, 0))).toEqual(List([{ display: 'qwer', raw: 'qwer' }])); + expect(cellValues.get(genCellKey(fkOne, 1))).toEqual(List([{ display: 'asdf', raw: 'asdf' }])); + expect(cellValues.get(genCellKey(fkOne, 2))).toEqual(List([{ display: 'zxcv', raw: 'zxcv' }])); + // Space: no CSV quoting to strip + expect(cellValues.get(genCellKey(fkOne, 3))).toEqual(List([{ display: 'hello world', raw: 'hello world' }])); + // Quoted comma: fromDragFill strips CSV quoting, comma preserved in value + expect(cellValues.get(genCellKey(fkOne, 4))).toEqual(List([{ display: 'hello, world', raw: 'hello, world' }])); + // Escaped double quotes: fromDragFill strips CSV quoting and unescapes "" + expect(cellValues.get(genCellKey(fkOne, 5))).toEqual(List([{ display: 'say "hello"', raw: 'say "hello"' }])); + }); + + test('pasting exactly A,B into mvtc matches single valid value', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(mvtc, 0)], + selectedColIdx: 3, + selectedRowIdx: 0, + }); + const changes = await validateAndInsertPastedData(em, '"A,B"', undefined, true, true, undefined, true); + expect(changes.cellValues.get(genCellKey(mvtc, 0))).toEqual(List([{ display: 'A,B', raw: 'A,B' }])); + expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toBeUndefined(); + }); + + test('pasting exactly A,B,C into mvtc matches single valid value', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(mvtc, 0)], + selectedColIdx: 3, + selectedRowIdx: 0, + }); + const changes = await validateAndInsertPastedData(em, '"A,B,C"', undefined, true, true, undefined, true); + expect(changes.cellValues.get(genCellKey(mvtc, 0))).toEqual(List([{ display: 'A,B,C', raw: 'A,B,C' }])); + expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toBeUndefined(); + }); + + test('pasting escaped "A,B,C" into mvtc matches single valid value', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(mvtc, 0)], + selectedColIdx: 3, + selectedRowIdx: 0, + }); + const changes = await validateAndInsertPastedData(em, '"""A,B,C"""', undefined, true, true, undefined, true); + expect(changes.cellValues.get(genCellKey(mvtc, 0))).toEqual(List([{ display: '"A,B,C"', raw: '"A,B,C"' }])); + expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toBeUndefined(); + }); + + test('pasting A, B with space into mvtc parses as two values', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(mvtc, 0)], + selectedColIdx: 3, + selectedRowIdx: 0, + }); + const changes = await validateAndInsertPastedData(em, 'A, B', undefined, true, true, undefined, true); + // 'A, B' does not match any validValue, so parsed as CSV → ' B' and 'A' (sorted with leading space) + expect(changes.cellValues.get(genCellKey(mvtc, 0))).toEqual( + List([ + { display: 'A', raw: 'A' }, + { display: 'B', raw: 'B' }, + ]) + ); + expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toBeUndefined(); + }); + + test('pasting quoted "A,B" into mvtc treats as single value', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(mvtc, 0)], + selectedColIdx: 3, + selectedRowIdx: 0, + }); + const changes = await validateAndInsertPastedData(em, '"A,B"', undefined, true, true, undefined, true); + // Quoted '"A,B"' is CSV-parsed to 'A,B' which is a valid value + expect(changes.cellValues.get(genCellKey(mvtc, 0))).toEqual(List([{ display: 'A,B', raw: 'A,B' }])); + expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toBeUndefined(); + }); + + test('pasting quoted "A, B" into mvtc, invalid', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(mvtc, 0)], + selectedColIdx: 3, + selectedRowIdx: 0, + }); + const changes = await validateAndInsertPastedData(em, '"A, B"', undefined, true, true, undefined, true); + // Quoted '"A,B"' is CSV-parsed to 'A,B' which is a valid value + expect(changes.cellValues.get(genCellKey(mvtc, 0))).toEqual(List([{ display: 'A, B', raw: 'A, B' }])); + expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toEqual({ + message: 'Could not find "A, B". Please make sure values that contain commas are properly quoted.', + }); + }); + + test('pasting mvtc values combined with other valid values', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(mvtc, 0), genCellKey(mvtc, 1), genCellKey(mvtc, 2)], + selectedColIdx: 3, + selectedRowIdx: 2, + }); + const changes = await validateAndInsertPastedData( + em, + '"A,B"\n"A,B",cc\nA,B,cc', + undefined, + true, + true, + undefined, + true + ); + const cellValues = changes.cellValues; + // Row 0: 'A,B' exactly matches single validValue + expect(cellValues.get(genCellKey(mvtc, 0))).toEqual(List([{ display: 'A,B', raw: 'A,B' }])); + expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toBeUndefined(); + // Row 1: '"A,B",cc' → CSV parsed to ['A,B', 'cc'], both valid + expect(cellValues.get(genCellKey(mvtc, 1))).toEqual( + List([ + { display: 'A,B', raw: 'A,B' }, + { display: 'cc', raw: 'cc' }, + ]) + ); + expect(changes.cellMessages.get(genCellKey(mvtc, 1))).toBeUndefined(); + // Row 2: 'A,B,cc' without quotes → CSV parsed to ['A', 'B', 'cc'], all valid + expect(cellValues.get(genCellKey(mvtc, 2))).toEqual( + List([ + { display: 'A', raw: 'A' }, + { display: 'B', raw: 'B' }, + { display: 'cc', raw: 'cc' }, + ]) + ); + expect(changes.cellMessages.get(genCellKey(mvtc, 2))).toBeUndefined(); + }); + + test('pasting mvtc values combined with invalid values', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(mvtc, 0), genCellKey(mvtc, 1)], + selectedColIdx: 3, + selectedRowIdx: 1, + }); + const changes = await validateAndInsertPastedData( + em, + 'A, B, bad\n"A,B",bad', + undefined, + true, + true, + undefined, + true + ); + const cellValues = changes.cellValues; + const cellMessages = changes.cellMessages; + // Row 0: 'A,B,bad' → CSV parsed to ['A', 'B', 'bad'], 'bad' invalid + expect(cellValues.get(genCellKey(mvtc, 0))).toEqual( + List([ + { display: 'A', raw: 'A' }, + { display: 'B', raw: 'B' }, + { display: 'bad', raw: 'bad' }, + ]) + ); + expect(cellMessages.get(genCellKey(mvtc, 0))).toEqual({ message: 'Could not find "bad"' }); + // Row 1: '"A,B",bad' → CSV parsed to ['A,B', 'bad'], 'bad' invalid + expect(cellValues.get(genCellKey(mvtc, 1))).toEqual( + List([ + { display: 'A,B', raw: 'A,B' }, + { display: 'bad', raw: 'bad' }, + ]) + ); + expect(cellMessages.get(genCellKey(mvtc, 1))).toEqual({ message: 'Could not find "bad"' }); + }); + + test.each([ + { values: ['A', 'B', 'C'], desc: 'simple values' }, + { values: ['"A",B'], desc: 'single value with quotes and comma' }, + { values: ['"A,B,C"'], desc: 'single value with quotes wrapping commas' }, + { values: ['"A",B', 'B'], desc: 'tricky + simple' }, + { values: ['A', '"A"', 'B'], desc: 'quotes among simple' }, + { values: ['A', 'A,B,C', '"A,B,C"'], desc: 'plain + comma + quote-containing' }, + { values: ['"A"', '"A",B', '"A,B,C"'], desc: 'all contain quotes' }, + ])('pasting multi values round-trip: $desc', async ({ values }) => { + const exported = joinMultiValueForExport(values); + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(mvtc, 0)], + selectedColIdx: 3, + selectedRowIdx: 0, + }); + const changes = await validateAndInsertPastedData(em, exported, undefined, true, true, undefined, true); + const cellValues = changes.cellValues.get(genCellKey(mvtc, 0)); + const sortedExpected = [...values].sort(); + const actualValues = cellValues.toArray().map(v => v.raw); + expect(actualValues).toStrictEqual(sortedExpected); + expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toBeUndefined(); + }); }); describe('loadEditorModelData', () => { diff --git a/packages/components/src/internal/components/editable/actions.ts b/packages/components/src/internal/components/editable/actions.ts index 581c245866..36c5d3af1b 100644 --- a/packages/components/src/internal/components/editable/actions.ts +++ b/packages/components/src/internal/components/editable/actions.ts @@ -1,6 +1,7 @@ import { Filter, getServerContext, QueryKey, Utils } from '@labkey/api'; import { fromJS, List, Map, OrderedMap } from 'immutable'; import { addDays, subDays } from 'date-fns'; +import Papa from 'papaparse'; import { ExtendedMap } from '../../../public/ExtendedMap'; import { QueryColumn, QueryLookup } from '../../../public/QueryColumn'; @@ -11,9 +12,12 @@ import { caseInsensitive, isFloat, isInteger, - parseCsvString, + isSimpleQuotedMultiLine, + joinMultiValueForExport, + NEWLINE_CHARS, parseScientificInt, quoteValueWithDelimiters, + splitMultiValueForImport, } from '../../util/utils'; import { ViewInfo } from '../../ViewInfo'; @@ -1108,7 +1112,7 @@ export function parsePastedLookup( // Parse pasted strings to split properly around quoted values. // Remove the quotes for storing the actual values in the grid. - const parsedValues = parseCsvString(value, ',', true); + const parsedValues = splitMultiValueForImport(value); // Issue 53055: Do not attempt to resolve multiple values for a single-value column if (!column.isJunctionLookup() && parsedValues.length > 1) { @@ -1215,7 +1219,7 @@ export function generateColumnFillValues( if (isReadonlyCell || isReadonlyRow) return ''; const initialValue = initialSelectionValues[i % initialSelectionValues.length]; - let value = initialValue.map(v => quoteValueWithDelimiters(v.display, ',')).join(','); + let value = joinMultiValueForExport(initialValue.map(v => v.display).toArray()); if (incrementType === IncrementType.NUMBER) { const amount = increment * (i + 1); let raw: number | string; @@ -1302,7 +1306,8 @@ export function dragFillEvent( forUpdate, targetContainerPath, false, - selectionToFill + selectionToFill, + true ); } @@ -1417,20 +1422,38 @@ function parsePaste(value: string): ParsePastePayload { let numCols = 0; let data = List>(); - if (value === undefined || value === null || typeof value !== 'string') { + if (value == null || typeof value !== 'string') { return { data, numCols, numRows: 0 }; } // remove trailing newline from pasted data to avoid creating an empty row of cells if (value.endsWith('\n')) value = value.substring(0, value.length - 1); - value.split('\n').forEach(rv => { - const columns = List(rv.split('\t')); - if (numCols < columns.size) { - numCols = columns.size; + if (value.indexOf('"') === -1 || isSimpleQuotedMultiLine(value)) { + // parse tsv ONLY if the copied string doesn't contain " + // quoteChar will be stripped during TSV parsing, resulting in incorrect parsed data + const rows = Papa.parse(value, { delimiter: '\t' }).data; + if (!rows || rows.length === 0) { + return { data, numCols, numRows: 0 }; } - data = data.push(columns); - }); + + rows.forEach(row => { + const columns: List = List(row); + if (numCols < columns.size) { + numCols = columns.size; + } + data = data.push(columns); + }); + } else { + // fall back to line by line processing without parsing, to preserve quotes + value.split('\n').forEach(rv => { + const columns = List(rv.split('\t')); + if (numCols < columns.size) { + numCols = columns.size; + } + data = data.push(columns); + }); + } // Normalize the number columns in each row in case a user pasted rows with different numbers of columns in them data = data @@ -1460,7 +1483,8 @@ async function insertPastedData( lockRowCount: boolean, forUpdate: boolean, targetContainerPath: string, - selectCells: boolean + selectCells: boolean, + fromDragFill?: boolean ): Promise> { const pastedData = paste.payload.data; let cellMessages = editorModel.cellMessages; @@ -1525,12 +1549,12 @@ async function insertPastedData( cv = valueDescriptors; msg = message; } else if (col?.isMultiChoice && Utils.isString(val)) { - const parsedValues = parseCsvString(val, ',', true).sort(caseSensitiveNaturalSort); - const unmatched: string[] = []; - const values = []; + const values: ValueDescriptor[] = []; + const parsedValues = splitMultiValueForImport(val, ',', true, true).sort(caseSensitiveNaturalSort); const foundValues = new Set(); + // GitHub Issue 942: Add error for duplicate values const dupValues = new Set(); parsedValues.forEach(v => { @@ -1566,7 +1590,19 @@ async function insertPastedData( } cv = List(values); } else { - const { message, value } = getValidatedEditableGridValue(val, col); + let valToValidate = val; + if (fromDragFill && Utils.isString(val)) { + // GitHub Issue 916: Copying/pasting in the grid doesn't always act as expected + // drag fill always quoteValueWithDelimiters, needs to remove the extra quotes before validating + const isMultiLinePasting = NEWLINE_CHARS.find(char => valToValidate.indexOf(char) > -1); + // multiline pasting has already been parsed + if (!isMultiLinePasting) { + const parsedValues = splitMultiValueForImport(val); + if (parsedValues.length === 1) valToValidate = parsedValues[0].trim(); + } + } + + const { message, value } = getValidatedEditableGridValue(valToValidate, col); let display = value; // Issue 52326: Copy/paste of date values across cells changes date formats @@ -1624,7 +1660,7 @@ function getPasteValuesByColumn(paste: PasteModel): List> { row.forEach((value, index) => { // if values contain commas, users will need to paste the values enclosed in quotes // but we don't want to retain these quotes for purposes of selecting values in the grid - parseCsvString(value, ',', true).forEach(v => { + splitMultiValueForImport(value).forEach(v => { if (v.trim().length > 0) valuesByColumn.get(index).push(v.trim()); }); }); @@ -1640,7 +1676,8 @@ export function validateAndInsertPastedData( forUpdate: boolean, targetContainerPath: string, selectCells: boolean, - selectionToFill?: string[][] + selectionToFill?: string[][], + fromDragFill?: boolean ): Promise> { let selectedColIdx: number; let selectedRowIdx: number; @@ -1678,7 +1715,8 @@ export function validateAndInsertPastedData( lockRowCount, forUpdate, targetContainerPath, - selectCells + selectCells, + fromDragFill ); } else { const fieldKey = editorModel.getFieldKeyByIndex(selectedColIdx); @@ -1715,18 +1753,16 @@ export function pasteEvent( } function getCellCopyValue(valueDescriptors: List): string { - let value = ''; - if (valueDescriptors && valueDescriptors.size > 0) { - let sep = ''; - value = valueDescriptors.reduce((agg, vd) => { - agg += sep + (vd.display !== undefined ? vd.display.toString().trim() : ''); - sep = ', '; - return agg; - }, value); + const values = []; + valueDescriptors.forEach(vd => { + values.push(vd.display !== undefined ? vd.display.toString().trim() : ''); + }); + + if (values.length > 0) return joinMultiValueForExport(values); } - return value; + return ''; } function getCopyValue(model: EditorModel, hideReadOnlyRows: boolean, readonlyRows: string[]): string { diff --git a/packages/components/src/internal/components/forms/input/SelectInput.tsx b/packages/components/src/internal/components/forms/input/SelectInput.tsx index 86648fc65f..5b9d9a0a9a 100644 --- a/packages/components/src/internal/components/forms/input/SelectInput.tsx +++ b/packages/components/src/internal/components/forms/input/SelectInput.tsx @@ -31,7 +31,7 @@ import { MIXED_VALUE_DISPLAY, } from '../constants'; import { QueryColumn } from '../../../../public/QueryColumn'; -import { generateId } from '../../../util/utils'; +import { generateId, joinMultiValueForExport } from '../../../util/utils'; import { naturalSortByProperty } from '../../../../public/sort'; const WARN_COLOR = '#8A6D3B'; @@ -498,7 +498,7 @@ export class SelectInputImpl extends Component { if (!skipJoinValues) { // consider removing altogether? - formValue = formValue.join(delimiter); + formValue = joinMultiValueForExport(formValue, delimiter); } } else { formValue = selectedOptions; diff --git a/packages/components/src/internal/components/forms/model.ts b/packages/components/src/internal/components/forms/model.ts index 01445de078..67234c276f 100644 --- a/packages/components/src/internal/components/forms/model.ts +++ b/packages/components/src/internal/components/forms/model.ts @@ -23,12 +23,11 @@ import { SchemaQuery } from '../../../public/SchemaQuery'; import { getQueryDetails, ISelectRowsResult, - quoteValueColumnWithDelimiters, searchRows, selectRowsDeprecated, } from '../../query/api'; import { similaritySortFactory } from '../../util/similaritySortFactory'; -import { caseInsensitive, parseCsvString } from '../../util/utils'; +import { caseInsensitive, splitMultiValueForImport } from '../../util/utils'; import { naturalSort } from '../../../public/sort'; @@ -129,8 +128,10 @@ export function formatSavedResults( const { key, orderedModels } = result; const models = fromJS(result.models[key]); - const orderedResults = orderedModels[key] - .reduce((ordered, k) => ordered.set(k, models.get(k)), OrderedMap()); + const orderedResults = orderedModels[key].reduce( + (ordered, k) => ordered.set(k, models.get(k)), + OrderedMap() + ); return formatResults(model, orderedResults, token); } @@ -157,7 +158,7 @@ function getSelectedOptions(model: QuerySelectModel, value: any): Map { const resultValue = result.getIn(keyPath); @@ -226,8 +227,7 @@ export function fetchSearchResults(model: QuerySelectModel, input: any): Promise filterVal, model.valueColumn, model.delimiter, - addExactFilter ? displayColumn : undefined, - model.multiple + addExactFilter ? displayColumn : undefined ); } @@ -356,7 +356,7 @@ export function buildValueFilter( filter = Filter.create(valueColumn, value, Filter.Types.IN); expectedValueCount = new Set(value).size; } else if (typeof value === 'string') { - const parsed = parseCsvString(value, delimiter, true); + const parsed = splitMultiValueForImport(value, delimiter); filter = Filter.create(valueColumn, parsed, Filter.Types.IN); expectedValueCount = new Set(parsed).size; } @@ -443,13 +443,7 @@ export async function initSelect(props: QuerySelectOwnProps): Promise(), + selectedItems: selectedItems ? fromJS(selectedItems.models[selectedItems.key]) : Map(), valueColumn, }; } diff --git a/packages/components/src/internal/query/api.test.ts b/packages/components/src/internal/query/api.test.ts index 93b6ea5c51..fa7fb64a60 100644 --- a/packages/components/src/internal/query/api.test.ts +++ b/packages/components/src/internal/query/api.test.ts @@ -15,9 +15,7 @@ import { getContainerFilterForFolder, getContainerFilterForLookups, includesLookupColumns, - ISelectRowsResult, isSelectRowMetadataRequired, - quoteValueColumnWithDelimiters, Renderers, splitRowsByContainer, } from './api'; @@ -137,73 +135,6 @@ describe('api', () => { }); }); - describe('quoteValueColumnWithDelimiters', () => { - function getResults(): ISelectRowsResult { - return { - key: 'test', - models: { - test: { - 1: { Name: { value: 'one', url: 'http://one/test', randomProperty: 123 } }, - 2: { Name: { value: 'with, comma', url: 'http://with, comma/test' } }, - 4: { Name: { value: 'with "quotes", and comma' } }, - 3: { NoName: { value: 'nonesuch', url: 'http://with, comma/test' } }, - 5: { Name: { value: ', comma first', displayValue: ',', url: 'http://with, comma/test' } }, - }, - }, - orderedModels: List([1, 2, 3, 4, 5]), - queries: {}, - rowCount: 5, - }; - } - - test('encode (multiple=true by default)', () => { - expect(quoteValueColumnWithDelimiters(getResults(), 'Name', ',')).toStrictEqual({ - key: 'test', - models: { - test: { - 1: { Name: { value: 'one', url: 'http://one/test', displayValue: 'one', randomProperty: 123 } }, - 2: { - Name: { - value: '"with, comma"', - url: 'http://with, comma/test', - displayValue: 'with, comma', - }, - }, - 4: { - Name: { - value: '"with ""quotes"", and comma"', - displayValue: 'with "quotes", and comma', - }, - }, - 3: { NoName: { value: 'nonesuch', url: 'http://with, comma/test' } }, - 5: { Name: { value: '", comma first"', displayValue: ',', url: 'http://with, comma/test' } }, - }, - }, - orderedModels: List([1, 2, 3, 4, 5]), - queries: {}, - rowCount: 5, - }); - }); - - test('no encode when multiple=false', () => { - expect(quoteValueColumnWithDelimiters(getResults(), 'Name', ',', false)).toStrictEqual({ - key: 'test', - models: { - test: { - 1: { Name: { value: 'one', url: 'http://one/test', displayValue: 'one', randomProperty: 123 } }, - 2: { Name: { value: 'with, comma', url: 'http://with, comma/test', displayValue: 'with, comma' } }, - 4: { Name: { value: 'with "quotes", and comma', displayValue: 'with "quotes", and comma' } }, - 3: { NoName: { value: 'nonesuch', url: 'http://with, comma/test' } }, - 5: { Name: { value: ', comma first', displayValue: ',', url: 'http://with, comma/test' } }, - }, - }, - orderedModels: List([1, 2, 3, 4, 5]), - queries: {}, - rowCount: 5, - }); - }); - }); - test('splitRowsByContainer', () => { const rows = [{ container: 'a' }, { container: 'b' }, { container: 'a' }, { container: 'b' }]; expect(splitRowsByContainer(rows, 'container')).toStrictEqual({ diff --git a/packages/components/src/internal/query/api.ts b/packages/components/src/internal/query/api.ts index 6f136f6f61..bfca8e2f6f 100644 --- a/packages/components/src/internal/query/api.ts +++ b/packages/components/src/internal/query/api.ts @@ -622,33 +622,12 @@ export function handleSelectRowsResponse(response: Query.Response, queryInfo: Qu }; } -// exported for jest testing -export function quoteValueColumnWithDelimiters( - selectRowsResult: ISelectRowsResult, - valueColumn: string, - delimiter: string, - multiple = true -): ISelectRowsResult { - const rowMap = selectRowsResult.models[selectRowsResult.key]; - - Object.values(rowMap).forEach(row => { - const cell = row[valueColumn]; - if (Utils.isString(cell?.value)) { - cell.displayValue = cell.displayValue ?? cell.value; - cell.value = multiple ? quoteValueWithDelimiters(cell.value, delimiter) : cell.value; - } - }); - - return selectRowsResult; -} - export function searchRows( selectRowsConfig, token: any, valueColumn: string, delimiter: string, exactColumn?: string, - multiple?: boolean ): Promise { return new Promise((resolve, reject) => { let exactFilters, qFilters; @@ -715,7 +694,7 @@ export function searchRows( finalResults = queryResults; } - resolve(quoteValueColumnWithDelimiters(finalResults, valueColumn, delimiter, multiple)); + resolve(finalResults); }) .catch(reason => { reject(reason); diff --git a/packages/components/src/internal/util/utils.test.ts b/packages/components/src/internal/util/utils.test.ts index 73b7f1c15e..5e0b98b0ca 100644 --- a/packages/components/src/internal/util/utils.test.ts +++ b/packages/components/src/internal/util/utils.test.ts @@ -47,11 +47,13 @@ import { isQuotedWithDelimiters, isSameWithStringCompare, isSetEqual, + isSimpleQuotedMultiLine, + joinMultiValueForExport, makeCommaSeparatedString, - parseCsvString, parseScientificInt, pronoun, quoteValueWithDelimiters, + splitMultiValueForImport, styleStringToObj, toLowerSafe, uncapitalizeFirstChar, @@ -1465,58 +1467,6 @@ describe('findMissingValues', () => { }); }); -describe('parseCsvString', () => { - test('no value', () => { - expect(parseCsvString(null, ',')).toBeUndefined(); - expect(parseCsvString(undefined, ';')).toBeUndefined(); - expect(parseCsvString('', undefined)).toBeUndefined(); - expect(parseCsvString(null, undefined)).toBeUndefined(); - }); - - test('no quotes', () => { - expect(parseCsvString('', '\t')).toStrictEqual([]); - expect(parseCsvString('abcd', ' ')).toStrictEqual(['abcd']); - expect(parseCsvString('a,b,c', ',')).toStrictEqual(['a', 'b', 'c']); - expect(parseCsvString(',b,c,', ',')).toStrictEqual(['', 'b', 'c']); - expect(parseCsvString('a,,c', ',')).toStrictEqual(['a', '', 'c']); - expect(parseCsvString('a\tb\tc', '\t')).toStrictEqual(['a', 'b', 'c']); - }); - - test('quote as delimiter', () => { - expect(() => parseCsvString('a"b"c"', '"')).toThrow('Unsupported delimiter: "'); - }); - - test('quoted values', () => { - expect(parseCsvString('a,"b","c,d"', ',')).toStrictEqual(['a', '"b"', '"c,d"']); - expect(parseCsvString(',"b","c,d"', ',')).toStrictEqual(['', '"b"', '"c,d"']); - expect(parseCsvString('a,"b","c', ',')).toStrictEqual(['a', '"b"', '"c']); - expect(parseCsvString('a,"b",c"', ',')).toStrictEqual(['a', '"b"', 'c"']); - expect(parseCsvString('"b"', ',')).toStrictEqual(['"b"']); - }); - - test('double quotes', () => { - expect(parseCsvString('a,"b""b2","c,d"', ',')).toStrictEqual(['a', '"b""b2"', '"c,d"']); - expect(parseCsvString('"b""b2""b3"""', ',')).toStrictEqual(['"b""b2""b3"""']); - }); - - test('remove quotes', () => { - expect(parseCsvString('a,"b","c,d"', ',', true)).toStrictEqual(['a', 'b', 'c,d']); - expect(parseCsvString(',"b","c,d"', ',', true)).toStrictEqual(['', 'b', 'c,d']); - expect(parseCsvString('a,"b","c', ',', true)).toStrictEqual(['a', 'b', '"c']); - expect(parseCsvString('a,"b",c"', ',', true)).toStrictEqual(['a', 'b', 'c"']); - expect(parseCsvString('"b"', ',', true)).toStrictEqual(['b']); - expect(parseCsvString('a,"b""b2","c,d"', ',', true)).toStrictEqual(['a', 'b"b2', 'c,d']); - expect(parseCsvString('"b""b2""b3"""', ',', true)).toStrictEqual(['b"b2"b3"']); - expect(parseCsvString('"a,123', ',', true)).toStrictEqual(['"a', '123']); - expect(parseCsvString('"a,"123', ',', true)).toStrictEqual(['"a', '"123']); - expect(parseCsvString('"a,"123', ', ', true)).toStrictEqual(['"a,"123']); - expect(parseCsvString('"a, "123', ',', true)).toStrictEqual(['"a', ' "123']); - expect(parseCsvString('"sam"', ',', true)).toStrictEqual(['sam']); - expect(parseCsvString('"a""b"', ',', true)).toStrictEqual(['a"b']); - expect(parseCsvString('"a"b"', ',', true)).toStrictEqual(['"a"b"']); - }); -}); - describe('quoteValueWithDelimiters', () => { test('no value', () => { expect(quoteValueWithDelimiters(undefined, ',')).toBeUndefined(); @@ -1574,27 +1524,200 @@ describe('quoteValueWithDelimiters', () => { expect(isQuotedWithDelimiters('"a\nb,c""d"', ',')).toBeTruthy(); }); + test('with double quotes', () => { + expect(quoteValueWithDelimiters('"', ',')).toBe('""""'); + expect(isQuotedWithDelimiters('""""', ',')).toBeTruthy(); + expect(isQuotedWithDelimiters('"a"', ',')).toBeTruthy(); + }); + test('round trip', () => { const initialString = 'ab "cd,e"'; - expect(parseCsvString(quoteValueWithDelimiters(initialString, ','), ',', true)).toStrictEqual([initialString]); expect(isQuotedWithDelimiters(quoteValueWithDelimiters(initialString, ','), ',')).toBeTruthy(); const initialStringWithNewLine = 'ab\nc'; - expect(parseCsvString(quoteValueWithDelimiters(initialStringWithNewLine, ','), ',', true)).toStrictEqual([ - initialStringWithNewLine, - ]); expect(isQuotedWithDelimiters(quoteValueWithDelimiters(initialStringWithNewLine, ','), ',')).toBeTruthy(); const initialStringWithNewLineAndComma = 'acb\nc'; - expect( - parseCsvString(quoteValueWithDelimiters(initialStringWithNewLineAndComma, ','), ',', true) - ).toStrictEqual([initialStringWithNewLineAndComma]); expect( isQuotedWithDelimiters(quoteValueWithDelimiters(initialStringWithNewLineAndComma, ','), ',') ).toBeTruthy(); }); }); +describe('isSimpleQuotedMultiLine', () => { + test('returns false for non-string and falsy values', () => { + expect(isSimpleQuotedMultiLine(null)).toBe(false); + expect(isSimpleQuotedMultiLine(undefined)).toBe(false); + expect(isSimpleQuotedMultiLine('')).toBe(false); + expect(isSimpleQuotedMultiLine(0)).toBe(false); + expect(isSimpleQuotedMultiLine(123)).toBe(false); + }); + + test('returns false for strings without newlines', () => { + expect(isSimpleQuotedMultiLine('"abc"')).toBe(false); + expect(isSimpleQuotedMultiLine('abc')).toBe(false); + expect(isSimpleQuotedMultiLine('"a,b"')).toBe(false); + }); + + test('returns false for unquoted strings with newlines', () => { + expect(isSimpleQuotedMultiLine('a\nb')).toBe(false); + expect(isSimpleQuotedMultiLine('a\rb')).toBe(false); + }); + + test('returns false for strings too short to be quoted', () => { + expect(isSimpleQuotedMultiLine('"\n')).toBe(false); + expect(isSimpleQuotedMultiLine('"\r')).toBe(false); + }); + + test('returns false for quoted strings with internal quotes', () => { + expect(isSimpleQuotedMultiLine('"a""b\nc"')).toBe(false); + expect(isSimpleQuotedMultiLine('"a\n""b"')).toBe(false); + }); + + test('returns true for simple quoted strings with newlines', () => { + expect(isSimpleQuotedMultiLine('"a\nb"')).toBe(true); + expect(isSimpleQuotedMultiLine('"a\rb"')).toBe(true); + expect(isSimpleQuotedMultiLine('"a\r\nb"')).toBe(true); + expect(isSimpleQuotedMultiLine('"a\nb\nc"')).toBe(true); + expect(isSimpleQuotedMultiLine('"\n"')).toBe(true); + }); +}); + +describe('splitMultiValueForImport', () => { + test('null and undefined', () => { + expect(splitMultiValueForImport(null)).toBeNull(); + expect(splitMultiValueForImport(undefined)).toBeUndefined(); + }); + + test('empty string', () => { + expect(splitMultiValueForImport('')).toStrictEqual([]); + }); + + test('simple values', () => { + expect(splitMultiValueForImport('A')).toStrictEqual(['A']); + expect(splitMultiValueForImport('A, B, C')).toStrictEqual(['A', ' B', ' C']); + expect(splitMultiValueForImport('A, B, C', ',', false, true)).toStrictEqual(['A', 'B', 'C']); + expect(splitMultiValueForImport('A,B,C')).toStrictEqual(['A', 'B', 'C']); + }); + + test('whitespace handling', () => { + expect(splitMultiValueForImport(' A , B ')).toStrictEqual([' A ', ' B ']); + expect(splitMultiValueForImport(' A , B , C ')).toStrictEqual([' A ', ' B ', ' C ']); + expect(splitMultiValueForImport(' A , B ', ',', false, true)).toStrictEqual(['A', 'B']); + }); + + test('empty value preservation', () => { + expect(splitMultiValueForImport(',', null, false)).toStrictEqual(['', '']); + expect(splitMultiValueForImport('A,', null, false)).toStrictEqual(['A', '']); + expect(splitMultiValueForImport(',B', null, false)).toStrictEqual(['', 'B']); + expect(splitMultiValueForImport('A,,C', null, false)).toStrictEqual(['A', '', 'C']); + expect(splitMultiValueForImport(' A , B ,', null, false)).toStrictEqual([' A ', ' B ', '']); + expect(splitMultiValueForImport(' A , B , ', null, false)).toStrictEqual([' A ', ' B ', ' ']); + + expect(splitMultiValueForImport(',')).toStrictEqual([]); + expect(splitMultiValueForImport('A,')).toStrictEqual(['A']); + expect(splitMultiValueForImport(',B')).toStrictEqual(['B']); + expect(splitMultiValueForImport('A,,C')).toStrictEqual(['A', 'C']); + expect(splitMultiValueForImport(' A , B ,')).toStrictEqual([' A ', ' B ']); + expect(splitMultiValueForImport(' A , B ,', null, true, true)).toStrictEqual(['A', 'B']); + expect(splitMultiValueForImport(' A , B , ', null, true, true)).toStrictEqual(['A', 'B']); + }); + + test('quoted values with commas', () => { + expect(splitMultiValueForImport('"A,B"')).toStrictEqual(['A,B']); + expect(splitMultiValueForImport('"A,B", C')).toStrictEqual(['A,B', ' C']); + expect(splitMultiValueForImport('"A,B", C', null, false, true)).toStrictEqual(['A,B', 'C']); + expect(splitMultiValueForImport('A,"B,C",D')).toStrictEqual(['A', 'B,C', 'D']); + }); + + test('escaped quotes', () => { + expect(splitMultiValueForImport('"""A"""')).toStrictEqual(['"A"']); + expect(splitMultiValueForImport('"""A"",B"')).toStrictEqual(['"A",B']); + expect(splitMultiValueForImport('"""A,B,C"""')).toStrictEqual(['"A,B,C"']); + }); + + test('quoted empty string', () => { + expect(splitMultiValueForImport('""')).toStrictEqual([]); + expect(splitMultiValueForImport('""', null, false)).toStrictEqual(['']); + expect(splitMultiValueForImport('"", A')).toStrictEqual([' A']); + expect(splitMultiValueForImport('"", A', null, false)).toStrictEqual(['', ' A']); + expect(splitMultiValueForImport('"", A', null, false, true)).toStrictEqual(['', 'A']); + }); + + test('quoted value with leading/trailing whitespace', () => { + expect(splitMultiValueForImport('" A "')).toStrictEqual([' A ']); + }); + + test('malformed input handled gracefully', () => { + expect(splitMultiValueForImport('"abc')).toStrictEqual(['abc']); + }); +}); + +describe('joinMultiValueForExport', () => { + test('simple values', () => { + expect(joinMultiValueForExport(['A', 'B', 'C'])).toBe('A,B,C'); + }); + + test('null and undefined become empty quoted string', () => { + expect(joinMultiValueForExport([null])).toBe(''); + expect(joinMultiValueForExport([undefined])).toBe(''); + expect(joinMultiValueForExport(['A', null, 'B'])).toBe('A,,B'); + }); + + test('empty string is quoted', () => { + expect(joinMultiValueForExport([''])).toBe(''); + expect(joinMultiValueForExport(['A', '', 'B'])).toBe('A,,B'); + }); + + test('values with commas are quoted', () => { + expect(joinMultiValueForExport(['A,B'])).toBe('"A,B"'); + expect(joinMultiValueForExport(['A,B', 'C'])).toBe('"A,B",C'); + }); + + test('values with quotes are escaped and quoted', () => { + expect(joinMultiValueForExport(['"A"'])).toBe('"""A"""'); + expect(joinMultiValueForExport(['A"B'])).toBe('"A""B"'); + }); + + test('values with leading/trailing whitespace are quoted', () => { + expect(joinMultiValueForExport([' A'])).toBe('" A"'); + expect(joinMultiValueForExport(['A '])).toBe('"A "'); + expect(joinMultiValueForExport([' A '])).toBe('" A "'); + }); +}); + +describe('splitMultiValueForImport / joinMultiValueForExport round-trip', () => { + test.each([ + [['A', 'B', 'C']], + [['A,B,C']], + [['"A",B']], + [['"A,B,C"']], + [['"A",B', 'B']], + [['A', '"A"', 'B']], + [['A', 'A,B,C', '"A,B,C"']], + [['"A"', '"A",B', '"A,B,C"']], + ])('split(join(%j)) === original', values => { + const exported = joinMultiValueForExport(values); + const imported = splitMultiValueForImport(exported); + expect(imported).toStrictEqual(values); + }); + + test.each([ + ['A,B,C'], + ['"A,B,C"'], + ['"""A"",B"'], + ['"""A,B,C"""'], + ['"""A"",B",B'], + ['A,"""A""",B'], + ['A,"A,B,C","""A,B,C"""'], + ['"""A""","""A"",B","""A,B,C"""'], + ])('join(split(%j)) === original', str => { + const imported = splitMultiValueForImport(str); + const exported = joinMultiValueForExport(imported); + expect(exported).toBe(str); + }); +}); + describe('arrayEquals', () => { test('ignore order, case sensitive', () => { expect(arrayEquals(undefined, undefined)).toBeTruthy(); diff --git a/packages/components/src/internal/util/utils.ts b/packages/components/src/internal/util/utils.ts index 7473ba59cb..afcd029d85 100644 --- a/packages/components/src/internal/util/utils.ts +++ b/packages/components/src/internal/util/utils.ts @@ -16,6 +16,7 @@ import { Set as ImmutableSet, Iterable, List, Map } from 'immutable'; import { getServerContext, Utils } from '@labkey/api'; import { ChangeEvent, CSSProperties } from 'react'; +import Papa from 'papaparse'; import { hasParameter, toggleParameter } from '../url/ActionURL'; import { QueryInfo } from '../../public/QueryInfo'; @@ -653,71 +654,13 @@ export const handleFileInputChange = ( }; }; -export function parseCsvString(value: string, delimiter: string, removeQuotes?: boolean): string[] { - if (delimiter === '"') throw 'Unsupported delimiter: ' + delimiter; - - if (!delimiter) return undefined; - - if (value == null) return undefined; - - let start = 0; - const parsedValues = []; - while (start < value.length) { - let end; - const ch = value[start]; - if (ch === delimiter) { - // empty string case - end = start; - parsedValues.push(''); - } else if (ch === '"') { - // starting a quoted value - end = start; - while (true) { - // find the end of the quoted value - end = value.indexOf('"', end + 1); - if (end === -1) break; - if (end === value.length - 1 || value[end + 1] !== '"') { - // end quote at end of string or without double quote - break; - } - end++; // skip double "" - } - // if no ending quote, don't remove quotes; - if (end === -1 || end !== value.length - 1) { - let isCurrentDelimiterOrQuote = true; - if (end > -1) { - const nextChar = value[end + 1]; - // Issue 51056: "a, "b should be parsed to ["a, "b], not [a, ] - isCurrentDelimiterOrQuote = nextChar === '"' || nextChar === delimiter; - } - - if (end === -1 || !isCurrentDelimiterOrQuote) { - end = value.indexOf(delimiter, start); - if (end === -1) end = value.length; - parsedValues.push(value.substring(start, end)); - start = end + delimiter.length; - continue; - } - } - let parsedValue = removeQuotes ? value.substring(start + 1, end) : value.substring(start, end + 1); // start is at the quote - if (removeQuotes && parsedValue.indexOf('""') !== -1) { - parsedValue = parsedValue.replace(/""/g, '"'); - } - parsedValues.push(parsedValue); - end++; // get past the last " - } else { - end = value.indexOf(delimiter, start); - if (end === -1) end = value.length; - parsedValues.push(value.substring(start, end)); - } - start = end + delimiter.length; - } - return parsedValues; -} - const TSV_ESCAPE_CHARS = ['\r', '\n', '\\', '"']; function hasTsvEscapeChar(value: any, delimiter: string): boolean { + if (value.length === 0) return false; + const allEscapedChars = [...TSV_ESCAPE_CHARS, delimiter]; + // if start or end with whitespace, we need to quote to preserve that whitespace when importing back from CSV + if (value[0].trim() === '' || value[value.length - 1].trim() === '') return true; return !!allEscapedChars.find(char => value.indexOf(char) > -1); } @@ -752,6 +695,53 @@ export function isQuotedWithDelimiters(value: any, delimiter: string): boolean { return strVal.startsWith('"') && strVal.endsWith('"'); } +/** + * Returns true if the value is a string that contains a newline character and is quoted with double quotes, + * and does not contain any other double quotes. + * This is used to determine whether we can safely parse a multi-line string as TSV (for paste) without losing its escaped characters. + * @param value + */ +export const NEWLINE_CHARS = ['\r', '\n']; +export function isSimpleQuotedMultiLine(value: any): boolean { + if (!value || !Utils.isString(value)) { + return false; + } + + if (!NEWLINE_CHARS.find(char => value.indexOf(char) > -1)) return false; + + const strVal = value + ''; + if (strVal.length <= 2) return false; // need at least 2 characters to be quoted with something in between + if (!strVal.startsWith('"') || !strVal.endsWith('"')) return false; + + const innerValue = strVal.substring(1, strVal.length - 1); + return innerValue.indexOf('"') === -1; +} + +export function joinMultiValueForExport(values: string[], delimiter = ','): string { + return Papa.unparse([values], { delimiter }); +} + +const processParsedResults = (results, removeEmpty = true, trimSpace?: boolean): string[] => { + return results.data[0] + ?.map(value => (trimSpace && Utils.isString(value) ? value.trim() : value)) + .filter(value_ => (removeEmpty ? value_ !== '' : true)); +}; +// Port of Java PageFlowUtil.splitStringToValuesForImport — Google Sheets-compatible CSV parsing +// for multi-value (multi-select) column values. Fixed comma delimiter, double-quote quoting. +export function splitMultiValueForImport( + str: string, + delimiter = ',', + removeEmpty = true, + trimSpace?: boolean +): string[] | null | undefined { + if (str === null) return null; + if (str === undefined) return undefined; + if (!str) { + return []; + } + return processParsedResults(Papa.parse(str, { delimiter }), removeEmpty, trimSpace); +} + export function arrayEquals( a: null | string[] | undefined, b: null | string[] | undefined,