From d3579fcdea5ad40f14743d7350a8fa01499c2e8a Mon Sep 17 00:00:00 2001 From: Jack Ord <36296721+j-or@users.noreply.github.com> Date: Thu, 25 Sep 2025 12:56:35 +0000 Subject: [PATCH 01/60] Add initial settings sections (wip) --- package-lock.json | 168 +++++++++++++++--- packages/ui/package.json | 1 + .../features/data-points/data-points.style.ts | 5 +- .../src/features/data-points/data-points.tsx | 76 +++++--- .../settings/data-points-settings.style.ts | 42 +++++ .../settings/data-points-settings.tsx | 132 ++++++++++++++ 6 files changed, 370 insertions(+), 54 deletions(-) create mode 100644 packages/ui/src/features/data-points/settings/data-points-settings.style.ts create mode 100644 packages/ui/src/features/data-points/settings/data-points-settings.tsx diff --git a/package-lock.json b/package-lock.json index 1f2cfe46..5519b8d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -335,13 +335,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", - "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } @@ -6058,6 +6055,19 @@ "dev": true, "license": "MIT" }, + "node_modules/complex.js": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.2.tgz", + "integrity": "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/concat-map": { "version": "0.0.1", "dev": true, @@ -6559,7 +6569,6 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, "license": "MIT" }, "node_modules/decimal.js-light": { @@ -7283,6 +7292,12 @@ "node": ">=6" } }, + "node_modules/escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "license": "MIT", @@ -8231,6 +8246,19 @@ "integrity": "sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg==", "dev": true }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fresh": { "version": "0.5.2", "dev": true, @@ -9676,6 +9704,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", + "license": "MIT" + }, "node_modules/jju": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", @@ -10611,6 +10645,29 @@ "speech-rule-engine": "^4.0.6" } }, + "node_modules/mathjs": { + "version": "14.8.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-14.8.0.tgz", + "integrity": "sha512-DN4wmAjNzFVJ9vHqpAJ3vX0UF306u/1DgGKh7iVPuAFH19JDRd9NAaQS764MsKbSwDB6uBSkQEmgVmKdgYaCoQ==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.26.10", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^5.2.1", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/md5": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", @@ -12371,12 +12428,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, "node_modules/regexp.prototype.flags": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", @@ -12825,6 +12876,12 @@ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "license": "MIT" + }, "node_modules/semver": { "version": "5.7.1", "dev": true, @@ -13559,6 +13616,12 @@ "version": "1.0.23", "license": "MIT" }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -13915,6 +13978,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-function": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz", + "integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -15065,6 +15137,7 @@ "@boostv/process-optimizer-frontend-core": "2.11.0", "@boostv/process-optimizer-frontend-plots": "1.0.7", "lodash.debounce": "^4.0.8", + "mathjs": "^14.8.0", "react-hook-form": "^7.54.2", "remeda": "^2.20.1", "tss-react": "^4.9.15" @@ -15384,12 +15457,9 @@ } }, "@babel/runtime": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", - "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", - "requires": { - "regenerator-runtime": "^0.14.0" - } + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==" }, "@babel/template": { "version": "7.27.2", @@ -15639,6 +15709,7 @@ "@vitejs/plugin-react": "5.0.2", "jsdom": "26.1.0", "lodash.debounce": "^4.0.8", + "mathjs": "^14.8.0", "node-mocks-http": "^1.16.2", "react-devtools": "^6.1.0", "react-hook-form": "^7.54.2", @@ -19134,6 +19205,11 @@ "version": "4.1.4", "dev": true }, + "complex.js": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.2.tgz", + "integrity": "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g==" + }, "concat-map": { "version": "0.0.1", "dev": true @@ -19462,8 +19538,7 @@ "decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==" }, "decimal.js-light": { "version": "2.5.1", @@ -19971,6 +20046,11 @@ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true }, + "escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==" + }, "escape-string-regexp": { "version": "4.0.0" }, @@ -20562,6 +20642,11 @@ "integrity": "sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg==", "dev": true }, + "fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==" + }, "fresh": { "version": "0.5.2", "dev": true @@ -21460,6 +21545,11 @@ "@isaacs/cliui": "^8.0.2" } }, + "javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==" + }, "jju": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", @@ -22075,6 +22165,22 @@ "speech-rule-engine": "^4.0.6" } }, + "mathjs": { + "version": "14.8.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-14.8.0.tgz", + "integrity": "sha512-DN4wmAjNzFVJ9vHqpAJ3vX0UF306u/1DgGKh7iVPuAFH19JDRd9NAaQS764MsKbSwDB6uBSkQEmgVmKdgYaCoQ==", + "requires": { + "@babel/runtime": "^7.26.10", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^5.2.1", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + } + }, "md5": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", @@ -23200,11 +23306,6 @@ "which-builtin-type": "^1.2.1" } }, - "regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, "regexp.prototype.flags": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", @@ -23504,6 +23605,11 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==" }, + "seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" + }, "semver": { "version": "5.7.1", "dev": true @@ -23990,6 +24096,11 @@ "timezone": { "version": "1.0.23" }, + "tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" + }, "tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -24218,6 +24329,11 @@ "reflect.getprototypeof": "^1.0.6" } }, + "typed-function": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz", + "integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==" + }, "typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", diff --git a/packages/ui/package.json b/packages/ui/package.json index 72aea0cb..b90fdd0d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -8,6 +8,7 @@ "@boostv/process-optimizer-frontend-core": "2.11.0", "@boostv/process-optimizer-frontend-plots": "1.0.7", "lodash.debounce": "^4.0.8", + "mathjs": "^14.8.0", "react-hook-form": "^7.54.2", "remeda": "^2.20.1", "tss-react": "^4.9.15" diff --git a/packages/ui/src/features/data-points/data-points.style.ts b/packages/ui/src/features/data-points/data-points.style.ts index ef35b190..2d1e41b4 100644 --- a/packages/ui/src/features/data-points/data-points.style.ts +++ b/packages/ui/src/features/data-points/data-points.style.ts @@ -4,10 +4,7 @@ export const useStyles = makeStyles()(() => ({ tableContainer: { overflowX: 'auto', }, - titleButton: { - float: 'right', - }, - titleIcon: { + iconLight: { color: 'white', }, })) diff --git a/packages/ui/src/features/data-points/data-points.tsx b/packages/ui/src/features/data-points/data-points.tsx index 4fc02520..3dc2145f 100644 --- a/packages/ui/src/features/data-points/data-points.tsx +++ b/packages/ui/src/features/data-points/data-points.tsx @@ -1,6 +1,9 @@ import { CircularProgress, IconButton, Box, Tooltip } from '@mui/material' + import { EditableTable } from '../core' -import { SwapVert } from '@mui/icons-material' +import SwapVertIcon from '@mui/icons-material/SwapVert' +import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined' +import SettingsIcon from '@mui/icons-material/Settings' import { TitleCard } from '../core/title-card/title-card' import DownloadCSVButton from './download-csv-button' import useStyles from './data-points.style' @@ -16,6 +19,8 @@ import { EditableTableViolation, } from '@boostv/process-optimizer-frontend-core' import { useDataPoints } from './useDataPoints' +import { DataPointsSettings } from './settings/data-points-settings' +import { useState } from 'react' type DataPointProps = { id?: string @@ -48,6 +53,8 @@ export function DataPoints(props: DataPointProps) { onUpdateDataPoints, } = props const { classes } = useStyles() + const [isSettingsOpen, setSettingsOpen] = useState(false) + const enabledValueVariables = valueVariables.filter(v => v.enabled) const enabledCategoricalVariables = categoricalVariables.filter( v => v.enabled @@ -108,10 +115,23 @@ export function DataPoints(props: DataPointProps) { - + + + + + setSettingsOpen(!isSettingsOpen)} + > + {isSettingsOpen ? ( + + ) : ( + + )} @@ -125,27 +145,35 @@ export function DataPoints(props: DataPointProps) { isLoadingState && } {enabledValueVariables.length + enabledCategoricalVariables.length > 0 && !isLoadingState && ( - - rowAdded(row)} - onRowDeleted={(rowIndex: number) => rowDeleted(rowIndex)} - onRowEdited={(rowIndex: number, row: TableDataRow) => - rowEdited(rowIndex, row) - } - violations={violationsInTable} - order={newestFirst ? 'ascending' : 'descending'} - isEditingDisabled={isEditingDisabled} - onRowEnabledToggled={(index, enabled) => - rowEnabledToggled(index, enabled) - } - /> - + <> + {isSettingsOpen && ( + setSettingsOpen(false)} + /> + )} + + rowAdded(row)} + onRowDeleted={(rowIndex: number) => rowDeleted(rowIndex)} + onRowEdited={(rowIndex: number, row: TableDataRow) => + rowEdited(rowIndex, row) + } + violations={violationsInTable} + order={newestFirst ? 'ascending' : 'descending'} + isEditingDisabled={isEditingDisabled} + onRowEnabledToggled={(index, enabled) => + rowEnabledToggled(index, enabled) + } + /> + + )} ) diff --git a/packages/ui/src/features/data-points/settings/data-points-settings.style.ts b/packages/ui/src/features/data-points/settings/data-points-settings.style.ts new file mode 100644 index 00000000..30ab1c1f --- /dev/null +++ b/packages/ui/src/features/data-points/settings/data-points-settings.style.ts @@ -0,0 +1,42 @@ +import { makeStyles } from 'tss-react/mui' +import { colors } from '@mui/material' + +export const useStyles = makeStyles()(theme => ({ + main: { + backgroundColor: colors.grey[200], + }, + header: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + padding: theme.spacing(1), + }, + tabContainer: { + padding: theme.spacing(1), + }, + tabContainers: { + display: 'flex', + gap: theme.spacing(1), + flexDirection: 'row', + }, + functionContainer: { + background: 'coral', + }, + functionVariablesContainer: { + background: 'dodgerblue', + }, + playgroundContainer: { + background: 'yellowgreen', + }, + function: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }, + settingsControls: { + display: 'flex', + gap: theme.spacing(1), + }, +})) + +export default useStyles diff --git a/packages/ui/src/features/data-points/settings/data-points-settings.tsx b/packages/ui/src/features/data-points/settings/data-points-settings.tsx new file mode 100644 index 00000000..fa980797 --- /dev/null +++ b/packages/ui/src/features/data-points/settings/data-points-settings.tsx @@ -0,0 +1,132 @@ +import { Box, Tooltip, Button } from '@mui/material' +import Tabs from '@mui/material/Tabs' +import Tab from '@mui/material/Tab' +import SettingsIcon from '@mui/icons-material/Settings' +import IconButton from '@mui/material/IconButton' +import { useState } from 'react' +import useStyles from './data-points-settings.style' +import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined' +import FormInputText from '@ui/common/forms/form-input' +import { useForm } from 'react-hook-form' +import { InfoBox } from '../../core/info-box/info-box' +import { parse } from 'mathjs' + +type DataPointsSettingsProps = { + tabs: string[] + onCancel: () => void +} + +export function DataPointsSettings(props: DataPointsSettingsProps) { + const { classes } = useStyles() + const { tabs, onCancel } = props + + const { handleSubmit, control } = useForm<{ qualityFunction: string }>({ + defaultValues: { qualityFunction: '' }, + mode: 'onSubmit', + reValidateMode: 'onSubmit', + }) + + const [tabIndex, setTabIndex] = useState(0) + + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => + setTabIndex(newValue) + + const onSubmit = (data: { qualityFunction: string }) => { + console.log(data) + } + + return ( + + + + Settings + + + {tabs.map((tab, index) => ( + + ))} + + + {tabs.map((_, index) => ( + + <> + {tabIndex === index && ( +
+ + + + + { + try { + parse(expression) + return true + } catch (e) { + return `${e}` + } + }, + }} + /> + + + + + + + + + + Function variables + + + + Playground + + + + + + + + +
+ )} + +
+ ))} +
+
+ ) +} From 63b361ed3a962277c1d03d15d9f5bc04c1214790 Mon Sep 17 00:00:00 2001 From: Jack Ord <36296721+j-or@users.noreply.github.com> Date: Tue, 30 Sep 2025 11:34:38 +0000 Subject: [PATCH 02/60] Add function variables (very wip) --- .../settings/data-points-settings.style.ts | 1 + .../settings/data-points-settings.tsx | 139 +++++++++--------- .../settings/function-variables.tsx | 61 ++++++++ .../data-points/settings/playground.tsx | 15 ++ 4 files changed, 150 insertions(+), 66 deletions(-) create mode 100644 packages/ui/src/features/data-points/settings/function-variables.tsx create mode 100644 packages/ui/src/features/data-points/settings/playground.tsx diff --git a/packages/ui/src/features/data-points/settings/data-points-settings.style.ts b/packages/ui/src/features/data-points/settings/data-points-settings.style.ts index 30ab1c1f..1aca7465 100644 --- a/packages/ui/src/features/data-points/settings/data-points-settings.style.ts +++ b/packages/ui/src/features/data-points/settings/data-points-settings.style.ts @@ -4,6 +4,7 @@ import { colors } from '@mui/material' export const useStyles = makeStyles()(theme => ({ main: { backgroundColor: colors.grey[200], + borderRadius: '4px', }, header: { display: 'flex', diff --git a/packages/ui/src/features/data-points/settings/data-points-settings.tsx b/packages/ui/src/features/data-points/settings/data-points-settings.tsx index fa980797..71c536cc 100644 --- a/packages/ui/src/features/data-points/settings/data-points-settings.tsx +++ b/packages/ui/src/features/data-points/settings/data-points-settings.tsx @@ -7,21 +7,26 @@ import { useState } from 'react' import useStyles from './data-points-settings.style' import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined' import FormInputText from '@ui/common/forms/form-input' -import { useForm } from 'react-hook-form' -import { InfoBox } from '../../core/info-box/info-box' +import { FormProvider, useForm } from 'react-hook-form' +import { InfoBox } from '@ui/features/core' import { parse } from 'mathjs' +import { FunctionVariables } from './function-variables' type DataPointsSettingsProps = { tabs: string[] onCancel: () => void + onSave: () => void } export function DataPointsSettings(props: DataPointsSettingsProps) { const { classes } = useStyles() - const { tabs, onCancel } = props + const { tabs, onCancel, onSave } = props - const { handleSubmit, control } = useForm<{ qualityFunction: string }>({ - defaultValues: { qualityFunction: '' }, + const methods = useForm<{ + qualityFunction: string + functionVariables: string[] + }>({ + defaultValues: { qualityFunction: '', functionVariables: [] }, mode: 'onSubmit', reValidateMode: 'onSubmit', }) @@ -33,6 +38,7 @@ export function DataPointsSettings(props: DataPointsSettingsProps) { const onSubmit = (data: { qualityFunction: string }) => { console.log(data) + onSave() } return ( @@ -55,73 +61,74 @@ export function DataPointsSettings(props: DataPointsSettingsProps) { <> {tabIndex === index && ( -
- - - - - { - try { - parse(expression) - return true - } catch (e) { - return `${e}` - } - }, - }} + + + + + + + { + try { + parse(expression) + return true + } catch (e) { + return `${e}` + } + }, + }} + /> + + + + + + + + + + + + + Playground + - - - - - - - - Function variables - + - - Playground - - - - - - - - + + )} diff --git a/packages/ui/src/features/data-points/settings/function-variables.tsx b/packages/ui/src/features/data-points/settings/function-variables.tsx new file mode 100644 index 00000000..f5603555 --- /dev/null +++ b/packages/ui/src/features/data-points/settings/function-variables.tsx @@ -0,0 +1,61 @@ +import { Box, Button } from '@mui/material' +import FormInputText from '@ui/common/forms/form-input' +import { useState } from 'react' +import { useFormContext } from 'react-hook-form' + +type FunctionVariableProps = { + variables: string[] +} + +export function FunctionVariables(props: FunctionVariableProps) { + const { control, formState } = useFormContext() + + const { variables } = props + console.log('variables', variables) + + const [isAddingNew, setAddingNew] = useState(false) + + // const { handleSubmit, control } = useForm<{ name: string }>({ + // defaultValues: { name: '' }, + // mode: 'onSubmit', + // reValidateMode: 'onSubmit', + // }) + + return ( + + Function variables + + {isAddingNew && ( + + + + + + )} + + ) +} diff --git a/packages/ui/src/features/data-points/settings/playground.tsx b/packages/ui/src/features/data-points/settings/playground.tsx new file mode 100644 index 00000000..4f16ac00 --- /dev/null +++ b/packages/ui/src/features/data-points/settings/playground.tsx @@ -0,0 +1,15 @@ +import { Box } from '@mui/material' +import { InfoBox } from '@ui/features/core' + +export function Playground() { + return ( + <> + Playground + + + ) +} From 6e1fd7cc37291c4b2b86fa89ace766d3ff7884fc Mon Sep 17 00:00:00 2001 From: Jack Ord <36296721+j-or@users.noreply.github.com> Date: Fri, 17 Oct 2025 12:22:24 +0000 Subject: [PATCH 03/60] Add function variables table and inputs --- .../src/features/data-points/data-points.tsx | 3 +- .../settings/data-points-settings.style.ts | 4 +- .../settings/data-points-settings.tsx | 13 ++- .../settings/function-variables-table.tsx | 40 ++++++++++ .../settings/function-variables.style.ts | 21 +++++ .../settings/function-variables.tsx | 80 ++++++++++++------- 6 files changed, 124 insertions(+), 37 deletions(-) create mode 100644 packages/ui/src/features/data-points/settings/function-variables-table.tsx create mode 100644 packages/ui/src/features/data-points/settings/function-variables.style.ts diff --git a/packages/ui/src/features/data-points/data-points.tsx b/packages/ui/src/features/data-points/data-points.tsx index e69698ad..8a6c92a4 100644 --- a/packages/ui/src/features/data-points/data-points.tsx +++ b/packages/ui/src/features/data-points/data-points.tsx @@ -19,7 +19,7 @@ import { EditableTableViolation, } from '@boostv/process-optimizer-frontend-core' import { useDataPoints } from './useDataPoints' -import { DataPointsSettings } from './settings/data-points-settings' +import { DataPointsSettings } from '@ui/features/data-points/settings/data-points-settings' import { useState } from 'react' type DataPointProps = { @@ -150,6 +150,7 @@ export function DataPoints(props: DataPointProps) { setSettingsOpen(false)} + onSave={() => {}} /> )} diff --git a/packages/ui/src/features/data-points/settings/data-points-settings.style.ts b/packages/ui/src/features/data-points/settings/data-points-settings.style.ts index 1aca7465..9874c383 100644 --- a/packages/ui/src/features/data-points/settings/data-points-settings.style.ts +++ b/packages/ui/src/features/data-points/settings/data-points-settings.style.ts @@ -23,9 +23,7 @@ export const useStyles = makeStyles()(theme => ({ functionContainer: { background: 'coral', }, - functionVariablesContainer: { - background: 'dodgerblue', - }, + functionVariablesContainer: {}, playgroundContainer: { background: 'yellowgreen', }, diff --git a/packages/ui/src/features/data-points/settings/data-points-settings.tsx b/packages/ui/src/features/data-points/settings/data-points-settings.tsx index 71c536cc..42d8e6ec 100644 --- a/packages/ui/src/features/data-points/settings/data-points-settings.tsx +++ b/packages/ui/src/features/data-points/settings/data-points-settings.tsx @@ -10,7 +10,7 @@ import FormInputText from '@ui/common/forms/form-input' import { FormProvider, useForm } from 'react-hook-form' import { InfoBox } from '@ui/features/core' import { parse } from 'mathjs' -import { FunctionVariables } from './function-variables' +import { FunctionVariables } from '@ui/features/data-points/settings/function-variables' type DataPointsSettingsProps = { tabs: string[] @@ -25,8 +25,15 @@ export function DataPointsSettings(props: DataPointsSettingsProps) { const methods = useForm<{ qualityFunction: string functionVariables: string[] + newVariableName: string + newVariable: string }>({ - defaultValues: { qualityFunction: '', functionVariables: [] }, + defaultValues: { + qualityFunction: '', + functionVariables: [], + newVariableName: '', + newVariable: '', + }, mode: 'onSubmit', reValidateMode: 'onSubmit', }) @@ -37,7 +44,7 @@ export function DataPointsSettings(props: DataPointsSettingsProps) { setTabIndex(newValue) const onSubmit = (data: { qualityFunction: string }) => { - console.log(data) + console.log('submit', data) onSave() } diff --git a/packages/ui/src/features/data-points/settings/function-variables-table.tsx b/packages/ui/src/features/data-points/settings/function-variables-table.tsx new file mode 100644 index 00000000..1ed28397 --- /dev/null +++ b/packages/ui/src/features/data-points/settings/function-variables-table.tsx @@ -0,0 +1,40 @@ +import { + IconButton, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from '@mui/material' +import { Delete } from '@mui/icons-material' + +export function FunctionVariablesTable() { + return ( + + + + + Weight + Viscosity + + + + + Variable + + x1 + {}}> + + + + + x2 + {}}> + + + + + +
+ ) +} diff --git a/packages/ui/src/features/data-points/settings/function-variables.style.ts b/packages/ui/src/features/data-points/settings/function-variables.style.ts new file mode 100644 index 00000000..ac6dad78 --- /dev/null +++ b/packages/ui/src/features/data-points/settings/function-variables.style.ts @@ -0,0 +1,21 @@ +import { colors } from '@mui/material' +import { makeStyles } from 'tss-react/mui' + +export const useStyles = makeStyles()(theme => ({ + contentContainer: { + display: 'flex', + }, + newVariableContainer: { + backgroundColor: colors.grey[300], + padding: theme.spacing(1), + borderRadius: '4px', + width: 180, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + }, +})) + +export default useStyles diff --git a/packages/ui/src/features/data-points/settings/function-variables.tsx b/packages/ui/src/features/data-points/settings/function-variables.tsx index f5603555..5c9abe74 100644 --- a/packages/ui/src/features/data-points/settings/function-variables.tsx +++ b/packages/ui/src/features/data-points/settings/function-variables.tsx @@ -2,12 +2,15 @@ import { Box, Button } from '@mui/material' import FormInputText from '@ui/common/forms/form-input' import { useState } from 'react' import { useFormContext } from 'react-hook-form' +import useStyles from '@ui/features/data-points/settings/function-variables.style' +import { FunctionVariablesTable } from '@ui/features/data-points/settings/function-variables-table' type FunctionVariableProps = { variables: string[] } export function FunctionVariables(props: FunctionVariableProps) { + const { classes } = useStyles() const { control, formState } = useFormContext() const { variables } = props @@ -24,38 +27,55 @@ export function FunctionVariables(props: FunctionVariableProps) { return ( Function variables - - {isAddingNew && ( + + + + - - - + {!isAddingNew && ( + + )} + {isAddingNew && ( + + + + + + + )} - )} + ) } From 7d9e4594a578031d4539887e835b847f080a0b4d Mon Sep 17 00:00:00 2001 From: Jack Ord Date: Wed, 22 Jul 2026 11:37:05 +0000 Subject: [PATCH 04/60] feat(core): add score-function schema + v21 migration --- packages/core/src/common/types/common.test.ts | 134 ++++- packages/core/src/common/types/common.ts | 39 +- .../util/migration/data-formats/21.json | 105 ++++ .../common/util/migration/migration.test.ts | 27 + .../src/common/util/migration/migration.ts | 2 + .../common/util/migration/migrations/index.ts | 1 + .../util/migration/migrations/migrateToV21.ts | 11 + .../src/common/util/migration/schemas/21.json | 528 ++++++++++++++++++ 8 files changed, 845 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/common/util/migration/data-formats/21.json create mode 100644 packages/core/src/common/util/migration/migrations/migrateToV21.ts create mode 100644 packages/core/src/common/util/migration/schemas/21.json diff --git a/packages/core/src/common/types/common.test.ts b/packages/core/src/common/types/common.test.ts index dffb9574..c79febcd 100644 --- a/packages/core/src/common/types/common.test.ts +++ b/packages/core/src/common/types/common.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import { emptyExperiment } from '@core/context' -import { ExperimentType, isExperiment } from './common' +import { + ExperimentType, + isExperiment, + experimentSchema, + currentVersion, +} from './common' describe('Type guards', () => { it('should not narrow blank object', () => { @@ -34,3 +39,130 @@ describe('Type guards', () => { } }) }) + +describe('score function schema (v21)', () => { + it('currentVersion is 21', () => { + expect(currentVersion).toBe('21') + }) + + it('accepts a scoreVariable with a scoreFunction and a dataEntry with responses', () => { + const experiment = { + id: 'x', + changedSinceLastEvaluation: false, + info: { + name: 'n', + description: '', + swVersion: 'v', + dataFormatVersion: '21', + version: 0, + lastModified: '', + createdAt: '', + extras: {}, + }, + extras: {}, + categoricalVariables: [], + valueVariables: [], + scoreVariables: [ + { + name: 'quality', + label: 'Quality (0-5)', + description: '', + enabled: true, + scoreFunction: { + expression: 'weight/2 + firingAngle', + variables: [ + { name: 'Weight', symbol: 'weight', source: 'response' }, + { + name: 'Firing angle', + symbol: 'firingAngle', + source: 'factor', + factorName: 'Firing angle', + }, + ], + }, + }, + ], + constraints: [{ type: 'sum', value: 0, dimensions: [] }], + optimizerConfig: { + baseEstimator: 'GP', + acqFunc: 'EI', + initialPoints: 3, + kappa: 1.96, + xi: 0.01, + }, + results: { + id: '', + plots: [], + next: [], + pickled: '', + expectedMinimum: [], + extras: {}, + }, + dataPoints: [ + { + meta: { id: 1, enabled: true, valid: true }, + data: [{ type: 'score', name: 'quality', value: 2.5 }], + responses: [ + { + scoreName: 'quality', + useFunction: true, + values: [{ symbol: 'weight', value: 150 }], + }, + ], + }, + ], + } + expect(experimentSchema.safeParse(experiment).success).toBe(true) + }) + + it('still accepts an experiment with neither scoreFunction nor responses', () => { + const parsed = experimentSchema.safeParse({ + id: 'x', + changedSinceLastEvaluation: false, + info: { + name: 'n', + description: '', + swVersion: 'v', + dataFormatVersion: '21', + version: 0, + lastModified: '', + createdAt: '', + extras: {}, + }, + extras: {}, + categoricalVariables: [], + valueVariables: [], + scoreVariables: [ + { + name: 'quality', + label: 'Quality (0-5)', + description: '', + enabled: true, + }, + ], + constraints: [{ type: 'sum', value: 0, dimensions: [] }], + optimizerConfig: { + baseEstimator: 'GP', + acqFunc: 'EI', + initialPoints: 3, + kappa: 1.96, + xi: 0.01, + }, + results: { + id: '', + plots: [], + next: [], + pickled: '', + expectedMinimum: [], + extras: {}, + }, + dataPoints: [ + { + meta: { id: 1, enabled: true, valid: true }, + data: [{ type: 'score', name: 'quality', value: 2.5 }], + }, + ], + }) + expect(parsed.success).toBe(true) + }) +}) diff --git a/packages/core/src/common/types/common.ts b/packages/core/src/common/types/common.ts index 1de2ea49..9393c8d9 100644 --- a/packages/core/src/common/types/common.ts +++ b/packages/core/src/common/types/common.ts @@ -3,7 +3,7 @@ import { z } from 'zod' // Change the current version when doing structural // changes to any types belonging to ExperimentType -export const currentVersion = '20' +export const currentVersion = '21' export const scoreNames = ['quality', 'cost'] as const // Label is shown in UI, name is used in data @@ -60,11 +60,29 @@ const valueVariableSchema = z.object({ enabled: z.boolean(), }) +const scoreFunctionVariableSchema = z.object({ + name: z.string(), + symbol: z.string(), + // z.enum (not z.union of literals) — at this nesting depth, json-schema-faker's + // fuzz-testing in migration.test.ts reliably fakes a union-of-literals field as + // `null`, failing schema validation 100% of the time; z.enum has an identical + // JSON-schema `enum` (not `anyOf`) shape that the faker handles correctly, with + // the same runtime validation and inferred `'response' | 'factor'` type. + source: z.enum(['response', 'factor']), + factorName: z.string().optional(), +}) + +const scoreFunctionSchema = z.object({ + expression: z.string(), + variables: z.array(scoreFunctionVariableSchema), +}) + const scoreVariableSchema = z.object({ name: z.literal(scoreNames[0]).or(z.literal(scoreNames[1])), label: z.string(), description: z.string(), enabled: z.boolean(), + scoreFunction: scoreFunctionSchema.optional(), }) const optimizerSchema = z.object({ @@ -106,9 +124,21 @@ export const dataPointSchema = z.discriminatedUnion('type', [ scoreDataPoint, ]) +const responseValueSchema = z.object({ + symbol: z.string(), + value: z.number(), +}) + +const scoreResponsesSchema = z.object({ + scoreName: z.literal(scoreNames[0]).or(z.literal(scoreNames[1])), + useFunction: z.boolean(), + values: z.array(responseValueSchema), +}) + const dataEntrySchema = z.object({ meta: dataEntryMetaDataSchema, data: z.array(dataPointSchema), + responses: z.array(scoreResponsesSchema).optional(), }) export const spaceSchema = z @@ -168,6 +198,13 @@ export type CategoricalVariableType = z.infer export type ValueVariableType = z.infer export type ScoreVariableType = z.infer export type OptimizerConfig = z.infer +export type ScoreName = (typeof scoreNames)[number] +export type ScoreFunctionType = z.infer +export type ScoreFunctionVariableType = z.infer< + typeof scoreFunctionVariableSchema +> +export type ScoreResponsesType = z.infer +export type ResponseValueType = z.infer // Type guards export function isExperiment(obj: unknown): obj is ExperimentType { diff --git a/packages/core/src/common/util/migration/data-formats/21.json b/packages/core/src/common/util/migration/data-formats/21.json new file mode 100644 index 00000000..53683870 --- /dev/null +++ b/packages/core/src/common/util/migration/data-formats/21.json @@ -0,0 +1,105 @@ +{ + "id": "1234", + "changedSinceLastEvaluation": true, + "lastEvaluationHash": "never-calculated", + "info": { + "dataFormatVersion": "21", + "swVersion": "v1.2.0-16", + "name": "Cake", + "description": "Yummy", + "version": 0, + "extras": {}, + "lastModified": "", + "createdAt": "" + }, + "categoricalVariables": [ + { + "name": "Icing", + "description": "Sugary", + "options": ["White", "Brown"], + "enabled": true + } + ], + "valueVariables": [ + { + "name": "name1", + "description": "desc1", + "min": 10, + "max": 100, + "type": "discrete", + "enabled": true + }, + { + "name": "name2", + "description": "desc2", + "min": 10.2, + "max": 100.3, + "type": "continuous", + "enabled": true + } + ], + "scoreVariables": [ + { + "name": "quality", + "label": "Quality (0-5)", + "description": "", + "enabled": true + } + ], + "constraints": [ + { + "type": "sum", + "dimensions": [], + "value": 0 + } + ], + "optimizerConfig": { + "baseEstimator": "GP", + "acqFunc": "EI", + "initialPoints": 3, + "kappa": 1.96, + "xi": 0.01 + }, + "results": { + "id": "", + "next": [[]], + "plots": [], + "pickled": "", + "expectedMinimum": [], + "extras": {} + }, + "dataPoints": [ + { + "meta": { + "enabled": true, + "valid": true, + "id": 1 + }, + "data": [ + { + "type": "categorical", + "name": "Icing", + "value": "Brown" + }, + { + "type": "numeric", + "name": "name1", + "value": 10 + }, + { + "type": "numeric", + "name": "name2", + "value": 10.2 + }, + { + "type": "score", + "name": "quality", + "value": 0.5 + } + ] + } + ], + "extras": { + "experimentSuggestionCount": 1 + } +} diff --git a/packages/core/src/common/util/migration/migration.test.ts b/packages/core/src/common/util/migration/migration.test.ts index df4a76f9..231261fe 100644 --- a/packages/core/src/common/util/migration/migration.test.ts +++ b/packages/core/src/common/util/migration/migration.test.ts @@ -24,6 +24,7 @@ import { storeLatestSchema, loadTestData } from './test-utils' import { scoreName17 } from './migrations/migrateToV17' import { migrateToV17, migrateToV18 } from './migrations' import { ExperimentTypeV17 } from './migrations/migrateToV18' +import { migrateToV21 } from './migrations/migrateToV21' describe('Migration of data format', () => { storeLatestSchema() @@ -371,3 +372,29 @@ describe('Migration of data format', () => { }) }) }) + +describe('migrateToV21', () => { + it('bumps dataFormatVersion to 21 and preserves data', () => { + const v20 = { + info: { dataFormatVersion: '20', name: 'n' }, + scoreVariables: [ + { + name: 'quality', + label: 'Quality (0-5)', + description: '', + enabled: true, + }, + ], + dataPoints: [ + { + meta: { id: 1, enabled: true, valid: true }, + data: [{ type: 'score', name: 'quality', value: 2 }], + }, + ], + } + const v21 = migrateToV21(v20 as never) + expect(v21.info.dataFormatVersion).toBe('21') + expect(v21.scoreVariables).toEqual(v20.scoreVariables) + expect(v21.dataPoints).toEqual(v20.dataPoints) + }) +}) diff --git a/packages/core/src/common/util/migration/migration.ts b/packages/core/src/common/util/migration/migration.ts index defc9a47..2b8732c8 100644 --- a/packages/core/src/common/util/migration/migration.ts +++ b/packages/core/src/common/util/migration/migration.ts @@ -21,6 +21,7 @@ import { migrateToV18, migrateToV19, migrateToV20, + migrateToV21, } from './migrations' export const migrate = (json: any): ExperimentType => { @@ -106,4 +107,5 @@ export const MIGRATIONS: Migration[] = [ { version: '18', converter: migrateToV18 }, { version: '19', converter: migrateToV19 }, { version: '20', converter: migrateToV20 }, + { version: '21', converter: migrateToV21 }, ] diff --git a/packages/core/src/common/util/migration/migrations/index.ts b/packages/core/src/common/util/migration/migrations/index.ts index 1da83fbb..f5adabdf 100644 --- a/packages/core/src/common/util/migration/migrations/index.ts +++ b/packages/core/src/common/util/migration/migrations/index.ts @@ -16,3 +16,4 @@ export { migrateToV17 } from './migrateToV17' export { migrateToV18 } from './migrateToV18' export { migrateToV19 } from './migrateToV19' export { migrateToV20 } from './migrateToV20' +export { migrateToV21 } from './migrateToV21' diff --git a/packages/core/src/common/util/migration/migrations/migrateToV21.ts b/packages/core/src/common/util/migration/migrations/migrateToV21.ts new file mode 100644 index 00000000..30d33241 --- /dev/null +++ b/packages/core/src/common/util/migration/migrations/migrateToV21.ts @@ -0,0 +1,11 @@ +import { ExperimentType } from '@core/common/types' +import { produce } from 'immer' + +// v21 adds optional score-function definitions (scoreVariable.scoreFunction) and +// per-data-point response inputs (dataEntry.responses). Both are optional, so no +// data transform is needed — existing experiments validate as-is once the version +// literal is bumped. +export const migrateToV21 = (json: ExperimentType): ExperimentType => + produce(json, (draft: { info: { dataFormatVersion: string } }) => { + draft.info.dataFormatVersion = '21' + }) diff --git a/packages/core/src/common/util/migration/schemas/21.json b/packages/core/src/common/util/migration/schemas/21.json new file mode 100644 index 00000000..8d372680 --- /dev/null +++ b/packages/core/src/common/util/migration/schemas/21.json @@ -0,0 +1,528 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "lastEvaluationHash": { + "type": "string" + }, + "changedSinceLastEvaluation": { + "type": "boolean" + }, + "info": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "swVersion": { + "type": "string" + }, + "dataFormatVersion": { + "type": "string", + "const": "21" + }, + "version": { + "type": "number" + }, + "lastModified": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "extras": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "name", + "description", + "swVersion", + "dataFormatVersion", + "version", + "lastModified", + "createdAt", + "extras" + ], + "additionalProperties": false + }, + "extras": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "categoricalVariables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "name", + "description", + "options", + "enabled" + ], + "additionalProperties": false + } + }, + "valueVariables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "anyOf": [ + { + "type": "string", + "const": "discrete" + }, + { + "type": "string", + "const": "continuous" + } + ] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "type", + "name", + "description", + "min", + "max", + "enabled" + ], + "additionalProperties": false + } + }, + "scoreVariables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "const": "quality" + }, + { + "type": "string", + "const": "cost" + } + ] + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "scoreFunction": { + "type": "object", + "properties": { + "expression": { + "type": "string" + }, + "variables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "symbol": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "response", + "factor" + ] + }, + "factorName": { + "type": "string" + } + }, + "required": [ + "name", + "symbol", + "source" + ], + "additionalProperties": false + } + } + }, + "required": [ + "expression", + "variables" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "label", + "description", + "enabled" + ], + "additionalProperties": false + } + }, + "constraints": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sum" + }, + "value": { + "type": "number" + }, + "dimensions": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "value", + "dimensions" + ], + "additionalProperties": false + } + }, + "optimizerConfig": { + "type": "object", + "properties": { + "baseEstimator": { + "type": "string" + }, + "acqFunc": { + "type": "string" + }, + "initialPoints": { + "type": "number" + }, + "kappa": { + "type": "number" + }, + "xi": { + "type": "number" + } + }, + "required": [ + "baseEstimator", + "acqFunc", + "initialPoints", + "kappa", + "xi" + ], + "additionalProperties": false + }, + "results": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "plots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "plot": { + "type": "string" + } + }, + "required": [ + "id", + "plot" + ], + "additionalProperties": false + } + }, + "next": { + "type": "array", + "items": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + } + } + }, + "pickled": { + "type": "string" + }, + "expectedMinimum": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "type": "number" + } + ] + } + }, + "extras": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "id", + "plots", + "next", + "pickled", + "expectedMinimum", + "extras" + ], + "additionalProperties": false + }, + "dataPoints": { + "type": "array", + "items": { + "type": "object", + "properties": { + "meta": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "enabled": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + }, + "description": { + "type": "string" + } + }, + "required": [ + "id", + "enabled", + "valid" + ], + "additionalProperties": false + }, + "data": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "numeric" + }, + "name": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "required": [ + "type", + "name", + "value" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "categorical" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "name", + "value" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "score" + }, + "name": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "required": [ + "type", + "name", + "value" + ], + "additionalProperties": false + } + ] + } + }, + "responses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "scoreName": { + "anyOf": [ + { + "type": "string", + "const": "quality" + }, + { + "type": "string", + "const": "cost" + } + ] + }, + "useFunction": { + "type": "boolean" + }, + "values": { + "type": "array", + "items": { + "type": "object", + "properties": { + "symbol": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "required": [ + "symbol", + "value" + ], + "additionalProperties": false + } + } + }, + "required": [ + "scoreName", + "useFunction", + "values" + ], + "additionalProperties": false + } + } + }, + "required": [ + "meta", + "data" + ], + "additionalProperties": false + } + } + }, + "required": [ + "id", + "changedSinceLastEvaluation", + "info", + "extras", + "categoricalVariables", + "valueVariables", + "scoreVariables", + "constraints", + "optimizerConfig", + "results", + "dataPoints" + ], + "additionalProperties": false +} \ No newline at end of file From c79be3606007b483b917de5c79344fad0f779006 Mon Sep 17 00:00:00 2001 From: Jack Ord Date: Wed, 22 Jul 2026 11:46:47 +0000 Subject: [PATCH 05/60] feat(core): add computeScore/deriveSymbol/usedSymbols score utils --- packages/core/src/common/util/index.ts | 1 + .../common/util/score/compute-score.test.ts | 77 +++++++++++++++++++ .../src/common/util/score/compute-score.ts | 70 +++++++++++++++++ packages/core/src/common/util/score/index.ts | 1 + 4 files changed, 149 insertions(+) create mode 100644 packages/core/src/common/util/score/compute-score.test.ts create mode 100644 packages/core/src/common/util/score/compute-score.ts create mode 100644 packages/core/src/common/util/score/index.ts diff --git a/packages/core/src/common/util/index.ts b/packages/core/src/common/util/index.ts index 64204a06..dbb903f8 100644 --- a/packages/core/src/common/util/index.ts +++ b/packages/core/src/common/util/index.ts @@ -4,6 +4,7 @@ export * from './save-to-local-file' export * from './data-type-detectors' export * from './scores/quality' export * from './pareto/pareto-plot' +export * from './score' export const errorMessage = (e: unknown) => { if (typeof e === 'string') { diff --git a/packages/core/src/common/util/score/compute-score.test.ts b/packages/core/src/common/util/score/compute-score.test.ts new file mode 100644 index 00000000..c409c4e0 --- /dev/null +++ b/packages/core/src/common/util/score/compute-score.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest' +import { computeScore, deriveSymbol, usedSymbols } from './compute-score' +import type { ScoreFunctionType, DataPointType } from '@core/common/types' + +describe('deriveSymbol', () => { + it('sanitises names to valid identifiers', () => { + expect(deriveSymbol('Wait before stir', [])).toBe('waitBeforeStir') + expect(deriveSymbol('Weight', [])).toBe('weight') + }) + it('enforces uniqueness with numeric suffixes', () => { + expect(deriveSymbol('Weight', ['weight'])).toBe('weight_2') + expect(deriveSymbol('Weight', ['weight', 'weight_2'])).toBe('weight_3') + }) +}) + +describe('usedSymbols', () => { + it('returns referenced symbols', () => { + expect(usedSymbols('weight/2 + viscosity*2').sort()).toEqual([ + 'viscosity', + 'weight', + ]) + }) + it('returns [] for an unparseable expression', () => { + expect(usedSymbols('weight/')).toEqual([]) + }) +}) + +describe('computeScore', () => { + const fn: ScoreFunctionType = { + expression: 'weight/2 + firingAngle/100', + variables: [ + { name: 'Weight', symbol: 'weight', source: 'response' }, + { + name: 'Firing angle', + symbol: 'firingAngle', + source: 'factor', + factorName: 'Firing angle', + }, + ], + } + const factorData: DataPointType[] = [ + { type: 'numeric', name: 'Firing angle', value: 120 }, + ] + + it('evaluates responses + factors', () => { + expect( + computeScore(fn, [{ symbol: 'weight', value: 10 }], factorData) + ).toBeCloseTo(6.2) + }) + it('returns undefined when a response value is missing', () => { + expect(computeScore(fn, [], factorData)).toBeUndefined() + }) + it('returns undefined when a referenced factor is absent from the data point', () => { + expect( + computeScore(fn, [{ symbol: 'weight', value: 10 }], []) + ).toBeUndefined() + }) + it('supports a response-only function', () => { + const respFn: ScoreFunctionType = { + expression: 'a + b', + variables: [ + { name: 'A', symbol: 'a', source: 'response' }, + { name: 'B', symbol: 'b', source: 'response' }, + ], + } + expect( + computeScore( + respFn, + [ + { symbol: 'a', value: 2 }, + { symbol: 'b', value: 3 }, + ], + [] + ) + ).toBe(5) + }) +}) diff --git a/packages/core/src/common/util/score/compute-score.ts b/packages/core/src/common/util/score/compute-score.ts new file mode 100644 index 00000000..080fd7c6 --- /dev/null +++ b/packages/core/src/common/util/score/compute-score.ts @@ -0,0 +1,70 @@ +import { parse, evaluate, isSymbolNode } from 'mathjs' +import type { ScoreFunctionType, DataPointType } from '@core/common/types' + +// Turn a human display name into a valid, unique mathjs identifier. +// "Wait before stir" -> "waitBeforeStir"; collisions get _2, _3, ... +export const deriveSymbol = (name: string, existing: string[]): string => { + const words = name + .replace(/[^a-zA-Z0-9 ]/g, ' ') + .trim() + .split(/\s+/) + .filter(Boolean) + let base = + words + .map((w, i) => + i === 0 + ? w.toLowerCase() + : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase() + ) + .join('') || 'x' + if (/^[0-9]/.test(base)) base = 'x' + base + let symbol = base + let n = 2 + while (existing.includes(symbol)) { + symbol = `${base}_${n}` + n += 1 + } + return symbol +} + +// Symbol names referenced by an expression. [] if it does not parse. +export const usedSymbols = (expression: string): string[] => { + try { + const node = parse(expression) + const symbols = new Set() + node.filter(isSymbolNode).forEach(n => { + if (isSymbolNode(n)) symbols.add(n.name) + }) + return [...symbols] + } catch { + return [] + } +} + +export const computeScore = ( + fn: ScoreFunctionType, + responseValues: { symbol: string; value: number }[], + factorData: DataPointType[] +): number | undefined => { + const scope: Record = {} + for (const variable of fn.variables) { + if (variable.source === 'response') { + const rv = responseValues.find(r => r.symbol === variable.symbol) + if (rv === undefined || !Number.isFinite(rv.value)) return undefined + scope[variable.symbol] = rv.value + } else { + const fd = factorData.find(d => d.name === variable.factorName) + const value = fd === undefined ? undefined : Number(fd.value) + if (value === undefined || !Number.isFinite(value)) return undefined + scope[variable.symbol] = value + } + } + try { + const result = evaluate(fn.expression, scope) + return typeof result === 'number' && Number.isFinite(result) + ? result + : undefined + } catch { + return undefined + } +} diff --git a/packages/core/src/common/util/score/index.ts b/packages/core/src/common/util/score/index.ts new file mode 100644 index 00000000..1e4e91b2 --- /dev/null +++ b/packages/core/src/common/util/score/index.ts @@ -0,0 +1 @@ +export { computeScore, deriveSymbol, usedSymbols } from './compute-score' From 5d7a451d5cc4a531859b01b588afe6282bd6e54b Mon Sep 17 00:00:00 2001 From: Jack Ord Date: Wed, 22 Jul 2026 11:54:44 +0000 Subject: [PATCH 06/60] feat(core): reducer actions to set score function and data-point responses --- .../context/experiment/experiment-reducers.ts | 85 ++++++++++++++++ .../src/context/experiment/reducers.test.ts | 99 ++++++++++++++++++- .../core/src/context/experiment/reducers.ts | 4 +- .../core/src/context/experiment/test-utils.ts | 7 ++ 4 files changed, 193 insertions(+), 2 deletions(-) diff --git a/packages/core/src/context/experiment/experiment-reducers.ts b/packages/core/src/context/experiment/experiment-reducers.ts index bed1df0f..fdf771a5 100644 --- a/packages/core/src/context/experiment/experiment-reducers.ts +++ b/packages/core/src/context/experiment/experiment-reducers.ts @@ -4,6 +4,8 @@ import { ExperimentResultType, ExperimentType, OptimizerConfig, + ScoreFunctionType, + ScoreName, ScoreVariableType, ValueVariableType, experimentSchema, @@ -15,6 +17,7 @@ import { produce } from 'immer' import md5 from 'md5' import { settings, versionInfo } from '@core/common' import { assertUnreachable } from '@core/common/util' +import { computeScore } from '@core/common/util/score' import { selectActiveDataPoints, selectActiveDataPointsFromExperiment, @@ -175,6 +178,45 @@ export type ExperimentAction = type: 'experiment/removeVariableFromConstraintSum' payload: string } + | { + type: 'updateScoreFunction' + payload: { + scoreName: ScoreName + scoreFunction: ScoreFunctionType | undefined + } + } + | { + type: 'updateDataPointResponses' + payload: { + metaId: number + scoreName: ScoreName + useFunction: boolean + values: { symbol: string; value: number }[] + } + } + +// Recompute and persist the {type:'score', name} value for one objective of one +// data entry, when that objective is in function mode. Manual rows are untouched. +const recomputeScore = ( + dataEntry: DataEntry, + scoreName: ScoreName, + scoreFunction: ScoreFunctionType | undefined +): void => { + const responses = dataEntry.responses?.find(r => r.scoreName === scoreName) + if ( + scoreFunction === undefined || + responses === undefined || + !responses.useFunction + ) + return + const value = computeScore(scoreFunction, responses.values, dataEntry.data) + const scoreEntry = dataEntry.data.find( + d => d.type === 'score' && d.name === scoreName + ) + if (value !== undefined && scoreEntry !== undefined) { + scoreEntry.value = value + } +} const experimentReducerInner = produce( (state: ExperimentType, action: ExperimentAction): void | ExperimentType => { @@ -492,6 +534,49 @@ const experimentReducerInner = produce( break } + case 'updateScoreFunction': { + const sv = state.scoreVariables.find( + it => it.name === action.payload.scoreName + ) + if (sv !== undefined) { + sv.scoreFunction = action.payload.scoreFunction + state.dataPoints.forEach(dp => + recomputeScore( + dp, + action.payload.scoreName, + action.payload.scoreFunction + ) + ) + } + break + } + case 'updateDataPointResponses': { + const dp = state.dataPoints.find( + d => d.meta.id === action.payload.metaId + ) + if (dp !== undefined) { + if (dp.responses === undefined) dp.responses = [] + const existing = dp.responses.find( + r => r.scoreName === action.payload.scoreName + ) + const next = { + scoreName: action.payload.scoreName, + useFunction: action.payload.useFunction, + values: action.payload.values, + } + if (existing !== undefined) { + existing.useFunction = next.useFunction + existing.values = next.values + } else { + dp.responses.push(next) + } + const sv = state.scoreVariables.find( + it => it.name === action.payload.scoreName + ) + recomputeScore(dp, action.payload.scoreName, sv?.scoreFunction) + } + break + } default: assertUnreachable(action) } diff --git a/packages/core/src/context/experiment/reducers.test.ts b/packages/core/src/context/experiment/reducers.test.ts index fbecd6e7..5c362228 100644 --- a/packages/core/src/context/experiment/reducers.test.ts +++ b/packages/core/src/context/experiment/reducers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { ExperimentAction } from './experiment-reducers' +import { ExperimentAction, experimentReducer } from './experiment-reducers' import { rootReducer } from './reducers' import { currentVersion, @@ -7,6 +7,7 @@ import { ExperimentResultType, ExperimentType, OptimizerConfig, + ScoreFunctionType, scoreNames, ValueVariableType, } from '@core/common/types' @@ -1435,3 +1436,99 @@ describe('resetting suggestion count when the model is first fit (#1)', () => { expect('experimentSuggestionCount' in after.extras).toBe(false) }) }) + +describe('updateScoreFunction + updateDataPointResponses', () => { + const fn: ScoreFunctionType = { + expression: 'weight * 2', + variables: [{ name: 'Weight', symbol: 'weight', source: 'response' }], + } + + // v21 experiment: one valueVariable, scoreVariables: [{name:'quality', ...}] + // (from emptyExperiment), one dataPoint with a quality score of 0 whose + // responses entry drives the function. + const buildState = (): ExperimentType => + produce(emptyExperiment, draft => { + draft.valueVariables = [createValueVariable({ name: 'Weight' })] + draft.dataPoints = [ + { + meta: { enabled: true, valid: true, id: 1 }, + data: [ + { type: 'numeric', name: 'Weight', value: 1.5 }, + { type: 'score', name: scoreNames[0], value: 0 }, + ], + responses: [ + { + scoreName: scoreNames[0], + useFunction: true, + values: [{ symbol: 'weight', value: 1.5 }], + }, + ], + }, + ] + }) + + const buildStateWithFunction = ( + scoreFunction: ScoreFunctionType + ): ExperimentType => + produce(buildState(), draft => { + const sv = draft.scoreVariables.find(s => s.name === scoreNames[0]) + if (sv !== undefined) sv.scoreFunction = scoreFunction + }) + + it('setting a function recomputes rows that use it', () => { + let state = buildState() + state = experimentReducer(state, { + type: 'updateScoreFunction', + payload: { scoreName: 'quality', scoreFunction: fn }, + }) + const score = state.dataPoints[0]?.data.find( + d => d.type === 'score' && d.name === 'quality' + ) + expect(score?.value).toBe(3) // 1.5 * 2 + expect(state.scoreVariables[0]?.scoreFunction).toEqual(fn) + }) + + it('updating responses recomputes that row when useFunction is true', () => { + let state: ExperimentType = buildStateWithFunction(fn) + state = experimentReducer(state, { + type: 'updateDataPointResponses', + payload: { + metaId: 1, + scoreName: 'quality', + useFunction: true, + values: [{ symbol: 'weight', value: 2 }], + }, + }) + const score = state.dataPoints[0]?.data.find( + d => d.type === 'score' && d.name === 'quality' + ) + expect(score?.value).toBe(4) + expect(state.dataPoints[0]?.responses?.[0]?.values[0]?.value).toBe(2) + }) + + it('does not recompute a row in manual mode', () => { + let state: ExperimentType = buildStateWithFunction(fn) + // set manual value 9, useFunction false + state = experimentReducer(state, { + type: 'updateDataPointResponses', + payload: { + metaId: 1, + scoreName: 'quality', + useFunction: false, + values: [{ symbol: 'weight', value: 2 }], + }, + }) + // changing the function should NOT overwrite the manual score + state = experimentReducer(state, { + type: 'updateScoreFunction', + payload: { + scoreName: 'quality', + scoreFunction: { ...fn, expression: 'weight * 100' }, + }, + }) + const score = state.dataPoints[0]?.data.find( + d => d.type === 'score' && d.name === 'quality' + ) + expect(score?.value).not.toBe(200) + }) +}) diff --git a/packages/core/src/context/experiment/reducers.ts b/packages/core/src/context/experiment/reducers.ts index eb101be9..37662dc1 100644 --- a/packages/core/src/context/experiment/reducers.ts +++ b/packages/core/src/context/experiment/reducers.ts @@ -37,7 +37,9 @@ export const rootReducer = (state: State, action: Action) => { case 'experiment/toggleMultiObjective': case 'experiment/setConstraintSum': case 'experiment/addVariableToConstraintSum': - case 'experiment/removeVariableFromConstraintSum': { + case 'experiment/removeVariableFromConstraintSum': + case 'updateScoreFunction': + case 'updateDataPointResponses': { const experiment = experimentReducer(state.experiment, action) const validationViolations: ValidationViolations = validateExperiment(experiment) diff --git a/packages/core/src/context/experiment/test-utils.ts b/packages/core/src/context/experiment/test-utils.ts index 87fb1006..de952a1d 100644 --- a/packages/core/src/context/experiment/test-utils.ts +++ b/packages/core/src/context/experiment/test-utils.ts @@ -140,4 +140,11 @@ export const dummyPayloads: Payloads = { 'experiment/setConstraintSum': 0, 'experiment/addVariableToConstraintSum': '', 'experiment/removeVariableFromConstraintSum': '', + updateScoreFunction: { scoreName: scoreNames[0], scoreFunction: undefined }, + updateDataPointResponses: { + metaId: 1, + scoreName: scoreNames[0], + useFunction: false, + values: [], + }, } From 2062835443d39ce382d7377a24290b03fcb3de53 Mon Sep 17 00:00:00 2001 From: Jack Ord Date: Wed, 22 Jul 2026 13:58:59 +0000 Subject: [PATCH 07/60] feat(core): validate required responses when a score function is used --- .../experiment/validation-reducer.test.ts | 1 + .../src/context/experiment/validation.test.ts | 63 ++++++++++++++++++- .../core/src/context/experiment/validation.ts | 41 ++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/packages/core/src/context/experiment/validation-reducer.test.ts b/packages/core/src/context/experiment/validation-reducer.test.ts index 1fc34f0a..e19224ec 100644 --- a/packages/core/src/context/experiment/validation-reducer.test.ts +++ b/packages/core/src/context/experiment/validation-reducer.test.ts @@ -46,6 +46,7 @@ const emptyViolations = { duplicateDataPointIds: [], categoricalValues: [], dataPointsNumericType: [], + dataPointsResponsesUndefined: [], } satisfies ValidationViolations describe('validationReducer', () => { diff --git a/packages/core/src/context/experiment/validation.test.ts b/packages/core/src/context/experiment/validation.test.ts index 608bdc85..10582ff9 100644 --- a/packages/core/src/context/experiment/validation.test.ts +++ b/packages/core/src/context/experiment/validation.test.ts @@ -8,10 +8,11 @@ import { validateDataPointsUndefined, validateDuplicateDataPointIds, validateDuplicateVariableNames, + validateExperiment, validateLowerBoundary, validateUpperBoundary, } from './validation' -import { ExperimentType, scoreNames } from '@core/common' +import { ExperimentType, ScoreFunctionType, scoreNames } from '@core/common' describe('validateUpperBoundary', () => { it('should return empty array if no violations exist', () => { @@ -669,6 +670,7 @@ describe('findDataPointViolations', () => { upperBoundary: [1, 2, 5, 6], categoricalValues: [], dataPointsNumericType: [3], + dataPointsResponsesUndefined: [], } it('should return correct list of data point violations', () => { const dpViolations = findDataPointViolations(violations) @@ -716,3 +718,62 @@ describe('findDataPointViolations', () => { ) }) }) + +const buildExperimentWithQualityFunction = ( + fn: ScoreFunctionType +): ExperimentType => ({ + ...emptyExperiment, + scoreVariables: emptyExperiment.scoreVariables.map(sv => + sv.name === scoreNames[0] ? { ...sv, scoreFunction: fn } : sv + ), + dataPoints: [ + { + meta: { + id: 1, + enabled: true, + valid: true, + }, + data: [], + responses: [ + { + scoreName: scoreNames[0], + useFunction: true, + values: [], + }, + ], + }, + ], +}) + +describe('response validation', () => { + const base = () => + buildExperimentWithQualityFunction({ + expression: 'weight * 2', + variables: [{ name: 'Weight', symbol: 'weight', source: 'response' }], + }) + + it('flags a row using the function with a missing response value', () => { + const exp = base() // dataPoint meta.id 1, responses useFunction:true but values:[] + const v = validateExperiment(exp) + expect( + v.dataPointsResponsesUndefined.some( + x => x.id === 1 && x.scoreName === 'quality' + ) + ).toBe(true) + const messages = + findDataPointViolations(v).find(x => x.rowMetaId === 1)?.messages ?? [] + expect(messages).toContain( + 'All responses must be defined to use the quality function.' + ) + }) + + it('does not flag a row in manual mode', () => { + const exp = base() + exp.dataPoints[0]!.responses = [ + { scoreName: 'quality', useFunction: false, values: [] }, + ] + expect( + validateExperiment(exp).dataPointsResponsesUndefined.some(x => x.id === 1) + ).toBe(false) + }) +}) diff --git a/packages/core/src/context/experiment/validation.ts b/packages/core/src/context/experiment/validation.ts index 02235307..e156f29c 100644 --- a/packages/core/src/context/experiment/validation.ts +++ b/packages/core/src/context/experiment/validation.ts @@ -8,6 +8,7 @@ export type ValidationViolations = { duplicateDataPointIds: number[] categoricalValues: number[] dataPointsNumericType: number[] + dataPointsResponsesUndefined: { id: number; scoreName: string }[] } export const validateExperiment = ( @@ -21,6 +22,8 @@ export const validateExperiment = ( duplicateDataPointIds: validateDuplicateDataPointIds(experiment), categoricalValues: validateCategoricalValues(experiment), dataPointsNumericType: validateDataPointsNumericType(experiment), + dataPointsResponsesUndefined: + validateDataPointsResponsesUndefined(experiment), } } @@ -141,6 +144,35 @@ export const validateCategoricalValues = (experiment: ExperimentType) => { return violations } +export const validateDataPointsResponsesUndefined = ( + experiment: ExperimentType +): { id: number; scoreName: string }[] => { + const violations: { id: number; scoreName: string }[] = [] + const functionsByScore = new Map( + experiment.scoreVariables + .filter(sv => sv.scoreFunction !== undefined) + .map(sv => [sv.name, sv.scoreFunction!]) + ) + experiment.dataPoints.forEach(dp => { + dp.responses?.forEach(resp => { + const fn = functionsByScore.get(resp.scoreName) + if (fn === undefined || !resp.useFunction) return + const requiredSymbols = fn.variables + .filter(v => v.source === 'response') + .map(v => v.symbol) + const provided = new Map(resp.values.map(v => [v.symbol, v.value])) + const missing = requiredSymbols.some(sym => { + const val = provided.get(sym) + return val === undefined || !Number.isFinite(val) + }) + if (missing) { + violations.push({ id: dp.meta.id, scoreName: resp.scoreName }) + } + }) + }) + return violations +} + export const findUniqueEntries = (arr: number[]): number[] => arr.filter((val, i, arr) => arr.indexOf(val) === i) @@ -160,11 +192,13 @@ export const findDataPointViolations = ( const upperBoundary = violations.upperBoundary const lowerBoundary = violations.lowerBoundary const numericType = violations.dataPointsNumericType + const responsesUndefined = violations.dataPointsResponsesUndefined findUniqueEntries( pointsUndefined .concat(upperBoundary) .concat(lowerBoundary) .concat(numericType) + .concat(responsesUndefined.map(r => r.id)) ).forEach(e => { const messages: string[] = [] if (pointsUndefined.includes(e)) { @@ -185,6 +219,13 @@ export const findDataPointViolations = ( if (numericType.includes(e)) { messages.push('Discrete values must be integers.') } + responsesUndefined + .filter(r => r.id === e) + .forEach(r => { + messages.push( + `All responses must be defined to use the ${r.scoreName} function.` + ) + }) allViolations.push({ rowMetaId: e, messages, From 2bbdba3df9b3fe86014c433c423e335a68c44fda Mon Sep 17 00:00:00 2001 From: Jack Ord Date: Wed, 22 Jul 2026 14:19:42 +0000 Subject: [PATCH 08/60] fix(core): mark data points with missing responses invalid --- .../src/context/experiment/validation-reducer.test.ts | 9 +++++++++ .../core/src/context/experiment/validation-reducer.ts | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/src/context/experiment/validation-reducer.test.ts b/packages/core/src/context/experiment/validation-reducer.test.ts index e19224ec..874358e8 100644 --- a/packages/core/src/context/experiment/validation-reducer.test.ts +++ b/packages/core/src/context/experiment/validation-reducer.test.ts @@ -153,4 +153,13 @@ describe('validationReducer', () => { expect(validatedExperiment.dataPoints[0]?.meta.valid).toBeFalsy() expect(validatedExperiment.dataPoints[1]?.meta.valid).toBeTruthy() }) + + it('should invalidate data points with missing score function responses', () => { + const validatedExperiment = validationReducer(exp, { + ...emptyViolations, + dataPointsResponsesUndefined: [{ id: 1, scoreName: 'quality' }], + }) + expect(validatedExperiment.dataPoints[0]?.meta.valid).toBeFalsy() + expect(validatedExperiment.dataPoints[1]?.meta.valid).toBeTruthy() + }) }) diff --git a/packages/core/src/context/experiment/validation-reducer.ts b/packages/core/src/context/experiment/validation-reducer.ts index 02a8cb00..4cbcc885 100644 --- a/packages/core/src/context/experiment/validation-reducer.ts +++ b/packages/core/src/context/experiment/validation-reducer.ts @@ -14,7 +14,10 @@ export const validationReducer = produce( !violations.duplicateDataPointIds.includes(dp.meta.id) && !violations.categoricalValues.includes(dp.meta.id) && violations.duplicateVariableNames.length === 0 && - !violations.dataPointsNumericType.includes(dp.meta.id) + !violations.dataPointsNumericType.includes(dp.meta.id) && + !violations.dataPointsResponsesUndefined.some( + v => v.id === dp.meta.id + ) return { ...dp, meta: { From e86e90648ea663e1bd3154a9931a8d2fd027e435 Mon Sep 17 00:00:00 2001 From: Jack Ord Date: Wed, 22 Jul 2026 14:26:03 +0000 Subject: [PATCH 09/60] feat(ui): add react-simple-code-editor score-function field with symbol highlighting --- package-lock.json | 11 ++++ packages/ui/package.json | 1 + .../settings/score-function-field.test.tsx | 29 +++++++++ .../settings/score-function-field.tsx | 64 +++++++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 packages/ui/src/features/data-points/settings/score-function-field.test.tsx create mode 100644 packages/ui/src/features/data-points/settings/score-function-field.tsx diff --git a/package-lock.json b/package-lock.json index 944e2541..3360ae92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10382,6 +10382,16 @@ "react-dom": ">=18" } }, + "node_modules/react-simple-code-editor": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.14.1.tgz", + "integrity": "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "license": "BSD-3-Clause", @@ -12931,6 +12941,7 @@ "lodash.debounce": "^4.0.8", "mathjs": "^14.8.0", "react-hook-form": "^7.76.1", + "react-simple-code-editor": "^0.14.1", "remeda": "^2.37.0", "tss-react": "^4.9.21" }, diff --git a/packages/ui/package.json b/packages/ui/package.json index 92673ebc..814dc403 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -10,6 +10,7 @@ "lodash.debounce": "^4.0.8", "mathjs": "^14.8.0", "react-hook-form": "^7.76.1", + "react-simple-code-editor": "^0.14.1", "remeda": "^2.37.0", "tss-react": "^4.9.21" }, diff --git a/packages/ui/src/features/data-points/settings/score-function-field.test.tsx b/packages/ui/src/features/data-points/settings/score-function-field.test.tsx new file mode 100644 index 00000000..685faf7a --- /dev/null +++ b/packages/ui/src/features/data-points/settings/score-function-field.test.tsx @@ -0,0 +1,29 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen, cleanup, fireEvent } from '@testing-library/react' +import { ScoreFunctionField } from './score-function-field' + +afterEach(() => cleanup()) + +describe('ScoreFunctionField', () => { + it('renders the current expression text', () => { + const { container } = render( + {}} + /> + ) + // Scoped to the highlighted
: react-simple-code-editor also mounts the
+    // raw value as the textarea's initial text node, so an unscoped
+    // screen.getByText(/weight/) matches both and throws "multiple elements".
+    expect(container.querySelector('pre')).toHaveTextContent('weight')
+  })
+
+  it('calls onChange when edited', () => {
+    const onChange = vi.fn()
+    render()
+    const textarea = screen.getByRole('textbox')
+    fireEvent.change(textarea, { target: { value: 'weight' } })
+    expect(onChange).toHaveBeenCalledWith('weight')
+  })
+})
diff --git a/packages/ui/src/features/data-points/settings/score-function-field.tsx b/packages/ui/src/features/data-points/settings/score-function-field.tsx
new file mode 100644
index 00000000..f2136bf1
--- /dev/null
+++ b/packages/ui/src/features/data-points/settings/score-function-field.tsx
@@ -0,0 +1,64 @@
+import { forwardRef, useImperativeHandle, useRef } from 'react'
+import Editor from 'react-simple-code-editor'
+
+export type ScoreFunctionFieldHandle = {
+  insertAtCursor: (text: string) => void
+}
+
+type Props = {
+  value: string
+  symbols: string[]
+  onChange: (next: string) => void
+}
+
+// Split an expression into variable/other tokens and wrap recognised symbols in
+// a coloured span. Recognised = present in `symbols`. Everything else is plain.
+const highlight = (code: string, symbols: string[]): string => {
+  const escape = (s: string) =>
+    s.replace(/&/g, '&').replace(//g, '>')
+  return code.replace(/[A-Za-z_][A-Za-z0-9_]*|[^A-Za-z_]+/g, token => {
+    if (symbols.includes(token)) {
+      return `${escape(token)}`
+    }
+    return escape(token)
+  })
+}
+
+export const ScoreFunctionField = forwardRef(
+  ({ value, symbols, onChange }, ref) => {
+    const lastSelection = useRef(value.length)
+
+    useImperativeHandle(ref, () => ({
+      insertAtCursor: (text: string) => {
+        const pos = lastSelection.current ?? value.length
+        onChange(value.slice(0, pos) + text + value.slice(pos))
+      },
+    }))
+
+    return (
+       highlight(code, symbols)}
+        padding={10}
+        onKeyUp={e => {
+          lastSelection.current =
+            (e.target as HTMLTextAreaElement).selectionStart ?? value.length
+        }}
+        onClick={e => {
+          lastSelection.current =
+            (e.target as HTMLTextAreaElement).selectionStart ?? value.length
+        }}
+        textareaId="score-function-input"
+        style={{
+          fontFamily: 'monospace',
+          fontSize: 16,
+          border: '1px solid rgba(0,0,0,0.23)',
+          borderRadius: 4,
+          minHeight: 48,
+        }}
+      />
+    )
+  }
+)
+ScoreFunctionField.displayName = 'ScoreFunctionField'

From 1fd6860d44d85d067d70fbba98e0d662fe4d86b9 Mon Sep 17 00:00:00 2001
From: Jack Ord 
Date: Wed, 22 Jul 2026 14:42:05 +0000
Subject: [PATCH 10/60] feat(ui): add score-function playground with live
 evaluation

---
 .../score-function-playground.test.tsx        | 37 +++++++++++
 .../settings/score-function-playground.tsx    | 61 +++++++++++++++++++
 2 files changed, 98 insertions(+)
 create mode 100644 packages/ui/src/features/data-points/settings/score-function-playground.test.tsx
 create mode 100644 packages/ui/src/features/data-points/settings/score-function-playground.tsx

diff --git a/packages/ui/src/features/data-points/settings/score-function-playground.test.tsx b/packages/ui/src/features/data-points/settings/score-function-playground.test.tsx
new file mode 100644
index 00000000..faec63c7
--- /dev/null
+++ b/packages/ui/src/features/data-points/settings/score-function-playground.test.tsx
@@ -0,0 +1,37 @@
+import { describe, it, expect, afterEach } from 'vitest'
+import { render, screen, cleanup, fireEvent } from '@testing-library/react'
+import { ScoreFunctionPlayground } from './score-function-playground'
+
+afterEach(() => cleanup())
+
+describe('ScoreFunctionPlayground', () => {
+  it('shows empty state without a function', () => {
+    render()
+    expect(
+      screen.getByText('No function added. Add a function to test it.')
+    ).toBeInTheDocument()
+  })
+
+  it('computes a live result from test inputs', () => {
+    render(
+      
+    )
+    fireEvent.change(screen.getByLabelText('weight'), {
+      target: { value: '7' },
+    })
+    fireEvent.change(screen.getByLabelText('viscosity'), {
+      target: { value: '15' },
+    })
+    expect(screen.getByTestId('playground-result').textContent).toContain(
+      '33.5'
+    )
+  })
+})
diff --git a/packages/ui/src/features/data-points/settings/score-function-playground.tsx b/packages/ui/src/features/data-points/settings/score-function-playground.tsx
new file mode 100644
index 00000000..80c2f458
--- /dev/null
+++ b/packages/ui/src/features/data-points/settings/score-function-playground.tsx
@@ -0,0 +1,61 @@
+import { useMemo, useState } from 'react'
+import { Box, TextField, Typography } from '@mui/material'
+import {
+  usedSymbols,
+  computeScore,
+  type ScoreFunctionType,
+} from '@boostv/process-optimizer-frontend-core'
+
+type Props = { scoreFunction: ScoreFunctionType | undefined }
+
+export const ScoreFunctionPlayground = ({ scoreFunction }: Props) => {
+  const [inputs, setInputs] = useState>({})
+
+  const symbols = useMemo(
+    () => (scoreFunction ? usedSymbols(scoreFunction.expression) : []),
+    [scoreFunction]
+  )
+
+  if (!scoreFunction || scoreFunction.expression.trim() === '') {
+    return (
+      No function added. Add a function to test it.
+    )
+  }
+
+  // Treat every used symbol as a response for the playground (factors are just
+  // numbers here). Build response values from the test inputs.
+  const testFn: ScoreFunctionType = {
+    expression: scoreFunction.expression,
+    variables: symbols.map(s => ({
+      name: s,
+      symbol: s,
+      source: 'response' as const,
+    })),
+  }
+  const values = symbols.map(s => ({ symbol: s, value: Number(inputs[s]) }))
+  const result = computeScore(testFn, values, [])
+
+  return (
+    
+      {symbols.map(s => (
+         setInputs(prev => ({ ...prev, [s]: e.target.value }))}
+        />
+      ))}
+      
+        Result
+        
+          {result === undefined ? '—' : result}
+        
+      
+    
+  )
+}

From 53d411cf4546149621f68df1b920a43345775174 Mon Sep 17 00:00:00 2001
From: Jack Ord 
Date: Thu, 23 Jul 2026 06:16:39 +0000
Subject: [PATCH 11/60] feat(ui): rewrite score-function settings dialog wired
 to experiment state

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 .../settings/data-points-settings.style.ts    |   1 -
 .../settings/data-points-settings.test.tsx    |  80 ++++
 .../settings/data-points-settings.tsx         | 345 ++++++++++++------
 .../settings/function-variables-table.tsx     |  40 --
 .../settings/function-variables.style.ts      |  21 --
 .../settings/function-variables.tsx           |  81 ----
 .../data-points/settings/playground.tsx       |  15 -
 7 files changed, 312 insertions(+), 271 deletions(-)
 create mode 100644 packages/ui/src/features/data-points/settings/data-points-settings.test.tsx
 delete mode 100644 packages/ui/src/features/data-points/settings/function-variables-table.tsx
 delete mode 100644 packages/ui/src/features/data-points/settings/function-variables.style.ts
 delete mode 100644 packages/ui/src/features/data-points/settings/function-variables.tsx
 delete mode 100644 packages/ui/src/features/data-points/settings/playground.tsx

diff --git a/packages/ui/src/features/data-points/settings/data-points-settings.style.ts b/packages/ui/src/features/data-points/settings/data-points-settings.style.ts
index 9874c383..ae1ff1df 100644
--- a/packages/ui/src/features/data-points/settings/data-points-settings.style.ts
+++ b/packages/ui/src/features/data-points/settings/data-points-settings.style.ts
@@ -23,7 +23,6 @@ export const useStyles = makeStyles()(theme => ({
   functionContainer: {
     background: 'coral',
   },
-  functionVariablesContainer: {},
   playgroundContainer: {
     background: 'yellowgreen',
   },
diff --git a/packages/ui/src/features/data-points/settings/data-points-settings.test.tsx b/packages/ui/src/features/data-points/settings/data-points-settings.test.tsx
new file mode 100644
index 00000000..5fa7e374
--- /dev/null
+++ b/packages/ui/src/features/data-points/settings/data-points-settings.test.tsx
@@ -0,0 +1,80 @@
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import { render, screen, cleanup, fireEvent } from '@testing-library/react'
+import { DataPointsSettings } from './data-points-settings'
+
+// Minimal experiment-context mock exposing quality + cost score variables and a
+// spy dispatch.
+const dispatch = vi.fn()
+vi.mock('@boostv/process-optimizer-frontend-core', async importOriginal => {
+  const actual = await importOriginal>()
+  return {
+    ...actual,
+    useExperiment: () => ({
+      state: {
+        experiment: {
+          valueVariables: [
+            {
+              type: 'discrete',
+              name: 'Firing angle',
+              description: '',
+              min: 90,
+              max: 140,
+              enabled: true,
+            },
+          ],
+          categoricalVariables: [],
+          scoreVariables: [
+            {
+              name: 'quality',
+              label: 'Quality (0-5)',
+              description: '',
+              enabled: true,
+            },
+            { name: 'cost', label: 'Cost', description: '', enabled: true },
+          ],
+        },
+      },
+      dispatch,
+    }),
+    useSelector: () => true, // isMultiObjective
+  }
+})
+
+afterEach(() => {
+  cleanup()
+  dispatch.mockClear()
+})
+
+describe('DataPointsSettings', () => {
+  it('renders a tab per objective and a factor button', () => {
+    render(
+       {}}
+        onSave={() => {}}
+      />
+    )
+    expect(screen.getByRole('tab', { name: /quality/i })).toBeInTheDocument()
+    expect(screen.getByRole('tab', { name: /cost/i })).toBeInTheDocument()
+    expect(
+      screen.getByRole('button', { name: /firing angle/i })
+    ).toBeInTheDocument()
+  })
+
+  it('dispatches updateScoreFunction on save', () => {
+    render(
+       {}}
+        onSave={() => {}}
+      />
+    )
+    fireEvent.change(screen.getByRole('textbox'), {
+      target: { value: 'firingAngle/100' },
+    })
+    fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
+    expect(dispatch).toHaveBeenCalledWith(
+      expect.objectContaining({ type: 'updateScoreFunction' })
+    )
+  })
+})
diff --git a/packages/ui/src/features/data-points/settings/data-points-settings.tsx b/packages/ui/src/features/data-points/settings/data-points-settings.tsx
index 42d8e6ec..1b41bccf 100644
--- a/packages/ui/src/features/data-points/settings/data-points-settings.tsx
+++ b/packages/ui/src/features/data-points/settings/data-points-settings.tsx
@@ -1,16 +1,21 @@
-import { Box, Tooltip, Button } from '@mui/material'
-import Tabs from '@mui/material/Tabs'
-import Tab from '@mui/material/Tab'
+import { Box, Tab, Tabs, Button, Tooltip, IconButton } from '@mui/material'
 import SettingsIcon from '@mui/icons-material/Settings'
-import IconButton from '@mui/material/IconButton'
-import { useState } from 'react'
-import useStyles from './data-points-settings.style'
 import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined'
-import FormInputText from '@ui/common/forms/form-input'
-import { FormProvider, useForm } from 'react-hook-form'
+import { useMemo, useRef, useState } from 'react'
 import { InfoBox } from '@ui/features/core'
 import { parse } from 'mathjs'
-import { FunctionVariables } from '@ui/features/data-points/settings/function-variables'
+import {
+  useExperiment,
+  deriveSymbol,
+  type ScoreFunctionType,
+  type ScoreFunctionVariableType,
+} from '@boostv/process-optimizer-frontend-core'
+import useStyles from './data-points-settings.style'
+import {
+  ScoreFunctionField,
+  type ScoreFunctionFieldHandle,
+} from './score-function-field'
+import { ScoreFunctionPlayground } from './score-function-playground'
 
 type DataPointsSettingsProps = {
   tabs: string[]
@@ -18,33 +23,103 @@ type DataPointsSettingsProps = {
   onSave: () => void
 }
 
-export function DataPointsSettings(props: DataPointsSettingsProps) {
+export function DataPointsSettings({
+  onCancel,
+  onSave,
+}: DataPointsSettingsProps) {
   const { classes } = useStyles()
-  const { tabs, onCancel, onSave } = props
-
-  const methods = useForm<{
-    qualityFunction: string
-    functionVariables: string[]
-    newVariableName: string
-    newVariable: string
-  }>({
-    defaultValues: {
-      qualityFunction: '',
-      functionVariables: [],
-      newVariableName: '',
-      newVariable: '',
-    },
-    mode: 'onSubmit',
-    reValidateMode: 'onSubmit',
-  })
-
-  const [tabIndex, setTabIndex] = useState(0)
-
-  const handleTabChange = (_event: React.SyntheticEvent, newValue: number) =>
-    setTabIndex(newValue)
-
-  const onSubmit = (data: { qualityFunction: string }) => {
-    console.log('submit', data)
+  const {
+    state: { experiment },
+    dispatch,
+  } = useExperiment()
+  const [tabIndex, setTabIndex] = useState(0)
+
+  const enabledScores = useMemo(
+    () => experiment.scoreVariables.filter(sv => sv.enabled),
+    [experiment.scoreVariables]
+  )
+  const activeScore = enabledScores[tabIndex]
+
+  // Draft function per objective, seeded from the stored one.
+  const [drafts, setDrafts] = useState>(() =>
+    Object.fromEntries(
+      enabledScores.map(sv => [
+        sv.name,
+        sv.scoreFunction ?? { expression: '', variables: [] },
+      ])
+    )
+  )
+  const draft = activeScore ? drafts[activeScore.name] : undefined
+  const fieldRef = useRef(null)
+  const [addingResponse, setAddingResponse] = useState(false)
+  const [newResponseName, setNewResponseName] = useState('')
+
+  const numericFactors = experiment.valueVariables.filter(v => v.enabled)
+  const responseVars =
+    draft?.variables.filter(v => v.source === 'response') ?? []
+
+  const setDraft = (next: ScoreFunctionType) =>
+    activeScore && setDrafts(prev => ({ ...prev, [activeScore.name]: next }))
+
+  const symbols = draft?.variables.map(v => v.symbol) ?? []
+
+  const registerAndInsert = (variable: ScoreFunctionVariableType) => {
+    if (!draft) return
+    const exists = draft.variables.find(v => v.symbol === variable.symbol)
+    if (!exists)
+      setDraft({ ...draft, variables: [...draft.variables, variable] })
+    fieldRef.current?.insertAtCursor(variable.symbol)
+  }
+
+  const onClickFactor = (factorName: string) => {
+    const existingSymbols = draft?.variables.map(v => v.symbol) ?? []
+    const symbol = deriveSymbol(factorName, existingSymbols)
+    const already = draft?.variables.find(
+      v => v.source === 'factor' && v.factorName === factorName
+    )
+    registerAndInsert(
+      already ?? { name: factorName, symbol, source: 'factor', factorName }
+    )
+  }
+
+  const onAddResponse = () => {
+    if (!draft || newResponseName.trim() === '') return
+    const symbol = deriveSymbol(
+      newResponseName,
+      draft.variables.map(v => v.symbol)
+    )
+    setDraft({
+      ...draft,
+      variables: [
+        ...draft.variables,
+        { name: newResponseName, symbol, source: 'response' },
+      ],
+    })
+    setNewResponseName('')
+    setAddingResponse(false)
+  }
+
+  const expressionError = (() => {
+    if (!draft || draft.expression.trim() === '') return undefined
+    try {
+      parse(draft.expression)
+      return undefined
+    } catch (e) {
+      return `${e}`
+    }
+  })()
+
+  const onSaveClick = () => {
+    enabledScores.forEach(sv => {
+      const d = drafts[sv.name]
+      dispatch({
+        type: 'updateScoreFunction',
+        payload: {
+          scoreName: sv.name,
+          scoreFunction: d && d.expression.trim() !== '' ? d : undefined,
+        },
+      })
+    })
     onSave()
   }
 
@@ -56,90 +131,134 @@ export function DataPointsSettings(props: DataPointsSettingsProps) {
       
        setTabIndex(v)}
+        aria-label="score functions"
       >
-        {tabs.map((tab, index) => (
-          
+        {enabledScores.map(sv => (
+          
         ))}
       
       
-        {tabs.map((_, index) => (
-          
-            <>
-              {tabIndex === index && (
-                
-                  
- - - - - { - try { - parse(expression) - return true - } catch (e) { - return `${e}` - } - }, - }} - /> - - - - - - - - - - - - - Playground - - - - - - - - -
-
+ + + {activeScore?.name === 'quality' && ( + + )} + + + + + + + + + draft && setDraft({ ...draft, expression: next }) + } + /> + + + {expressionError && ( + + )} + + + {numericFactors.map(f => ( + + ))} + + + {responseVars.map(v => ( + + ))} + {!addingResponse && ( + )} - + {addingResponse && ( + + setNewResponseName(e.target.value)} + /> + + + + )} + - ))} + + + Playground + + + + + + + + +
) diff --git a/packages/ui/src/features/data-points/settings/function-variables-table.tsx b/packages/ui/src/features/data-points/settings/function-variables-table.tsx deleted file mode 100644 index 1ed28397..00000000 --- a/packages/ui/src/features/data-points/settings/function-variables-table.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { - IconButton, - Table, - TableBody, - TableCell, - TableHead, - TableRow, -} from '@mui/material' -import { Delete } from '@mui/icons-material' - -export function FunctionVariablesTable() { - return ( - - - - - Weight - Viscosity - - - - - Variable - - x1 - {}}> - - - - - x2 - {}}> - - - - - -
- ) -} diff --git a/packages/ui/src/features/data-points/settings/function-variables.style.ts b/packages/ui/src/features/data-points/settings/function-variables.style.ts deleted file mode 100644 index ac6dad78..00000000 --- a/packages/ui/src/features/data-points/settings/function-variables.style.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { colors } from '@mui/material' -import { makeStyles } from 'tss-react/mui' - -export const useStyles = makeStyles()(theme => ({ - contentContainer: { - display: 'flex', - }, - newVariableContainer: { - backgroundColor: colors.grey[300], - padding: theme.spacing(1), - borderRadius: '4px', - width: 180, - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(0.5), - marginTop: theme.spacing(1), - marginBottom: theme.spacing(1), - }, -})) - -export default useStyles diff --git a/packages/ui/src/features/data-points/settings/function-variables.tsx b/packages/ui/src/features/data-points/settings/function-variables.tsx deleted file mode 100644 index 5c9abe74..00000000 --- a/packages/ui/src/features/data-points/settings/function-variables.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Box, Button } from '@mui/material' -import FormInputText from '@ui/common/forms/form-input' -import { useState } from 'react' -import { useFormContext } from 'react-hook-form' -import useStyles from '@ui/features/data-points/settings/function-variables.style' -import { FunctionVariablesTable } from '@ui/features/data-points/settings/function-variables-table' - -type FunctionVariableProps = { - variables: string[] -} - -export function FunctionVariables(props: FunctionVariableProps) { - const { classes } = useStyles() - const { control, formState } = useFormContext() - - const { variables } = props - console.log('variables', variables) - - const [isAddingNew, setAddingNew] = useState(false) - - // const { handleSubmit, control } = useForm<{ name: string }>({ - // defaultValues: { name: '' }, - // mode: 'onSubmit', - // reValidateMode: 'onSubmit', - // }) - - return ( - - Function variables - - - - - - {!isAddingNew && ( - - )} - {isAddingNew && ( - - - - - - - )} - - - - ) -} diff --git a/packages/ui/src/features/data-points/settings/playground.tsx b/packages/ui/src/features/data-points/settings/playground.tsx deleted file mode 100644 index 4f16ac00..00000000 --- a/packages/ui/src/features/data-points/settings/playground.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Box } from '@mui/material' -import { InfoBox } from '@ui/features/core' - -export function Playground() { - return ( - <> - Playground - - - ) -} From bfdadcbb51b3682000e85438261462247bad6c06 Mon Sep 17 00:00:00 2001 From: Jack Ord Date: Thu, 23 Jul 2026 06:27:56 +0000 Subject: [PATCH 12/60] feat(ui): surface per-objective score-function state on table rows --- .../src/features/core/editable-table/types.ts | 7 +++ .../data-points/useDataPoints.test.ts | 55 +++++++++++++++++++ .../src/features/data-points/useDataPoints.ts | 50 ++++++++++++++++- 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/core/editable-table/types.ts b/packages/ui/src/features/core/editable-table/types.ts index 1d01b567..3d7a46c1 100644 --- a/packages/ui/src/features/core/editable-table/types.ts +++ b/packages/ui/src/features/core/editable-table/types.ts @@ -15,4 +15,11 @@ export type TableDataRow = { enabled?: boolean valid?: boolean metaId?: number + scoreFunctions?: { + scoreName: string + hasFunction: boolean + useFunction: boolean + responseVars: { symbol: string; name: string }[] + values: Record + }[] } diff --git a/packages/ui/src/features/data-points/useDataPoints.test.ts b/packages/ui/src/features/data-points/useDataPoints.test.ts index ee02711c..f495005b 100644 --- a/packages/ui/src/features/data-points/useDataPoints.test.ts +++ b/packages/ui/src/features/data-points/useDataPoints.test.ts @@ -48,4 +48,59 @@ describe('useDataPoints', () => { expect(deleteResult).toEqual(expected) }) }) + + describe('score function state', () => { + it('exposes per-objective score function state on rows', () => { + const { result } = renderHook(() => + useDataPoints( + [ + { + type: 'discrete', + name: 'F', + description: '', + min: 0, + max: 10, + enabled: true, + }, + ], + [], + [ + { + name: 'quality', + label: 'Quality (0-5)', + description: '', + enabled: true, + scoreFunction: { + expression: 'weight*2', + variables: [ + { name: 'Weight', symbol: 'weight', source: 'response' }, + ], + }, + }, + ], + [ + { + meta: { id: 1, enabled: true, valid: true }, + data: [ + { type: 'numeric', name: 'F', value: 3 }, + { type: 'score', name: 'quality', value: 4 }, + ], + responses: [ + { + scoreName: 'quality', + useFunction: true, + values: [{ symbol: 'weight', value: 2 }], + }, + ], + }, + ] + ) + ) + const sf = result.current.state.rows[0]?.scoreFunctions?.find( + s => s.scoreName === 'quality' + ) + expect(sf?.useFunction).toBe(true) + expect(sf?.values.weight).toBe('2') + }) + }) }) diff --git a/packages/ui/src/features/data-points/useDataPoints.ts b/packages/ui/src/features/data-points/useDataPoints.ts index e6f5fdd4..7effa294 100644 --- a/packages/ui/src/features/data-points/useDataPoints.ts +++ b/packages/ui/src/features/data-points/useDataPoints.ts @@ -32,12 +32,19 @@ export const useDataPoints = ( valueVariables, categoricalVariables, scoreNames, + scoreVariables, dataPoints ), meta: dataPoints.map(dp => dp.meta), }) as const, - [categoricalVariables, dataPoints, scoreNames, valueVariables] + [ + categoricalVariables, + dataPoints, + scoreNames, + scoreVariables, + valueVariables, + ] ) const addRow = useCallback( @@ -247,10 +254,34 @@ const mapDataPointToTableType = ( dpType: CombinedVariableInputType ): TableDataPointType => (dpType === 'options' ? 'options' : 'numeric') +const buildScoreFunctions = ( + scoreVariables: ScoreVariableType[], + entry: DataEntry +) => + scoreVariables + .filter(sv => sv.enabled) + .map(sv => { + const resp = entry.responses?.find(r => r.scoreName === sv.name) + const responseVars = + sv.scoreFunction?.variables + .filter(v => v.source === 'response') + .map(v => ({ symbol: v.symbol, name: v.name })) ?? [] + return { + scoreName: sv.name, + hasFunction: sv.scoreFunction !== undefined, + useFunction: resp?.useFunction ?? sv.scoreFunction !== undefined, + responseVars, + values: Object.fromEntries( + (resp?.values ?? []).map(v => [v.symbol, String(v.value)]) + ), + } + }) + const buildEmptyRow = ( valueVariables: ValueVariableType[], categoricalVariables: CategoricalVariableType[], - scoreNames: { name: string; label?: string }[] + scoreNames: { name: string; label?: string }[], + scoreVariables: ScoreVariableType[] ) => { return { dataPoints: buildCombinedVariables(valueVariables, categoricalVariables) @@ -272,6 +303,10 @@ const buildEmptyRow = ( })) ), isNew: true, + scoreFunctions: buildScoreFunctions(scoreVariables, { + meta: { id: 0, enabled: true, valid: true }, + data: [], + }), } satisfies TableDataRow } @@ -286,6 +321,7 @@ const buildRows = ( valueVariables: ValueVariableType[], categoricalVariables: CategoricalVariableType[], scoreNames: { name: string; label?: string }[], + scoreVariables: ScoreVariableType[], dataPoints: DataEntry[] ) => { const combinedVariables = buildCombinedVariables( @@ -345,11 +381,19 @@ const buildRows = ( enabled: item.meta.enabled, valid: item.meta.valid, metaId: item.meta.id, + scoreFunctions: buildScoreFunctions(scoreVariables, item), // Uncomment the following line to display a meta data property in the table // .concat([{ name: 'id', value: `${item.meta.id}` }]), } satisfies TableDataRow as TableDataRow }) - .concat([buildEmptyRow(valueVariables, categoricalVariables, scoreNames)]) + .concat([ + buildEmptyRow( + valueVariables, + categoricalVariables, + scoreNames, + scoreVariables + ), + ]) return dataPointRows } From 82294010d9bd7302f5b36d32778c793174f0eefa Mon Sep 17 00:00:00 2001 From: Jack Ord Date: Thu, 23 Jul 2026 06:39:32 +0000 Subject: [PATCH 13/60] feat(ui): f(x) toggle, manual-value person icon, and RESPONSE inputs in data table Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ui/src/common/forms/rating-input.tsx | 11 +- .../editable-table/editable-table-cell.tsx | 47 ++++++- .../editable-table-collapsed-row.tsx | 36 ++++-- .../editable-table-expanded-row.test.tsx | 75 +++++++++++ .../editable-table-expanded-row.tsx | 116 +++++++++++++++--- .../src/features/data-points/data-points.tsx | 48 ++++++-- 6 files changed, 294 insertions(+), 39 deletions(-) create mode 100644 packages/ui/src/features/core/editable-table/editable-table-expanded-row.test.tsx diff --git a/packages/ui/src/common/forms/rating-input.tsx b/packages/ui/src/common/forms/rating-input.tsx index 8aeff95f..60853dcf 100644 --- a/packages/ui/src/common/forms/rating-input.tsx +++ b/packages/ui/src/common/forms/rating-input.tsx @@ -16,9 +16,14 @@ import { StarRating } from './star-rating' type RatingInputProps = { value: string | undefined onChange: (val: string) => void + readOnly?: boolean } -export const RatingInput = ({ value, onChange }: RatingInputProps) => { +export const RatingInput = ({ + value, + onChange, + readOnly = false, +}: RatingInputProps) => { const [anchorEl, setAnchorEl] = useState(null) const [tempRating, setTempRating] = useState(undefined) @@ -52,17 +57,19 @@ export const RatingInput = ({ value, onChange }: RatingInputProps) => { endAdornment: ( ) => setAnchorEl(e.currentTarget) } > - + ), }, htmlInput: { + readOnly, sx: { minWidth: 24, }, diff --git a/packages/ui/src/features/core/editable-table/editable-table-cell.tsx b/packages/ui/src/features/core/editable-table/editable-table-cell.tsx index 110c9647..ef81ab37 100644 --- a/packages/ui/src/features/core/editable-table/editable-table-cell.tsx +++ b/packages/ui/src/features/core/editable-table/editable-table-cell.tsx @@ -1,5 +1,7 @@ import { + Box, FormControl, + IconButton, MenuItem, Select, SelectChangeEvent, @@ -7,6 +9,7 @@ import { TextField, Tooltip, } from '@mui/material' +import PersonIcon from '@mui/icons-material/Person' import { ChangeEvent, CSSProperties } from 'react' import useStyles from './editable-table-cell.style' import { RatingInput } from '@ui/common' @@ -20,6 +23,9 @@ type EditableTableCellProps = { onChange?: (value: string) => void tooltip?: string style?: CSSProperties + scoreName?: string + scoreFunction?: { hasFunction: boolean; useFunction: boolean } + onToggleUseFunction?: () => void } export function EditableTableCell({ @@ -30,12 +36,21 @@ export function EditableTableCell({ onChange, tooltip, style, + scoreName, + scoreFunction, + onToggleUseFunction, }: EditableTableCellProps) { const { classes } = useStyles() + const readOnly = scoreFunction?.useFunction ?? false + const textField = type === 'rating' ? ( - onChange?.(val)} /> + onChange?.(val)} + readOnly={readOnly} + /> ) : ( ) => onChange?.('' + e.target.value) } + slotProps={{ htmlInput: { readOnly } }} /> ) + const fxToggle = scoreFunction?.hasFunction ? ( + + onToggleUseFunction?.()} + > + + f(x) + + + + ) : null + // Value is undefined when new categorical variable is added to existing dataPoints const categoricalValue = value === undefined ? '' : value @@ -71,7 +102,7 @@ export function EditableTableCell({ ) : ( - <> + {tooltip !== undefined ? ( {textField} @@ -79,12 +110,20 @@ export function EditableTableCell({ ) : ( <>{textField} )} - + {fxToggle} + )} ) : ( - {value} + + {value} + {scoreFunction?.hasFunction && !scoreFunction.useFunction && ( + + + + )} + )} diff --git a/packages/ui/src/features/core/editable-table/editable-table-collapsed-row.tsx b/packages/ui/src/features/core/editable-table/editable-table-collapsed-row.tsx index 78048a48..a1d6c956 100644 --- a/packages/ui/src/features/core/editable-table/editable-table-collapsed-row.tsx +++ b/packages/ui/src/features/core/editable-table/editable-table-collapsed-row.tsx @@ -88,17 +88,31 @@ export const EditableTableCollapsedRow = ({ > {rowId} - {tableRow.dataPoints.map((item, itemIndex) => ( - - ))} + {tableRow.dataPoints.map((item, itemIndex) => { + const scoreFunction = tableRow.scoreFunctions?.find( + sf => sf.scoreName === item.name && sf.hasFunction + ) + return ( + + ) + })}
diff --git a/packages/ui/src/features/core/editable-table/editable-table-expanded-row.test.tsx b/packages/ui/src/features/core/editable-table/editable-table-expanded-row.test.tsx new file mode 100644 index 00000000..adfc5653 --- /dev/null +++ b/packages/ui/src/features/core/editable-table/editable-table-expanded-row.test.tsx @@ -0,0 +1,75 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { render, screen, cleanup } from '@testing-library/react' +import { EditableTableExpandedRow } from './editable-table-expanded-row' +import type { TableDataRow } from './types' + +afterEach(() => cleanup()) + +const row: TableDataRow = { + isNew: false, + metaId: 2, + enabled: true, + valid: true, + dataPoints: [ + { name: 'quality', label: 'Quality (0-5)', value: '2.5', type: 'rating' }, + ], + scoreFunctions: [ + { + scoreName: 'quality', + hasFunction: true, + useFunction: true, + responseVars: [ + { symbol: 'weight', name: 'Weight' }, + { symbol: 'viscosity', name: 'Viscosity' }, + ], + values: { weight: '150' }, + }, + ], +} + +describe('EditableTableExpandedRow RESPONSE section', () => { + it('renders response inputs for a function objective', () => { + render( + + + {}} + onAdd={() => {}} + onSave={() => {}} + /> + +
+ ) + expect(screen.getByText('Response')).toBeInTheDocument() + expect(screen.getByLabelText('Weight')).toHaveValue(150) + expect(screen.getByLabelText('Viscosity')).toBeInTheDocument() + }) + + it('shows the missing-response violation via InfoBox', () => { + render( + + + {}} + onAdd={() => {}} + onSave={() => {}} + violations={[ + 'All responses must be defined to use the quality function.', + ]} + /> + +
+ ) + expect( + screen.getByText( + 'All responses must be defined to use the quality function.' + ) + ).toBeInTheDocument() + }) +}) diff --git a/packages/ui/src/features/core/editable-table/editable-table-expanded-row.tsx b/packages/ui/src/features/core/editable-table/editable-table-expanded-row.tsx index 36776167..3ba469bd 100644 --- a/packages/ui/src/features/core/editable-table/editable-table-expanded-row.tsx +++ b/packages/ui/src/features/core/editable-table/editable-table-expanded-row.tsx @@ -10,6 +10,7 @@ import { TableCell, TableHead, TableRow, + TextField, } from '@mui/material' import { TableDataRow } from './types' @@ -61,6 +62,32 @@ export const EditableTableExpandedRow = ({ }) } + const handleResponseEdit = ( + scoreName: string, + symbol: string, + value: string + ) => { + setEditedRow({ + ...editedRow, + scoreFunctions: editedRow.scoreFunctions?.map(sf => + sf.scoreName === scoreName + ? { ...sf, values: { ...sf.values, [symbol]: value } } + : sf + ), + }) + } + + const handleToggleUseFunction = (scoreName: string) => { + setEditedRow({ + ...editedRow, + scoreFunctions: editedRow.scoreFunctions?.map(sf => + sf.scoreName === scoreName + ? { ...sf, useFunction: !sf.useFunction } + : sf + ), + }) + } + return ( {/* colSpan already equals the table's full column count; adding more would @@ -105,26 +132,85 @@ export const EditableTableExpandedRow = ({ ))} - {editedRow.dataPoints.map((d, i) => ( - handleEdit(i, value)} - options={d.options} - style={{ - fontSize: 14, - border: 'none', - }} - /> - ))} + {editedRow.dataPoints.map((d, i) => { + const scoreFunction = editedRow.scoreFunctions?.find( + sf => sf.scoreName === d.name && sf.hasFunction + ) + return ( + handleEdit(i, value)} + options={d.options} + scoreName={scoreFunction ? d.name : undefined} + scoreFunction={ + scoreFunction + ? { + hasFunction: scoreFunction.hasFunction, + useFunction: scoreFunction.useFunction, + } + : undefined + } + onToggleUseFunction={ + scoreFunction + ? () => handleToggleUseFunction(d.name) + : undefined + } + style={{ + fontSize: 14, + border: 'none', + }} + /> + ) + })} + {editedRow.scoreFunctions?.some(sf => sf.hasFunction) && ( + + Response + {editedRow.scoreFunctions + .filter(sf => sf.hasFunction) + .map(sf => ( + + + {sf.scoreName} + + {sf.responseVars.map(rv => ( + + handleResponseEdit( + sf.scoreName, + rv.symbol, + e.target.value + ) + } + /> + ))} + + ))} + + )} + {violations !== undefined && violations.length > 0 && violations.map((v, i) => ( @@ -142,7 +228,7 @@ export const EditableTableExpandedRow = ({ variant="outlined" size="small" style={{ float: 'right', marginLeft: 8 }} - disabled={!isModified} + disabled={!isModified || (violations?.length ?? 0) > 0} onClick={() => { if (tableRow.isNew) { onAdd(editedRow) diff --git a/packages/ui/src/features/data-points/data-points.tsx b/packages/ui/src/features/data-points/data-points.tsx index 981e5652..58f3adb6 100644 --- a/packages/ui/src/features/data-points/data-points.tsx +++ b/packages/ui/src/features/data-points/data-points.tsx @@ -17,6 +17,8 @@ import { ScoreVariableType, ValueVariableType, EditableTableViolation, + isValidScoreName, + useExperiment, } from '@boostv/process-optimizer-frontend-core' import { useDataPoints } from './useDataPoints' import { DataPointsSettings } from '@ui/features/data-points/settings/data-points-settings' @@ -53,6 +55,7 @@ export function DataPoints(props: DataPointProps) { onUpdateDataPoints, } = props const { classes } = useStyles() + const { dispatch } = useExperiment() const [isSettingsOpen, setSettingsOpen] = useState(false) const enabledValueVariables = valueVariables.filter(v => v.enabled) @@ -68,13 +71,40 @@ export function DataPoints(props: DataPointProps) { const isLoadingState = state.rows.length === 0 - const rowAdded = (row: TableDataRow) => - onUpdateDataPoints( - addRow({ - ...row, - dataPoints: row.dataPoints.filter(dp => dp.value !== undefined), + // The metaId of a freshly added row is assigned inside `addRow` (max id + 1, + // pushed to the end of the returned entries), so read it back from there. + const dispatchResponses = (row: TableDataRow, metaId: number) => + row.scoreFunctions?.forEach(sf => { + if (!sf.hasFunction || !isValidScoreName(sf.scoreName)) { + return + } + dispatch({ + type: 'updateDataPointResponses', + payload: { + metaId, + scoreName: sf.scoreName, + useFunction: sf.useFunction, + values: sf.responseVars + .map(rv => ({ + symbol: rv.symbol, + value: Number(sf.values[rv.symbol]), + })) + .filter(v => Number.isFinite(v.value)), + }, }) - ) + }) + + const rowAdded = (row: TableDataRow) => { + const updated = addRow({ + ...row, + dataPoints: row.dataPoints.filter(dp => dp.value !== undefined), + }) + onUpdateDataPoints(updated) + const newId = updated[updated.length - 1]?.meta.id + if (newId !== undefined) { + dispatchResponses(row, newId) + } + } const rowsDeleted = (rowIndices: number[]) => onUpdateDataPoints(deleteRows(rowIndices)) @@ -82,8 +112,12 @@ export function DataPoints(props: DataPointProps) { const rowEnabledToggled = (rowIndex: number, enabled: boolean) => onUpdateDataPoints(setEnabledState(rowIndex, enabled)) - const rowEdited = (rowIndex: number, row: TableDataRow) => + const rowEdited = (rowIndex: number, row: TableDataRow) => { onUpdateDataPoints(editRow(rowIndex, row)) + if (row.metaId !== undefined) { + dispatchResponses(row, row.metaId) + } + } return ( Date: Thu, 23 Jul 2026 07:22:05 +0000 Subject: [PATCH 14/60] =?UTF-8?q?fix(ui):=20address=20final=20review=20?= =?UTF-8?q?=E2=80=94=20drop=20debug=20styles/dead=20prop,=20default=20rows?= =?UTF-8?q?=20to=20manual,=20harden=20response/expression=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/features/data-points/data-points.tsx | 2 +- .../settings/data-points-settings.style.ts | 8 +--- .../settings/data-points-settings.test.tsx | 27 +++++------ .../settings/data-points-settings.tsx | 8 +++- .../data-points/useDataPoints.test.ts | 47 +++++++++++++++++++ .../src/features/data-points/useDataPoints.ts | 2 +- 6 files changed, 70 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/features/data-points/data-points.tsx b/packages/ui/src/features/data-points/data-points.tsx index 58f3adb6..5bffeb00 100644 --- a/packages/ui/src/features/data-points/data-points.tsx +++ b/packages/ui/src/features/data-points/data-points.tsx @@ -85,6 +85,7 @@ export function DataPoints(props: DataPointProps) { scoreName: sf.scoreName, useFunction: sf.useFunction, values: sf.responseVars + .filter(rv => (sf.values[rv.symbol] ?? '').trim() !== '') .map(rv => ({ symbol: rv.symbol, value: Number(sf.values[rv.symbol]), @@ -187,7 +188,6 @@ export function DataPoints(props: DataPointProps) { <> {isSettingsOpen && ( setSettingsOpen(false)} onSave={() => {}} /> diff --git a/packages/ui/src/features/data-points/settings/data-points-settings.style.ts b/packages/ui/src/features/data-points/settings/data-points-settings.style.ts index ae1ff1df..fc97b464 100644 --- a/packages/ui/src/features/data-points/settings/data-points-settings.style.ts +++ b/packages/ui/src/features/data-points/settings/data-points-settings.style.ts @@ -20,12 +20,8 @@ export const useStyles = makeStyles()(theme => ({ gap: theme.spacing(1), flexDirection: 'row', }, - functionContainer: { - background: 'coral', - }, - playgroundContainer: { - background: 'yellowgreen', - }, + functionContainer: {}, + playgroundContainer: {}, function: { display: 'flex', alignItems: 'center', diff --git a/packages/ui/src/features/data-points/settings/data-points-settings.test.tsx b/packages/ui/src/features/data-points/settings/data-points-settings.test.tsx index 5fa7e374..9714df93 100644 --- a/packages/ui/src/features/data-points/settings/data-points-settings.test.tsx +++ b/packages/ui/src/features/data-points/settings/data-points-settings.test.tsx @@ -47,13 +47,7 @@ afterEach(() => { describe('DataPointsSettings', () => { it('renders a tab per objective and a factor button', () => { - render( - {}} - onSave={() => {}} - /> - ) + render( {}} onSave={() => {}} />) expect(screen.getByRole('tab', { name: /quality/i })).toBeInTheDocument() expect(screen.getByRole('tab', { name: /cost/i })).toBeInTheDocument() expect( @@ -62,13 +56,7 @@ describe('DataPointsSettings', () => { }) it('dispatches updateScoreFunction on save', () => { - render( - {}} - onSave={() => {}} - /> - ) + render( {}} onSave={() => {}} />) fireEvent.change(screen.getByRole('textbox'), { target: { value: 'firingAngle/100' }, }) @@ -77,4 +65,15 @@ describe('DataPointsSettings', () => { expect.objectContaining({ type: 'updateScoreFunction' }) ) }) + + it('disables Save when the active expression fails to parse', () => { + render( {}} onSave={() => {}} />) + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'firingAngle/' }, + }) + const saveButton = screen.getByRole('button', { name: /^save$/i }) + expect(saveButton).toBeDisabled() + fireEvent.click(saveButton) + expect(dispatch).not.toHaveBeenCalled() + }) }) diff --git a/packages/ui/src/features/data-points/settings/data-points-settings.tsx b/packages/ui/src/features/data-points/settings/data-points-settings.tsx index 1b41bccf..cbcd25b6 100644 --- a/packages/ui/src/features/data-points/settings/data-points-settings.tsx +++ b/packages/ui/src/features/data-points/settings/data-points-settings.tsx @@ -18,7 +18,6 @@ import { import { ScoreFunctionPlayground } from './score-function-playground' type DataPointsSettingsProps = { - tabs: string[] onCancel: () => void onSave: () => void } @@ -252,7 +251,12 @@ export function DataPointsSettings({ -