diff --git a/.changeset/brave-mangos-bake.md b/.changeset/brave-mangos-bake.md new file mode 100644 index 00000000..249cd3b5 --- /dev/null +++ b/.changeset/brave-mangos-bake.md @@ -0,0 +1,6 @@ +--- +'@boostv/process-optimizer-frontend-core': minor +'@boostv/process-optimizer-frontend-ui': minor +--- + +Add per-point notes and robust CSV round-trips (quoting, configurable separator, import errors). diff --git a/packages/core/src/common/types/common.test.ts b/packages/core/src/common/types/common.test.ts index c79febcd..70cf9763 100644 --- a/packages/core/src/common/types/common.test.ts +++ b/packages/core/src/common/types/common.test.ts @@ -40,9 +40,9 @@ describe('Type guards', () => { }) }) -describe('score function schema (v21)', () => { - it('currentVersion is 21', () => { - expect(currentVersion).toBe('21') +describe('score function schema (v22)', () => { + it('currentVersion is 22', () => { + expect(currentVersion).toBe('22') }) it('accepts a scoreVariable with a scoreFunction and a dataEntry with responses', () => { @@ -53,7 +53,7 @@ describe('score function schema (v21)', () => { name: 'n', description: '', swVersion: 'v', - dataFormatVersion: '21', + dataFormatVersion: '22', version: 0, lastModified: '', createdAt: '', @@ -123,7 +123,7 @@ describe('score function schema (v21)', () => { name: 'n', description: '', swVersion: 'v', - dataFormatVersion: '21', + dataFormatVersion: '22', version: 0, lastModified: '', createdAt: '', @@ -166,3 +166,30 @@ describe('score function schema (v21)', () => { expect(parsed.success).toBe(true) }) }) + +describe('data point note (v22)', () => { + it('accepts a dataEntry with a note in meta', () => { + const withNote = { + ...emptyExperiment, + dataPoints: [ + { + meta: { id: 1, enabled: true, valid: true, note: 'measured twice' }, + data: [], + }, + ], + } + const parsed = experimentSchema.safeParse(withNote) + expect(parsed.success).toBe(true) + if (parsed.success) { + expect(parsed.data.dataPoints[0]?.meta.note).toBe('measured twice') + } + }) + + it('still accepts a dataEntry without a note', () => { + const withoutNote = { + ...emptyExperiment, + dataPoints: [{ meta: { id: 1, enabled: true, valid: true }, data: [] }], + } + expect(experimentSchema.safeParse(withoutNote).success).toBe(true) + }) +}) diff --git a/packages/core/src/common/types/common.ts b/packages/core/src/common/types/common.ts index 9393c8d9..34bf765e 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 = '21' +export const currentVersion = '22' export const scoreNames = ['quality', 'cost'] as const // Label is shown in UI, name is used in data @@ -98,6 +98,7 @@ const dataEntryMetaDataSchema = z.object({ enabled: z.coerce.boolean().prefault(true), valid: z.coerce.boolean().prefault(true), description: z.optional(z.string()), + note: z.optional(z.string()), }) const numericDataPoint = z.object({ diff --git a/packages/core/src/common/util/converters/converters.test.ts b/packages/core/src/common/util/converters/converters.test.ts index 73be4877..f5100b84 100644 --- a/packages/core/src/common/util/converters/converters.test.ts +++ b/packages/core/src/common/util/converters/converters.test.ts @@ -380,6 +380,17 @@ describe('converters', () => { expect(actual).toEqual(expected) }) + it('quotes meta values that contain the separator or a quote', () => { + const csv = dataPointsToCSV([ + { + meta: { id: 1, enabled: true, valid: true, note: 'a;b "c"' }, + data: [{ type: 'numeric', name: 'A', value: 1 }], + }, + ]) + // the note field is quoted, inner quotes doubled; other fields untouched + expect(csv).toBe('id;A;enabled;valid;note\n1;1;true;true;"a;b ""c"""') + }) + it('should convert known value', () => { const input: DataEntry[] = [ { @@ -872,4 +883,116 @@ describe('converters', () => { expect(actual[1]?.meta.description).toEqual('I am also a description') }) }) + + describe('note round-trip', () => { + const valueVars = [ + { + type: 'continuous' as const, + name: 'A', + description: '', + min: 0, + max: 10, + enabled: true, + }, + ] + + it('writes the note as a meta column in CSV', () => { + const csv = dataPointsToCSV([ + { + meta: { id: 1, enabled: true, valid: true, note: 'seemed fine' }, + data: [{ type: 'numeric', name: 'A', value: 1 }], + }, + ]) + expect(csv).toContain('note') + expect(csv).toContain('seemed fine') + }) + + it('parses the note back from CSV', () => { + const csv = 'id;A;enabled;valid;note\n1;1;true;true;seemed fine' + const actual = csvToDataPoints(csv, valueVars, [], []) + expect(actual[0]?.meta.note).toBe('seemed fine') + }) + + it('round-trips a note through export and import', () => { + const input = [ + { + meta: { id: 1, enabled: true, valid: true, note: 'seemed fine' }, + data: [{ type: 'numeric' as const, name: 'A', value: 1 }], + }, + ] + const back = csvToDataPoints(dataPointsToCSV(input), valueVars, [], []) + expect(back[0]?.meta.note).toBe('seemed fine') + }) + + it('does not set a note when the CSV note column is empty', () => { + const csv = 'id;A;enabled;valid;note\n1;1;true;true;' + const actual = csvToDataPoints(csv, valueVars, [], []) + expect(actual[0]?.meta.note).toBeUndefined() + }) + + it('keeps the note aligned when rows have different optional meta keys', () => { + // Row 1 has a note but no description; row 2 has a description but no + // note. The CSV header is the union of meta keys, so meta values must be + // emitted by header order — otherwise row 2's description would land in + // the note column and be read back as a note. + const input = [ + { + meta: { id: 1, enabled: true, valid: true, note: 'has note' }, + data: [{ type: 'numeric' as const, name: 'A', value: 1 }], + }, + { + meta: { id: 2, enabled: true, valid: true, description: 'has desc' }, + data: [{ type: 'numeric' as const, name: 'A', value: 2 }], + }, + ] + const back = csvToDataPoints(dataPointsToCSV(input), valueVars, [], []) + expect(back[0]?.meta.note).toBe('has note') + expect(back[1]?.meta.note).toBeUndefined() + }) + + it('round-trips a note containing the separator and quotes', () => { + const note = 'I pressed; it broke "hard"' + const back = csvToDataPoints( + dataPointsToCSV([ + { + meta: { id: 1, enabled: true, valid: true, note }, + data: [{ type: 'numeric' as const, name: 'A', value: 1 }], + }, + ]), + valueVars, + [], + [] + ) + expect(back[0]?.meta.note).toBe(note) + }) + }) + + describe.each([';', ',', '\t', '|'])('separator %j round-trip', sep => { + it('round-trips values and notes', () => { + const valueVars = [ + { + type: 'continuous' as const, + name: 'A', + description: '', + min: 0, + max: 10, + enabled: true, + }, + ] + const input = [ + { + meta: { id: 1, enabled: true, valid: true, note: 'x;y,z\tw|q' }, + data: [{ type: 'numeric' as const, name: 'A', value: 1 }], + }, + ] + const back = csvToDataPoints( + dataPointsToCSV(input, sep), + valueVars, + [], + [], + sep + ) + expect(back[0]?.meta.note).toBe('x;y,z\tw|q') + }) + }) }) diff --git a/packages/core/src/common/util/converters/converters.ts b/packages/core/src/common/util/converters/converters.ts index 23484053..faee2dc1 100644 --- a/packages/core/src/common/util/converters/converters.ts +++ b/packages/core/src/common/util/converters/converters.ts @@ -109,6 +109,48 @@ export const calculateConstraints = (experiment: ExperimentType) => .sort(), })) .filter(c => (c.type === 'sum' ? c.dimensions.length > 1 : true)) +// RFC-4180: a field containing the separator, a double-quote, or a newline is +// wrapped in double-quotes with internal quotes doubled. Clean fields are left +// untouched, so exports of ordinary data are byte-identical to before. +const escapeCsvField = (value: string, separator: string): string => + value.includes(separator) || + value.includes('"') || + value.includes('\n') || + value.includes('\r') + ? `"${value.replaceAll('"', '""')}"` + : value + +// Split one CSV line into fields, honoring RFC-4180 quotes: a double-quoted +// field may contain the separator, and "" is an escaped quote. (Embedded +// newlines are out of scope — the app's inputs are single-line.) +const parseCsvLine = (line: string, separator: string): string[] => { + const fields: string[] = [] + let field = '' + let inQuotes = false + for (let i = 0; i < line.length; i++) { + const ch = line[i] + if (inQuotes) { + if (ch === '"' && line[i + 1] === '"') { + field += '"' + i++ + } else if (ch === '"') { + inQuotes = false + } else { + field += ch + } + } else if (ch === '"') { + inQuotes = true + } else if (ch === separator) { + fields.push(field) + field = '' + } else { + field += ch + } + } + fields.push(field) + return fields +} + /** * Converts a list of DataEntry objects into a CSV string. * The output format: @@ -142,7 +184,11 @@ export const dataPointsToCSV = ( ] return dataPoints.length === 0 ? '' - : [['id'].concat(header, meta).join(separator)] + : [ + ['id', ...header, ...meta] + .map(field => escapeCsvField(field, separator)) + .join(separator), + ] // Generate data lines .concat( [...dataPoints] @@ -152,15 +198,21 @@ export const dataPointsToCSV = ( ) return { ...line, data: header.map(h => values.get(h) ?? '') } }) - .map( - line => - `${line.meta.id}${separator}${line.data - .concat( - Object.entries(line.meta as object) - .filter(e => e[0] !== 'id') - .map(e => e[1]) - ) - .join(separator)}` + .map(line => + // Meta values are emitted in the `meta` header (union) order — + // looked up by key, not the row's own key order — so rows with + // different optional meta keys (e.g. note vs description) stay + // column-aligned on re-import. + [ + String(line.meta.id), + ...line.data, + ...meta.map(key => { + const value = (line.meta as Record)[key] + return value === undefined ? '' : String(value) + }), + ] + .map(field => escapeCsvField(field, separator)) + .join(separator) ) ) .filter(s => '' !== s) @@ -220,16 +272,17 @@ export const csvToDataPoints = ( const lines = csv.split(newlinePattern) if ('' === csv || lines.length < 2) return [] else { - const header = lines[0]?.split(separator).map(h => h.trim()) ?? [] + const header = parseCsvLine(lines[0] ?? '', separator).map(h => h.trim()) if ( header.length >= expectedHeader.length && expectedHeader.every(value => header.includes(value)) ) { const data = lines.slice(1) const dataAsKeyValue = data.map(line => - line - .split(separator) - .map((value, idx) => ({ key: header[idx] ?? '', value })) + parseCsvLine(line, separator).map((value, idx) => ({ + key: header[idx] ?? '', + value, + })) ) const dataList = dataAsKeyValue.map((line, idx) => ({ data: line @@ -286,5 +339,8 @@ const convertToMetaData = ( : true, id: 'id' in parsedMeta ? Number(parsedMeta['id'] ?? idx + 1) : idx + 1, } + if (result.note === '' || result.note === undefined) { + delete result.note + } return result } diff --git a/packages/core/src/common/util/migration/data-formats/22.json b/packages/core/src/common/util/migration/data-formats/22.json new file mode 100644 index 00000000..45183dfa --- /dev/null +++ b/packages/core/src/common/util/migration/data-formats/22.json @@ -0,0 +1,105 @@ +{ + "id": "1234", + "changedSinceLastEvaluation": true, + "lastEvaluationHash": "never-calculated", + "info": { + "dataFormatVersion": "22", + "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 231261fe..353895fe 100644 --- a/packages/core/src/common/util/migration/migration.test.ts +++ b/packages/core/src/common/util/migration/migration.test.ts @@ -25,6 +25,7 @@ import { scoreName17 } from './migrations/migrateToV17' import { migrateToV17, migrateToV18 } from './migrations' import { ExperimentTypeV17 } from './migrations/migrateToV18' import { migrateToV21 } from './migrations/migrateToV21' +import { migrateToV22 } from './migrations/migrateToV22' describe('Migration of data format', () => { storeLatestSchema() @@ -398,3 +399,29 @@ describe('migrateToV21', () => { expect(v21.dataPoints).toEqual(v20.dataPoints) }) }) + +describe('migrateToV22', () => { + it('bumps dataFormatVersion to 22 and preserves data', () => { + const v21 = { + info: { dataFormatVersion: '21', 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 v22 = migrateToV22(v21 as never) + expect(v22.info.dataFormatVersion).toBe('22') + expect(v22.scoreVariables).toEqual(v21.scoreVariables) + expect(v22.dataPoints).toEqual(v21.dataPoints) + }) +}) diff --git a/packages/core/src/common/util/migration/migration.ts b/packages/core/src/common/util/migration/migration.ts index 2b8732c8..51d1cb9e 100644 --- a/packages/core/src/common/util/migration/migration.ts +++ b/packages/core/src/common/util/migration/migration.ts @@ -22,6 +22,7 @@ import { migrateToV19, migrateToV20, migrateToV21, + migrateToV22, } from './migrations' export const migrate = (json: any): ExperimentType => { @@ -108,4 +109,5 @@ export const MIGRATIONS: Migration[] = [ { version: '19', converter: migrateToV19 }, { version: '20', converter: migrateToV20 }, { version: '21', converter: migrateToV21 }, + { version: '22', converter: migrateToV22 }, ] diff --git a/packages/core/src/common/util/migration/migrations/index.ts b/packages/core/src/common/util/migration/migrations/index.ts index f5adabdf..3ef01c82 100644 --- a/packages/core/src/common/util/migration/migrations/index.ts +++ b/packages/core/src/common/util/migration/migrations/index.ts @@ -17,3 +17,4 @@ export { migrateToV18 } from './migrateToV18' export { migrateToV19 } from './migrateToV19' export { migrateToV20 } from './migrateToV20' export { migrateToV21 } from './migrateToV21' +export { migrateToV22 } from './migrateToV22' diff --git a/packages/core/src/common/util/migration/migrations/migrateToV22.ts b/packages/core/src/common/util/migration/migrations/migrateToV22.ts new file mode 100644 index 00000000..7550bf1f --- /dev/null +++ b/packages/core/src/common/util/migration/migrations/migrateToV22.ts @@ -0,0 +1,10 @@ +import { ExperimentType } from '@core/common/types' +import { produce } from 'immer' + +// v22 adds an optional per-data-point note (dataEntry.meta.note). It is +// optional, so no data transform is needed — existing experiments validate +// as-is once the version literal is bumped. +export const migrateToV22 = (json: ExperimentType): ExperimentType => + produce(json, (draft: { info: { dataFormatVersion: string } }) => { + draft.info.dataFormatVersion = '22' + }) diff --git a/packages/core/src/common/util/migration/schemas/22.json b/packages/core/src/common/util/migration/schemas/22.json new file mode 100644 index 00000000..5cb43260 --- /dev/null +++ b/packages/core/src/common/util/migration/schemas/22.json @@ -0,0 +1,531 @@ +{ + "$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": "22" + }, + "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" + }, + "note": { + "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 diff --git a/packages/core/src/context/experiment/experiment-reducers.test.ts b/packages/core/src/context/experiment/experiment-reducers.test.ts index 31503671..deeac3c8 100644 --- a/packages/core/src/context/experiment/experiment-reducers.test.ts +++ b/packages/core/src/context/experiment/experiment-reducers.test.ts @@ -344,3 +344,63 @@ describe('score-function factor sync', () => { ).toHaveLength(0) }) }) + +describe('variable name trimming', () => { + it('trims the name when adding a value variable', () => { + const state: State = { experiment: emptyExperiment } + const actual = rootReducer(state, { + type: 'addValueVariable', + payload: { + type: 'continuous', + name: ' Temp ', + description: '', + min: 0, + max: 10, + enabled: true, + }, + }) + expect(actual.experiment.valueVariables.at(-1)?.name).toBe('Temp') + }) + + it('trims on rename and renames matching data points in lockstep', () => { + const state: State = { experiment: emptyExperiment } + const withVar = rootReducer(state, { + type: 'addValueVariable', + payload: { + type: 'continuous', + name: 'Temp', + description: '', + min: 0, + max: 10, + enabled: true, + }, + }) + const withPoint: State = { + experiment: { + ...withVar.experiment, + dataPoints: [ + { + meta: { id: 1, enabled: true, valid: true }, + data: [{ type: 'numeric', name: 'Temp', value: 5 }], + }, + ], + }, + } + const renamed = rootReducer(withPoint, { + type: 'editValueVariable', + payload: { + index: 0, + newVariable: { + type: 'continuous', + name: ' Heat ', + description: '', + min: 0, + max: 10, + enabled: true, + }, + }, + }) + expect(renamed.experiment.valueVariables[0]?.name).toBe('Heat') + expect(renamed.experiment.dataPoints[0]?.data[0]?.name).toBe('Heat') + }) +}) diff --git a/packages/core/src/context/experiment/experiment-reducers.ts b/packages/core/src/context/experiment/experiment-reducers.ts index 1eb87406..011f02ca 100644 --- a/packages/core/src/context/experiment/experiment-reducers.ts +++ b/packages/core/src/context/experiment/experiment-reducers.ts @@ -372,7 +372,10 @@ const experimentReducerInner = produce( state.valueVariables.splice( state.valueVariables.length, 0, - experimentSchema.shape.valueVariables.element.parse(action.payload) + experimentSchema.shape.valueVariables.element.parse({ + ...action.payload, + name: action.payload.name.trim(), + }) ) state.optimizerConfig.initialPoints = calculateInitialPoints(state) state.extras.experimentSuggestionCount = @@ -380,7 +383,10 @@ const experimentReducerInner = produce( break case 'editValueVariable': { const oldVariable = state.valueVariables[action.payload.index] - const newVariable = action.payload.newVariable + const newVariable = { + ...action.payload.newVariable, + name: action.payload.newVariable.name.trim(), + } state.valueVariables[action.payload.index] = experimentSchema.shape.valueVariables.element.parse({ ...newVariable, @@ -397,18 +403,18 @@ const experimentReducerInner = produce( state.dataPoints = updateDataPointNamesAndValues( state, oldVariable, - action.payload.newVariable + newVariable ) state.constraints = updateNamesInConstraints( state, oldVariable.name, - action.payload.newVariable.name + newVariable.name ) state.scoreVariables.forEach(sv => sv.scoreFunction?.variables.forEach(v => { if (v.source === 'factor' && v.factorName === oldVariable.name) { - v.factorName = action.payload.newVariable.name - v.name = action.payload.newVariable.name + v.factorName = newVariable.name + v.name = newVariable.name } }) ) @@ -458,9 +464,10 @@ const experimentReducerInner = produce( state.categoricalVariables.splice( state.categoricalVariables.length, 0, - experimentSchema.shape.categoricalVariables.element.parse( - action.payload - ) + experimentSchema.shape.categoricalVariables.element.parse({ + ...action.payload, + name: action.payload.name.trim(), + }) ) state.optimizerConfig.initialPoints = calculateInitialPoints(state) state.extras.experimentSuggestionCount = @@ -469,15 +476,17 @@ const experimentReducerInner = produce( case 'editCategoricalVariable': { const oldVariableName = state.categoricalVariables[action.payload.index]?.name + const newVariable = { + ...action.payload.newVariable, + name: action.payload.newVariable.name.trim(), + } state.categoricalVariables[action.payload.index] = - experimentSchema.shape.categoricalVariables.element.parse( - action.payload.newVariable - ) + experimentSchema.shape.categoricalVariables.element.parse(newVariable) if (oldVariableName !== undefined) { state.dataPoints = updateDataPointNames( state, oldVariableName, - action.payload.newVariable.name + newVariable.name ) } break diff --git a/packages/ui/src/features/core/editable-table/editable-table-collapsed-row.test.tsx b/packages/ui/src/features/core/editable-table/editable-table-collapsed-row.test.tsx new file mode 100644 index 00000000..2162b933 --- /dev/null +++ b/packages/ui/src/features/core/editable-table/editable-table-collapsed-row.test.tsx @@ -0,0 +1,95 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { render, screen, cleanup, fireEvent } from '@testing-library/react' +import { EditableTableCollapsedRow } from './editable-table-collapsed-row' +import type { TableDataRow } from './types' + +afterEach(() => cleanup()) + +const baseRow: TableDataRow = { + isNew: false, + enabled: true, + valid: true, + metaId: 1, + dataPoints: [{ name: 'A', value: '1', type: 'numeric' }], +} + +const renderRow = ( + tableRow: TableDataRow, + onNoteChanged: (note: string | undefined) => void = () => {} +) => + render( + + + {}} + onEnabledToggled={() => {}} + onNoteChanged={onNoteChanged} + onSelected={() => {}} + isSelectionExists={false} + isSelected={false} + /> + +
+ ) + +describe('EditableTableCollapsedRow note indicator', () => { + it('shows a note icon with the note as its label when a note exists', () => { + renderRow({ ...baseRow, note: 'Seemed fine.' }) + expect( + screen.getByRole('button', { name: 'Seemed fine.' }) + ).toBeInTheDocument() + }) + + it('truncates a long note to 100 chars plus an ellipsis', () => { + const long = 'x'.repeat(120) + renderRow({ ...baseRow, note: long }) + const expected = `${'x'.repeat(100)}…` + expect(screen.getByRole('button', { name: expected })).toBeInTheDocument() + }) + + it('renders no note icon when there is no note', () => { + renderRow(baseRow) + expect(screen.queryByTestId('note-indicator')).toBeNull() + }) +}) + +describe('EditableTableCollapsedRow note popover', () => { + const openPopover = (note = 'first note') => { + const onNoteChanged = vi.fn() + renderRow({ ...baseRow, note }, onNoteChanged) + fireEvent.click(screen.getByTestId('note-indicator')) + return onNoteChanged + } + + it('opens a popover prefilled with the note when the icon is clicked', () => { + openPopover('I pressed the red button.') + expect(screen.getByRole('textbox', { name: 'Edit note' })).toHaveValue( + 'I pressed the red button.' + ) + }) + + it('saves the edited note via onNoteChanged', () => { + const onNoteChanged = openPopover('old') + fireEvent.change(screen.getByRole('textbox', { name: 'Edit note' }), { + target: { value: 'updated note' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Save note' })) + expect(onNoteChanged).toHaveBeenCalledWith('updated note') + }) + + it('deletes the note via onNoteChanged(undefined)', () => { + const onNoteChanged = openPopover('to be deleted') + fireEvent.click(screen.getByRole('button', { name: 'Delete note' })) + expect(onNoteChanged).toHaveBeenCalledWith(undefined) + }) + + it('cancels without calling onNoteChanged and closes the popover', () => { + const onNoteChanged = openPopover('unchanged') + fireEvent.click(screen.getByRole('button', { name: 'Cancel note' })) + expect(onNoteChanged).not.toHaveBeenCalled() + expect(screen.queryByRole('textbox', { name: 'Edit note' })).toBeNull() + }) +}) 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 a1d6c956..7bf11ca7 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 @@ -7,10 +7,24 @@ import { Tooltip, Box, Checkbox, + Popover, + TextField, } from '@mui/material' import { TableDataRow } from './types' import { EditableTableCell } from './editable-table-cell' -import { Add, Edit } from '@mui/icons-material' +import { + Add, + Cancel, + Check, + Delete, + DescriptionOutlined, + Edit, +} from '@mui/icons-material' +import { useState, type MouseEvent } from 'react' + +const NOTE_TOOLTIP_MAX = 100 +const truncateNote = (note: string) => + note.length > NOTE_TOOLTIP_MAX ? `${note.slice(0, NOTE_TOOLTIP_MAX)}…` : note interface EditableTableCollapsedRowProps { colSpan: number @@ -18,6 +32,7 @@ interface EditableTableCollapsedRowProps { tableRow: TableDataRow setExpanded: (expanded: boolean) => void onEnabledToggled: (enabled: boolean) => void + onNoteChanged: (note: string | undefined) => void onSelected: (isShiftKeyDown: boolean, isCtrlKeyDown: boolean) => void isEditingDisabled?: boolean isSelectionExists: boolean @@ -30,6 +45,7 @@ export const EditableTableCollapsedRow = ({ tableRow, setExpanded, onEnabledToggled, + onNoteChanged, onSelected, isEditingDisabled, isSelected, @@ -38,6 +54,24 @@ export const EditableTableCollapsedRow = ({ const { classes } = useStyles() const rowEnabled = tableRow.enabled && tableRow.valid + const [noteAnchorEl, setNoteAnchorEl] = useState(null) + const [noteDraft, setNoteDraft] = useState('') + + const openNotePopover = (e: MouseEvent) => { + e.stopPropagation() + setNoteDraft(tableRow.note ?? '') + setNoteAnchorEl(e.currentTarget) + } + const closeNotePopover = () => setNoteAnchorEl(null) + const saveNote = () => { + onNoteChanged(noteDraft.trim() === '' ? undefined : noteDraft) + closeNotePopover() + } + const deleteNote = () => { + onNoteChanged(undefined) + closeNotePopover() + } + return (
+ {tableRow.note !== undefined && tableRow.note !== '' && ( + <> + + + + + + + + e.stopPropagation()} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + > + + setNoteDraft(e.target.value)} + slotProps={{ htmlInput: { 'aria-label': 'Edit note' } }} + /> + + + + + + + + + + + + + + + + + + + + + )} { ).toBeInTheDocument() }) }) + +describe('EditableTableExpandedRow NOTE section', () => { + it('renders the note input and prefills an existing note', () => { + render( + + + {}} + onAdd={() => {}} + onSave={() => {}} + /> + +
+ ) + expect(screen.getByText('Note')).toBeInTheDocument() + expect(screen.getByPlaceholderText('Add note here')).toHaveValue( + 'existing note' + ) + }) + + it('saves an edited note through onSave', () => { + let saved: { note?: string } | undefined + render( + + + {}} + onAdd={() => {}} + onSave={r => { + saved = r + }} + /> + +
+ ) + fireEvent.change(screen.getByPlaceholderText('Add note here'), { + target: { value: 'forgot the red button' }, + }) + fireEvent.click(screen.getByRole('button', { name: /save/i })) + expect(saved?.note).toBe('forgot the red button') + }) +}) 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 95ec2e41..f9305974 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 @@ -88,6 +88,13 @@ export const EditableTableExpandedRow = ({ }) } + const handleNoteEdit = (value: string) => { + setEditedRow({ + ...editedRow, + note: value === '' ? undefined : value, + }) + } + // Objectives that show response inputs, and the widest response count, so the // inputs can be laid out in a grid where column N of every objective aligns. const responseObjectives = (editedRow.scoreFunctions ?? []).filter( @@ -233,6 +240,18 @@ export const EditableTableExpandedRow = ({ )} + + Note + handleNoteEdit(e.target.value)} + slotProps={{ htmlInput: { 'aria-label': 'Note' } }} + /> + + {violations !== undefined && violations.length > 0 && violations.map((v, i) => ( diff --git a/packages/ui/src/features/core/editable-table/editable-table-row.tsx b/packages/ui/src/features/core/editable-table/editable-table-row.tsx index ede16a58..95431dde 100644 --- a/packages/ui/src/features/core/editable-table/editable-table-row.tsx +++ b/packages/ui/src/features/core/editable-table/editable-table-row.tsx @@ -11,6 +11,7 @@ interface EditableTableRowProps { onSave: (row: TableDataRow) => void onAdd: (row: TableDataRow) => void onEnabledToggled: (enabled: boolean) => void + onNoteChanged: (note: string | undefined) => void onSelected: (isShiftKeyDown: boolean, isCtrlKeyDown: boolean) => void violations?: string[] order: TableOrder @@ -26,6 +27,7 @@ export const EditableTableRow = ({ onSave, onAdd, onEnabledToggled, + onNoteChanged, onSelected, violations, order, @@ -61,6 +63,7 @@ export const EditableTableRow = ({ setExpanded={expanded => setExpanded(expanded)} isEditingDisabled={isEditingDisabled} onEnabledToggled={enabled => onEnabledToggled(enabled)} + onNoteChanged={note => onNoteChanged(note)} onSelected={(isShiftKeyDown, isCtrlKeyDown) => onSelected(isShiftKeyDown, isCtrlKeyDown) } diff --git a/packages/ui/src/features/core/editable-table/editable-table.tsx b/packages/ui/src/features/core/editable-table/editable-table.tsx index e32662ce..877bb006 100644 --- a/packages/ui/src/features/core/editable-table/editable-table.tsx +++ b/packages/ui/src/features/core/editable-table/editable-table.tsx @@ -25,6 +25,7 @@ type EditableTableProps = { onRowsDeleted: (rowIndices: number[]) => void onRowEdited: (rowIndex: number, row: TableDataRow) => void onRowEnabledToggled: (rowIndex: number, enabled: boolean) => void + onRowNoteChanged: (rowIndex: number, note: string | undefined) => void violations?: EditableTableViolation[] order: TableOrder isEditingDisabled?: boolean @@ -37,6 +38,7 @@ export const EditableTable = ({ onRowsDeleted, onRowEdited, onRowEnabledToggled, + onRowNoteChanged, violations, order, isEditingDisabled, @@ -193,6 +195,12 @@ export const EditableTable = ({ enabled ) } + onNoteChanged={note => + onRowNoteChanged( + getRowIndex(newestFirst, rowIndex, rows.length), + note + ) + } isSelectionExists={isSelectionExists} isSelected={selectedRowIndices.includes( getRowIndex(newestFirst, rowIndex, rows.length) diff --git a/packages/ui/src/features/core/editable-table/types.ts b/packages/ui/src/features/core/editable-table/types.ts index ec8ce030..6d3be378 100644 --- a/packages/ui/src/features/core/editable-table/types.ts +++ b/packages/ui/src/features/core/editable-table/types.ts @@ -15,6 +15,7 @@ export type TableDataRow = { enabled?: boolean valid?: boolean metaId?: number + note?: string scoreFunctions?: { scoreName: string label: string diff --git a/packages/ui/src/features/data-points/data-points.tsx b/packages/ui/src/features/data-points/data-points.tsx index db1fb382..890ff6f4 100644 --- a/packages/ui/src/features/data-points/data-points.tsx +++ b/packages/ui/src/features/data-points/data-points.tsx @@ -37,6 +37,8 @@ type DataPointProps = { warning?: string onToggleNewestFirst: () => void onUpdateDataPoints: (dataPoints: DataEntry[]) => void + csvSeparator?: string + onCsvImportError?: (error: unknown) => void } export function DataPoints(props: DataPointProps) { @@ -53,6 +55,8 @@ export function DataPoints(props: DataPointProps) { warning, onToggleNewestFirst, onUpdateDataPoints, + csvSeparator = ';', + onCsvImportError, } = props const { classes } = useStyles() const { dispatch } = useExperiment() @@ -62,12 +66,13 @@ export function DataPoints(props: DataPointProps) { const enabledCategoricalVariables = categoricalVariables.filter( v => v.enabled ) - const { state, addRow, deleteRows, editRow, setEnabledState } = useDataPoints( - enabledValueVariables, - enabledCategoricalVariables, - scoreVariables, - dataPoints - ) + const { state, addRow, deleteRows, editRow, setEnabledState, setNote } = + useDataPoints( + enabledValueVariables, + enabledCategoricalVariables, + scoreVariables, + dataPoints + ) const isLoadingState = state.rows.length === 0 @@ -114,6 +119,9 @@ export function DataPoints(props: DataPointProps) { const rowEnabledToggled = (rowIndex: number, enabled: boolean) => onUpdateDataPoints(setEnabledState(rowIndex, enabled)) + const rowNoteChanged = (rowIndex: number, note: string | undefined) => + onUpdateDataPoints(setNote(rowIndex, note)) + const rowEdited = (rowIndex: number, row: TableDataRow) => { onUpdateDataPoints(editRow(rowIndex, row)) if (row.metaId !== undefined) { @@ -139,7 +147,7 @@ export function DataPoints(props: DataPointProps) { light onClick={() => saveCSVToLocalFile( - dataPointsToCSV(dataPoints), + dataPointsToCSV(dataPoints, csvSeparator), experimentId + '.csv' ) } @@ -152,6 +160,8 @@ export function DataPoints(props: DataPointProps) { categoricalVariables={enabledCategoricalVariables} valueVariables={enabledValueVariables} scoreVariables={scoreVariables} + separator={csvSeparator} + onError={onCsvImportError} /> rowEnabledToggled(index, enabled) } + onRowNoteChanged={(index, note) => rowNoteChanged(index, note)} /> diff --git a/packages/ui/src/features/data-points/upload-csv-button.test.tsx b/packages/ui/src/features/data-points/upload-csv-button.test.tsx new file mode 100644 index 00000000..312b65a8 --- /dev/null +++ b/packages/ui/src/features/data-points/upload-csv-button.test.tsx @@ -0,0 +1,66 @@ +import { it, expect, afterEach, vi } from 'vitest' +import { + render, + screen, + cleanup, + fireEvent, + waitFor, +} from '@testing-library/react' +import UploadCSVButton from './upload-csv-button' + +afterEach(() => cleanup()) + +const valueVars = [ + { + type: 'continuous' as const, + name: 'A', + description: '', + min: 0, + max: 10, + enabled: true, + }, +] + +const uploadFile = (contents: string) => { + const input = screen.getByTestId('upload-csv-input') as HTMLInputElement + const file = new File([contents], 'data.csv', { type: 'text/csv' }) + fireEvent.change(input, { target: { files: [file] } }) +} + +it('parses using the provided separator', async () => { + const onUpload = vi.fn() + render( + + ) + uploadFile('id\tA\tenabled\tvalid\n1\t5\ttrue\ttrue') + await waitFor(() => expect(onUpload).toHaveBeenCalled()) + expect(onUpload.mock.calls[0]?.[0]?.[0]?.data[0]).toMatchObject({ + name: 'A', + value: 5, + }) +}) + +it('calls onError (not onUpload) when the CSV cannot be parsed', async () => { + const onUpload = vi.fn() + const onError = vi.fn() + render( + + ) + // header doesn't contain the expected 'A' column (e.g. wrong delimiter) → + // csvToDataPoints throws → onError, never onUpload + uploadFile('nope;nada\n1;2') + await waitFor(() => expect(onError).toHaveBeenCalled()) + expect(onUpload).not.toHaveBeenCalled() +}) diff --git a/packages/ui/src/features/data-points/upload-csv-button.tsx b/packages/ui/src/features/data-points/upload-csv-button.tsx index 6539e721..fc3d6da3 100644 --- a/packages/ui/src/features/data-points/upload-csv-button.tsx +++ b/packages/ui/src/features/data-points/upload-csv-button.tsx @@ -21,30 +21,43 @@ const readFile = (file: Blob, dataHandler: (s: string) => void) => { interface UploadCSVButtonProps { light?: boolean onUpload: (dataPoints: DataEntry[]) => void + onError?: (error: unknown) => void valueVariables: ValueVariableType[] categoricalVariables: CategoricalVariableType[] scoreVariables: ScoreVariableType[] + separator?: string } const UploadCSVButton = ({ onUpload, + onError, light, valueVariables, categoricalVariables, scoreVariables, + separator = ';', }: UploadCSVButtonProps) => { const handleFileUpload = (files: File[]) => { if (files && files.length > 0 && files[0] !== undefined) { - readFile(files[0], data => - onUpload( - csvToDataPoints( + readFile(files[0], data => { + // Parsing can throw on a malformed file or a delimiter mismatch. Catch + // it and surface via onError so the consumer can inform the user; + // don't call onUpload with a failed parse. + let parsed: DataEntry[] + try { + parsed = csvToDataPoints( data, valueVariables, categoricalVariables, - scoreVariables + scoreVariables, + separator ) - ) - ) + } catch (error) { + onError?.(error) + return + } + onUpload(parsed) + }) } } @@ -58,6 +71,7 @@ const UploadCSVButton = ({ style={{ display: 'none' }} inputProps={{ accept: '.csv', + 'data-testid': 'upload-csv-input', }} onChange={(e: ChangeEvent) => handleFileUpload(Array.from(e.target.files || [])) diff --git a/packages/ui/src/features/data-points/useDataPoints.test.ts b/packages/ui/src/features/data-points/useDataPoints.test.ts index 2071b5e5..f39d102a 100644 --- a/packages/ui/src/features/data-points/useDataPoints.test.ts +++ b/packages/ui/src/features/data-points/useDataPoints.test.ts @@ -157,4 +157,79 @@ describe('useDataPoints', () => { expect(newRow?.useFunction).toBe(true) }) }) + + describe('note', () => { + it('persists a row note into meta.note on edit', () => { + const original = [ + { meta: { id: 1, enabled: true, valid: true }, data: [] }, + ] + const { result } = renderHook(() => useDataPoints([], [], [], original)) + const edited = result.current.editRow(0, { + isNew: false, + metaId: 1, + enabled: true, + valid: true, + note: 'measured twice', + dataPoints: [], + }) + expect(edited[0]?.meta.note).toBe('measured twice') + }) + + it('clears meta.note when the row note is empty', () => { + const original = [ + { + meta: { id: 1, enabled: true, valid: true, note: 'old' }, + data: [], + }, + ] + const { result } = renderHook(() => useDataPoints([], [], [], original)) + const edited = result.current.editRow(0, { + isNew: false, + metaId: 1, + enabled: true, + valid: true, + note: '', + dataPoints: [], + }) + expect(edited[0]?.meta.note).toBeUndefined() + }) + + it('exposes meta.note as row.note when building rows', () => { + const { result } = renderHook(() => + useDataPoints( + [], + [], + [], + [ + { + meta: { id: 1, enabled: true, valid: true, note: 'hello' }, + data: [], + }, + ] + ) + ) + expect(result.current.state.rows[0]?.note).toBe('hello') + }) + + it('sets a note on a row via setNote', () => { + const original = [ + { meta: { id: 1, enabled: true, valid: true }, data: [] }, + ] + const { result } = renderHook(() => useDataPoints([], [], [], original)) + const updated = result.current.setNote(0, 'inline note') + expect(updated[0]?.meta.note).toBe('inline note') + }) + + it('removes the note via setNote when given undefined or empty', () => { + const original = [ + { + meta: { id: 1, enabled: true, valid: true, note: 'old' }, + data: [], + }, + ] + const { result } = renderHook(() => useDataPoints([], [], [], original)) + expect(result.current.setNote(0, undefined)[0]?.meta.note).toBeUndefined() + expect(result.current.setNote(0, '')[0]?.meta.note).toBeUndefined() + }) + }) }) diff --git a/packages/ui/src/features/data-points/useDataPoints.ts b/packages/ui/src/features/data-points/useDataPoints.ts index cd548c2b..a353ccd6 100644 --- a/packages/ui/src/features/data-points/useDataPoints.ts +++ b/packages/ui/src/features/data-points/useDataPoints.ts @@ -91,6 +91,12 @@ export const useDataPoints = ( [dataPoints] ) + const setNote = useCallback( + (rowIndex: number, note: string | undefined) => + _setNote(dataPoints, rowIndex, note), + [dataPoints] + ) + return { state, addRow, @@ -98,6 +104,7 @@ export const useDataPoints = ( deleteRows, editRow, setEnabledState, + setNote, } } @@ -154,6 +161,7 @@ const convertToDataEntry = ( enabled: row.enabled ?? true, id: row.metaId ?? 0, valid: row.valid ?? true, + ...(row.note !== undefined && row.note !== '' ? { note: row.note } : {}), } satisfies DataEntry['meta'] if ( data.length < @@ -189,6 +197,7 @@ const _editRow = (original: DataEntry[], rowIndex: number, row: DataEntry) => if (originalRow !== undefined) { originalRow.meta.enabled = row.meta.enabled ?? originalRow.meta.enabled originalRow.meta.id = row.meta.id ?? originalRow.meta.id + originalRow.meta.note = row.meta.note row.data.forEach(dp => { const originalDataPoint = originalRow.data.find( odp => odp.name === dp.name @@ -217,6 +226,19 @@ const _setEnabledState = ( } }) +const _setNote = ( + original: DataEntry[], + rowIndex: number, + note: string | undefined +) => + produce(original, result => { + const originalRow = result[rowIndex] + if (originalRow !== undefined) { + // Empty/undefined note ⇒ drop the key so no note is persisted. + originalRow.meta.note = note === '' ? undefined : note + } + }) + const _deleteRow = (original: DataEntry[], rowIndex: number) => produce(original, result => { result.splice(rowIndex, 1) @@ -396,6 +418,7 @@ const buildRows = ( enabled: item.meta.enabled, valid: item.meta.valid, metaId: item.meta.id, + note: item.meta.note, scoreFunctions: buildScoreFunctions(scoreVariables, item), // Uncomment the following line to display a meta data property in the table // .concat([{ name: 'id', value: `${item.meta.id}` }]),