From 3ff764a522116298d660712532b246520fdb7c1a Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Tue, 28 Apr 2026 16:28:49 +0100 Subject: [PATCH] add export programming language Typescript Fixes #19 --- ...04-28-more-programming-language-exports.md | 11 +- .../typescript/010-typescript.md | 55 ++++ .../030-data-formats/typescript/options.md | 85 ++++++ js/data_formats/file-types.js | 1 + js/data_formats/typescript-convertor.js | 253 ++++++++++++++++++ js/grid/exporter.js | 3 + js/gui_components/data-generator-page.js | 3 + js/gui_components/import-export-controls.js | 2 + .../options-typescript-panel.js | 173 ++++++++++++ js/gui_components/tabbed-text-control.js | 1 + .../data_formats/typescript-convertor.test.js | 154 +++++++++++ tests/generator/data-generator-page.test.js | 20 +- tests/utils/options-typescript-panel.test.js | 83 ++++++ 13 files changed, 839 insertions(+), 5 deletions(-) create mode 100644 docs-src/docs/030-data-formats/typescript/010-typescript.md create mode 100644 docs-src/docs/030-data-formats/typescript/options.md create mode 100644 js/data_formats/typescript-convertor.js create mode 100644 js/gui_components/options_panels/options-typescript-panel.js create mode 100644 tests/data_formats/typescript-convertor.test.js create mode 100644 tests/utils/options-typescript-panel.test.js diff --git a/docs-src/blog/2026-04-28-more-programming-language-exports.md b/docs-src/blog/2026-04-28-more-programming-language-exports.md index f5fb09cc..5e07c06b 100644 --- a/docs-src/blog/2026-04-28-more-programming-language-exports.md +++ b/docs-src/blog/2026-04-28-more-programming-language-exports.md @@ -9,7 +9,7 @@ draft: true We've expanded export options for programming-language-style output so it is easier to move table data into application code. -This release builds on the existing JavaScript export and adds both Python and Java export support with configurable output options. +This release builds on the existing JavaScript export and adds Python, Java, and TypeScript export support with configurable output options. @@ -39,6 +39,15 @@ Read the docs: - [Java Data Format](/docs/data-formats/java/java) - [Java Options](/docs/data-formats/java/options) +## TypeScript + +TypeScript export supports both anonymous-object and class-instance output and includes options for collection type (`Array` or `T[]`), variable assignment, number conversion, and pretty printing with custom delimiters. + +Read the docs: + +- [TypeScript Data Format](/docs/data-formats/typescript/typescript) +- [TypeScript Options](/docs/data-formats/typescript/options) + ## JavaScript JavaScript export continues to support object-array output with configurable formatting and conversion options. diff --git a/docs-src/docs/030-data-formats/typescript/010-typescript.md b/docs-src/docs/030-data-formats/typescript/010-typescript.md new file mode 100644 index 00000000..df73ddae --- /dev/null +++ b/docs-src/docs/030-data-formats/typescript/010-typescript.md @@ -0,0 +1,55 @@ +--- +sidebar_position: 1 +title: "TypeScript" +description: "TypeScript is a typed superset of JavaScript and can represent tabular data as typed arrays of objects or class instances. You can export grid data as TypeScript-ready source in AnyWayData.com." +--- + +TypeScript is a strongly typed programming language and the AnyWayData TypeScript option is useful when you need typed, copy/paste-ready data for tests, fixtures, demos, and prototypes. + +## What is TypeScript Data Output? + +TypeScript can represent table-like data using arrays of anonymous objects or arrays of class instances. + +For example, a typed array of anonymous objects: + +```ts +const data: Array> = [ + {"user": "Jesse.Bradtke97", "name": "Corine"}, + {"user": "Cielo.Little", "name": "Zander"} +]; +``` + +In the example above: + +- each row is represented as an object literal using key/value pairs +- object keys come from the column headers +- row objects are collected in a typed TypeScript array + +AnyWayData can also generate class-instance output where each row is represented as an instance of a named TypeScript class. + +## How is TypeScript Output different from JSON and Javascript? + +JSON is a language-independent data format and JavaScript object arrays are JavaScript syntax. + +TypeScript output uses TypeScript syntax and typing concepts: + +- rows can be emitted as anonymous object literals or named class instances +- output can be generated as `Array` or `T[]` +- values can be emitted as quoted strings or numeric literals +- output can be assigned to a named, typed variable + +Unlike JSON, TypeScript output is designed to be used directly in TypeScript code. + +## AnyWayData Support for TypeScript + +AnyWayData currently supports **exporting** data to TypeScript format. + +You can configure output options such as: + +- collection type (`Array` or `T[]`) +- variable assignment and naming +- number conversion (quoted vs unquoted) +- anonymous-object vs class-instance output +- pretty print indentation and custom delimiter + +TypeScript output can be generated from the same grid data used for the other supported export formats. diff --git a/docs-src/docs/030-data-formats/typescript/options.md b/docs-src/docs/030-data-formats/typescript/options.md new file mode 100644 index 00000000..07bbd1e0 --- /dev/null +++ b/docs-src/docs/030-data-formats/typescript/options.md @@ -0,0 +1,85 @@ +--- +sidebar_position: 2 +title: "TypeScript Options" +description: "Options available for converting to TypeScript in AnyWayData.com. This includes collection and object style, variable assignment, number conversion, and pretty print formatting." +--- + +The configuration options for TypeScript are listed below. + +## Collection Type + +Choose whether the outer container is generated as: + +- `List (Array)` +- `Array [ ]` + +In generated output this maps to `Array` or `T[]` type annotations. + +## Assign to Variable + +When enabled, output is assigned to a typed variable. + +For example: + +```ts +const data: Array> = [ + {"name": "Monica"} +]; +``` + +## Variable Name + +Set the name used when `Assign to Variable` is enabled. + +For example, using `records`: + +```ts +const records: Array> = [ + {"name": "Monica"} +]; +``` + +## Number Convert (Quote Numbers) + +When enabled, numeric-looking values are emitted as quoted strings. + +When disabled, numeric-looking values are emitted as numeric literals. + +## Use Anonymous Objects + +When enabled, each row is output as an anonymous object literal. + +When disabled, each row is output as an instance of a named TypeScript class. + +## Class Name + +Used when `Use Anonymous Objects` is disabled to set the class name for generated row instances. + +## Blank Values + +Choose how blank values are exported: + +- `null` +- `Empty String` + +## Pretty Print + +Controls whether output is formatted across multiple lines with indentation. + +When disabled, output is compact (minified style). + +## Delimiter + +Controls indentation when `Pretty Print` is enabled. + +TypeScript supports: + +- `Tab [\t]` +- `Space [ ]` +- `Custom Value` + +## Custom Delimiter + +Set a custom indentation value used for pretty print. + +For TypeScript output, indentation must be whitespace. Non-whitespace values automatically fall back to safe spaces. diff --git a/js/data_formats/file-types.js b/js/data_formats/file-types.js index 1663c37a..cdbb4654 100644 --- a/js/data_formats/file-types.js +++ b/js/data_formats/file-types.js @@ -9,6 +9,7 @@ fileTypes['json'] = { type: 'json', fileExtension: '.json' }; fileTypes['javascript'] = { type: 'javascript', fileExtension: '.js' }; fileTypes['python'] = { type: 'python', fileExtension: '.py' }; fileTypes['java'] = { type: 'java', fileExtension: '.java' }; +fileTypes['typescript'] = { type: 'typescript', fileExtension: '.ts' }; fileTypes['gherkin'] = { type: 'gherkin', fileExtension: '.gherkin' }; fileTypes['html'] = { type: 'html', fileExtension: '.html' }; fileTypes['asciitable'] = { type: 'asciitable', fileExtension: '.txt' }; diff --git a/js/data_formats/typescript-convertor.js b/js/data_formats/typescript-convertor.js new file mode 100644 index 00000000..c5be8dde --- /dev/null +++ b/js/data_formats/typescript-convertor.js @@ -0,0 +1,253 @@ +function convertStringToTypeScriptValidName(aString) { + let result = String(aString).replace(/[^A-Za-z0-9_$]/g, '_'); + if (/^[0-9]/.test(result)) { + result = '_' + result; + } + return result; +} + +function isNumericValue(value) { + if (value === '' || value === null || value === undefined) { + return false; + } + return !isNaN(value) && !isNaN(parseFloat(value)); +} + +class TypeScriptConvertorOptions { + constructor() { + this.delimiterMappings = { + tab: '\t', + space: ' ', + }; + + this.options = { + // 'list' | 'array' + collectionType: 'list', + // wrap output in a variable assignment + assignToVariable: true, + variableName: 'data', + // false = numbers emitted as numeric literals; true = always quoted strings (Number Convert) + quoteNumbers: false, + // true = list/array of anonymous object literals; false = list/array of class instances + useAnonymousObjects: true, + // class name used when useAnonymousObjects is false + objectClassName: 'Row', + // 'null' or 'empty-string' for blank values + blankValueBehavior: 'null', + // pretty print with line breaks and indentation + prettyPrint: true, + // indent character(s) used when prettyPrint is enabled + prettyPrintDelimiter: ' ', + }; + } + + mergeOptions(newoptions) { + const src = newoptions.options !== undefined ? newoptions.options : newoptions; + this.options = { ...this.options, ...src }; + } +} + +class TypeScriptConvertor { + constructor(options) { + this.config = new TypeScriptConvertorOptions(); + if (options) { + this.setOptions(options); + } + } + + setOptions(newOptions) { + this.config.mergeOptions(newOptions); + } + + _escape(textValue) { + let escaped = String(textValue).replace(/\\/g, '\\\\'); + escaped = escaped.replace(/\n/g, '\\n'); + escaped = escaped.replace(/\r/g, '\\r'); + escaped = escaped.replace(/\t/g, '\\t'); + escaped = escaped.replace(/"/g, '\\"'); + return escaped; + } + + _quote(textValue) { + return `"${this._escape(textValue)}"`; + } + + _formatValue(value) { + const opts = this.config.options; + if (value === '' || value === null || value === undefined) { + if (opts.blankValueBehavior === 'null') { + return 'null'; + } + return this._quote(''); + } + + if (!opts.quoteNumbers && isNumericValue(value)) { + return String(Number(value)); + } + + return this._quote(value); + } + + _indent(level) { + if (!this.config.options.prettyPrint) { + return ''; + } + let unit = this.config.options.prettyPrintDelimiter ?? ' '; + // TypeScript indentation should be whitespace; fallback to spaces for invalid custom values. + if (!/^\s+$/.test(String(unit))) { + unit = ' '; + } + return String(unit).repeat(level); + } + + _inferColumnType(colIndex, dataTable) { + const options = this.config.options; + let allNumeric = true; + let hasAnyValue = false; + let hasBlanks = false; + for (let i = 0; i < dataTable.getRowCount(); i++) { + const row = dataTable.getRow(i); + const value = row[colIndex]; + if (value === '' || value === null || value === undefined) { + hasBlanks = true; + continue; + } + hasAnyValue = true; + if (!isNumericValue(value)) { + allNumeric = false; + break; + } + } + + const isNullable = options.blankValueBehavior === 'null' && hasBlanks; + + if (options.quoteNumbers) { + return isNullable ? 'string | null' : 'string'; + } + + if (!hasAnyValue) { + return isNullable ? 'string | null' : 'string'; + } + + if (allNumeric) { + return isNullable ? 'number | null' : 'number'; + } + + return isNullable ? 'string | null' : 'string'; + } + + _buildAnonymousObjectRow(rawHeaders, row, prettyIndent) { + const options = this.config.options; + const pairs = rawHeaders.map((header, index) => `${this._quote(header)}: ${this._formatValue(row[index])}`); + + if (options.prettyPrint) { + return `${prettyIndent}{${pairs.join(', ')}}`; + } + + return `{${pairs.join(', ')}}`; + } + + _buildNamedObjectRow(objectClassName, headers, columnTypes, row, prettyIndent) { + const args = headers.map((h, i) => { + const value = row[i]; + if (value === '' || value === null || value === undefined) { + return this.config.options.blankValueBehavior === 'null' ? 'null' : this._quote(''); + } + if (columnTypes[i].includes('number') && isNumericValue(value)) { + return String(Number(value)); + } + return this._quote(value); + }); + + if (this.config.options.prettyPrint) { + return `${prettyIndent}new ${objectClassName}(${args.join(', ')})`; + } + + return `new ${objectClassName}(${args.join(', ')})`; + } + + _buildClassDefinition(objectClassName, headers, columnTypes) { + const lines = []; + lines.push(`class ${objectClassName} {`); + + headers.forEach((header, index) => { + lines.push(` ${header}: ${columnTypes[index]};`); + }); + + lines.push(''); + + const constructorParams = headers.map((h, i) => `${h}: ${columnTypes[i]}`).join(', '); + lines.push(` constructor(${constructorParams}) {`); + headers.forEach((header) => { + lines.push(` this.${header} = ${header};`); + }); + lines.push(' }'); + lines.push('}'); + + return lines; + } + + fromDataTable(dataTable) { + const options = this.config.options; + const rawHeaders = dataTable.getHeaders(); + const sanitisedHeaders = rawHeaders.map((h) => convertStringToTypeScriptValidName(h)); + + const usedNames = new Set(); + const headers = sanitisedHeaders.map((header) => { + if (!usedNames.has(header)) { + usedNames.add(header); + return header; + } + let counter = 2; + let candidate = `${header}_${counter}`; + while (usedNames.has(candidate)) { + counter++; + candidate = `${header}_${counter}`; + } + usedNames.add(candidate); + return candidate; + }); + + const variableName = convertStringToTypeScriptValidName(options.variableName || 'data'); + const objectClassName = convertStringToTypeScriptValidName(options.objectClassName || 'Row'); + + const useAnonymousObjects = options.useAnonymousObjects; + const useList = options.collectionType === 'list'; + + const outputLines = []; + const rowItems = []; + const indent1 = this._indent(1); + const columnTypes = headers.map((h, i) => this._inferColumnType(i, dataTable)); + + for (let rowIndex = 0; rowIndex < dataTable.getRowCount(); rowIndex++) { + const row = dataTable.getRow(rowIndex); + if (useAnonymousObjects) { + rowItems.push(this._buildAnonymousObjectRow(rawHeaders, row, indent1)); + } else { + rowItems.push(this._buildNamedObjectRow(objectClassName, headers, columnTypes, row, indent1)); + } + } + + if (!useAnonymousObjects) { + outputLines.push(...this._buildClassDefinition(objectClassName, headers, columnTypes), ''); + } + + const listType = useAnonymousObjects ? 'Record' : objectClassName; + const collectionType = useList ? `Array<${listType}>` : `${listType}[]`; + const varDeclaration = options.assignToVariable ? `const ${variableName}: ${collectionType} = ` : ''; + + if (options.prettyPrint) { + outputLines.push(`${varDeclaration}[`); + if (rowItems.length > 0) { + outputLines.push(rowItems.join(',\n')); + } + outputLines.push('];'); + return outputLines.join('\n'); + } + + outputLines.push(`${varDeclaration}[${rowItems.join(', ')}];`); + return outputLines.join('\n'); + } +} + +export { TypeScriptConvertor, TypeScriptConvertorOptions }; diff --git a/js/grid/exporter.js b/js/grid/exporter.js index 6767b589..db7c3ca7 100644 --- a/js/grid/exporter.js +++ b/js/grid/exporter.js @@ -5,6 +5,7 @@ import { JsonConvertor, JsonConvertorOptions } from '../data_formats/json-conver import { JavaConvertor, JavaConvertorOptions } from '../data_formats/java-convertor.js'; import { JavascriptConvertor, JavascriptConvertorOptions } from '../data_formats/javascript-convertor.js'; import { PythonConvertor, PythonConvertorOptions } from '../data_formats/python-convertor.js'; +import { TypeScriptConvertor, TypeScriptConvertorOptions } from '../data_formats/typescript-convertor.js'; import { CsvConvertor } from '../data_formats/csv-convertor.js'; import { DelimiterConvertor } from '../data_formats/delimiter-convertor.js'; import { DelimiterOptions } from '../data_formats/delimiter-options.js'; @@ -27,6 +28,7 @@ class Exporter { this.options['java'] = new JavaConvertorOptions(); this.options['javascript'] = new JavascriptConvertorOptions(); this.options['python'] = new PythonConvertorOptions(); + this.options['typescript'] = new TypeScriptConvertorOptions(); this.options['html'] = new HtmlConvertorOptions(); this.options['gherkin'] = new GherkinOptions(); @@ -40,6 +42,7 @@ class Exporter { this.exporters['java'] = new JavaConvertor(); this.exporters['javascript'] = new JavascriptConvertor(); this.exporters['python'] = new PythonConvertor(); + this.exporters['typescript'] = new TypeScriptConvertor(); this.exporters['gherkin'] = new GherkinConvertor(); this.exporters['html'] = new HtmlConvertor(); this.exporters['asciitable'] = new AsciiTableConvertor(); diff --git a/js/gui_components/data-generator-page.js b/js/gui_components/data-generator-page.js index aa99bd07..0a52bd8a 100644 --- a/js/gui_components/data-generator-page.js +++ b/js/gui_components/data-generator-page.js @@ -10,6 +10,7 @@ import { JsonOptionsPanel } from './options_panels/options-json-panel.js'; import { JavaOptionsPanel } from './options_panels/options-java-panel.js'; import { JavascriptOptionsPanel } from './options_panels/options-javascript-panel.js'; import { PythonOptionsPanel } from './options_panels/options-python-panel.js'; +import { TypeScriptOptionsPanel } from './options_panels/options-typescript-panel.js'; import { HtmlOptionsPanel } from './options_panels/options-html-panel.js'; import { GherkinOptionsPanel } from './options_panels/options-gherkin-panel.js'; import { AsciiTableOptionsPanel } from './options_panels/options-ascii-table.js'; @@ -261,6 +262,7 @@ class DataGeneratorPage { { type: 'java', label: 'Java' }, { type: 'javascript', label: 'JavaScript' }, { type: 'python', label: 'Python' }, + { type: 'typescript', label: 'TypeScript' }, ]; const codeGroup = this.documentObj.createElement('optgroup'); codeGroup.label = '-- Code --'; @@ -321,6 +323,7 @@ class DataGeneratorPage { this.optionsPanels['javascript'] = new JavascriptOptionsPanel(optionsParent); this.optionsPanels['java'] = new JavaOptionsPanel(optionsParent); this.optionsPanels['python'] = new PythonOptionsPanel(optionsParent); + this.optionsPanels['typescript'] = new TypeScriptOptionsPanel(optionsParent); this.optionsPanels['html'] = new HtmlOptionsPanel(optionsParent); this.optionsPanels['gherkin'] = new GherkinOptionsPanel(optionsParent); this.optionsPanels['asciitable'] = new AsciiTableOptionsPanel(optionsParent); diff --git a/js/gui_components/import-export-controls.js b/js/gui_components/import-export-controls.js index d1a313fd..f884041d 100644 --- a/js/gui_components/import-export-controls.js +++ b/js/gui_components/import-export-controls.js @@ -8,6 +8,7 @@ import { JsonOptionsPanel } from './options_panels/options-json-panel.js'; import { JavaOptionsPanel } from './options_panels/options-java-panel.js'; import { JavascriptOptionsPanel } from './options_panels/options-javascript-panel.js'; import { PythonOptionsPanel } from './options_panels/options-python-panel.js'; +import { TypeScriptOptionsPanel } from './options_panels/options-typescript-panel.js'; import { GherkinOptionsPanel } from './options_panels/options-gherkin-panel.js'; import { HtmlOptionsPanel } from './options_panels/options-html-panel.js'; import { GenericDataTable } from '../data_formats/generic-data-table.js'; @@ -266,6 +267,7 @@ class ImportExportControls { this.optionsPanels['javascript'] = new JavascriptOptionsPanel(optionsparent); this.optionsPanels['java'] = new JavaOptionsPanel(optionsparent); this.optionsPanels['python'] = new PythonOptionsPanel(optionsparent); + this.optionsPanels['typescript'] = new TypeScriptOptionsPanel(optionsparent); this.optionsPanels['html'] = new HtmlOptionsPanel(optionsparent); this.optionsPanels['gherkin'] = new GherkinOptionsPanel(optionsparent); } diff --git a/js/gui_components/options_panels/options-typescript-panel.js b/js/gui_components/options_panels/options-typescript-panel.js new file mode 100644 index 00000000..d908d445 --- /dev/null +++ b/js/gui_components/options_panels/options-typescript-panel.js @@ -0,0 +1,173 @@ +import { TypeScriptConvertorOptions } from '../../data_formats/typescript-convertor.js'; +import { HtmlDataValues } from './html-options-data-utils.js'; + +class TypeScriptOptionsPanel { + constructor(parentElement) { + this.parent = parentElement; + this.htmlData = new HtmlDataValues(this.parent); + } + + addToGui() { + this.parent.innerHTML = ` +
+

Options

+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ `; + } + + setApplyCallback(callbackFunc) { + const button = this.parent.querySelector('.apply button'); + button.onclick = function () { + callbackFunc(this.getOptionsFromGui()); + }.bind(this); + } + + getOptionsFromGui() { + const newOptions = new TypeScriptConvertorOptions(); + newOptions.options.collectionType = this.htmlData.getSelectedValueFrom("select[name='collectiontype']", 'list'); + newOptions.options.assignToVariable = this.htmlData.getCheckBoxValueFrom('.assigntovariable label input'); + newOptions.options.variableName = this.htmlData.getTextInputValueFrom('.variablename label input') || 'data'; + newOptions.options.quoteNumbers = this.htmlData.getCheckBoxValueFrom('.quotenumbers label input'); + newOptions.options.useAnonymousObjects = this.htmlData.getCheckBoxValueFrom('.useanonymousobjects label input'); + newOptions.options.objectClassName = this.htmlData.getTextInputValueFrom('.objectclassname label input') || 'Row'; + newOptions.options.blankValueBehavior = this.htmlData.getSelectedValueFrom( + "select[name='blankvaluebehavior']", + 'null' + ); + newOptions.options.prettyPrint = this.htmlData.getCheckBoxValueFrom('.prettyprint label input'); + newOptions.options.prettyPrintDelimiter = this.htmlData.getSelectWithCustomInput( + "select[name='prettydelimiter']", + 'custom', + '.custom-pretty-delimiter label input', + newOptions.delimiterMappings, + ' ' + ); + return newOptions; + } + + setFromOptions(mainOptions) { + const options = mainOptions?.options ?? {}; + const delimiterMappings = mainOptions?.delimiterMappings ?? new TypeScriptConvertorOptions().delimiterMappings; + this.htmlData.setDropDownOptionToKeyValue("select[name='collectiontype']", options.collectionType, 'list'); + this.htmlData.setCheckBoxFrom('.assigntovariable label input', options.assignToVariable, true); + this.htmlData.setTextFieldToValue('.variablename label input', options.variableName ?? 'data'); + this.htmlData.setCheckBoxFrom('.quotenumbers label input', options.quoteNumbers, false); + this.htmlData.setCheckBoxFrom('.useanonymousobjects label input', options.useAnonymousObjects, true); + this.htmlData.setTextFieldToValue('.objectclassname label input', options.objectClassName ?? 'Row'); + this.htmlData.setDropDownOptionToKeyValue("select[name='blankvaluebehavior']", options.blankValueBehavior, 'null'); + this.htmlData.setCheckBoxFrom('.prettyprint label input', options.prettyPrint, true); + this.htmlData.setSelectWithCustomInput( + "select[name='prettydelimiter']", + 'custom', + '.custom-pretty-delimiter label input', + delimiterMappings, + options.prettyPrintDelimiter + ); + } +} + +export { TypeScriptOptionsPanel }; diff --git a/js/gui_components/tabbed-text-control.js b/js/gui_components/tabbed-text-control.js index 0ba8798c..c5726d32 100644 --- a/js/gui_components/tabbed-text-control.js +++ b/js/gui_components/tabbed-text-control.js @@ -15,6 +15,7 @@ class TabbedTextControl { { id: 'java', label: 'Java', type: 'java' }, { id: 'javascript', label: 'JavaScript', type: 'javascript' }, { id: 'python', label: 'Python', type: 'python' }, + { id: 'typescript', label: 'TypeScript', type: 'typescript' }, ], }, { id: 'gherkin', label: 'Gherkin', type: 'gherkin' }, diff --git a/tests/data_formats/typescript-convertor.test.js b/tests/data_formats/typescript-convertor.test.js new file mode 100644 index 00000000..978e9e84 --- /dev/null +++ b/tests/data_formats/typescript-convertor.test.js @@ -0,0 +1,154 @@ +import { GenericDataTable } from '../../js/data_formats/generic-data-table.js'; +import { TypeScriptConvertor, TypeScriptConvertorOptions } from '../../js/data_formats/typescript-convertor.js'; + +const basicTable = new GenericDataTable(); +basicTable.setHeaders(['name', 'age']); +basicTable.appendDataRow(['Alice', '30']); +basicTable.appendDataRow(['Bob', '25']); + +describe('TypeScriptConvertor - anonymous objects (default)', () => { + test('default: list output assigned to typed variable, numbers unquoted', () => { + const expected = `const data: Array> = [ + {"name": "Alice", "age": 30}, + {"name": "Bob", "age": 25} +];`; + const convertor = new TypeScriptConvertor(); + expect(convertor.fromDataTable(basicTable)).toBe(expected); + }); + + test('collectionType array uses T[] type annotation', () => { + const convertor = new TypeScriptConvertor(); + convertor.setOptions({ collectionType: 'array' }); + const output = convertor.fromDataTable(basicTable); + expect(output).toContain('const data: Record[] = ['); + }); + + test('quoteNumbers outputs numbers as strings', () => { + const convertor = new TypeScriptConvertor(); + convertor.setOptions({ quoteNumbers: true }); + const output = convertor.fromDataTable(basicTable); + expect(output).toContain('{"name": "Alice", "age": "30"}'); + }); + + test('assignToVariable false omits declaration prefix', () => { + const convertor = new TypeScriptConvertor(); + convertor.setOptions({ assignToVariable: false }); + const output = convertor.fromDataTable(basicTable); + expect(output.startsWith('[')).toBe(true); + expect(output).not.toContain('const data'); + }); + + test('custom variable name is used', () => { + const convertor = new TypeScriptConvertor(); + convertor.setOptions({ variableName: 'records' }); + const output = convertor.fromDataTable(basicTable); + expect(output).toContain('const records: Array> = ['); + }); + + test('prettyPrint false outputs compact single-line collection', () => { + const convertor = new TypeScriptConvertor(); + convertor.setOptions({ prettyPrint: false }); + const output = convertor.fromDataTable(basicTable); + expect(output).toContain('[{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}];'); + }); + + test('tab delimiter indents with tab character', () => { + const convertor = new TypeScriptConvertor(); + convertor.setOptions({ prettyPrintDelimiter: '\t' }); + const output = convertor.fromDataTable(basicTable); + expect(output).toContain('\t{"name": "Alice", "age": 30}'); + }); + + test('invalid custom delimiter falls back to spaces', () => { + const convertor = new TypeScriptConvertor(); + convertor.setOptions({ prettyPrintDelimiter: '---' }); + const output = convertor.fromDataTable(basicTable); + expect(output).toContain(' {"name": "Alice", "age": 30}'); + }); +}); + +describe('TypeScriptConvertor - named class instances', () => { + test('generates class definition and typed list', () => { + const convertor = new TypeScriptConvertor(); + convertor.setOptions({ useAnonymousObjects: false }); + const output = convertor.fromDataTable(basicTable); + expect(output).toContain('class Row {'); + expect(output).toContain('name: string;'); + expect(output).toContain('age: number;'); + expect(output).toContain('constructor(name: string, age: number) {'); + expect(output).toContain('const data: Array = ['); + expect(output).toContain('new Row("Alice", 30)'); + }); + + test('named class with array collection type uses Row[]', () => { + const convertor = new TypeScriptConvertor(); + convertor.setOptions({ useAnonymousObjects: false, collectionType: 'array' }); + const output = convertor.fromDataTable(basicTable); + expect(output).toContain('const data: Row[] = ['); + }); + + test('custom class name', () => { + const convertor = new TypeScriptConvertor(); + convertor.setOptions({ useAnonymousObjects: false, objectClassName: 'Employee' }); + const output = convertor.fromDataTable(basicTable); + expect(output).toContain('class Employee {'); + expect(output).toContain('new Employee("Alice", 30)'); + }); + + test('blank value null uses union type with null', () => { + const table = new GenericDataTable(); + table.setHeaders(['name', 'age']); + table.appendDataRow(['Alice', '']); + table.appendDataRow(['Bob', '42']); + + const convertor = new TypeScriptConvertor(); + convertor.setOptions({ useAnonymousObjects: false, blankValueBehavior: 'null' }); + const output = convertor.fromDataTable(table); + + expect(output).toContain('age: number | null;'); + expect(output).toContain('new Row("Alice", null)'); + }); +}); + +describe('TypeScriptConvertor - header and identifier sanitisation', () => { + test('sanitises special characters in field names for class mode', () => { + const table = new GenericDataTable(); + table.setHeaders(['First Name', '2nd Column']); + table.appendDataRow(['Alice', 'value']); + + const convertor = new TypeScriptConvertor(); + convertor.setOptions({ useAnonymousObjects: false }); + const output = convertor.fromDataTable(table); + + expect(output).toContain('First_Name: string;'); + expect(output).toContain('_2nd_Column: string;'); + }); + + test('keeps original header keys in anonymous objects', () => { + const table = new GenericDataTable(); + table.setHeaders(['First Name', '2nd Column']); + table.appendDataRow(['Alice', 'value']); + + const convertor = new TypeScriptConvertor(); + const output = convertor.fromDataTable(table); + + expect(output).toContain('"First Name": "Alice"'); + expect(output).toContain('"2nd Column": "value"'); + }); +}); + +describe('TypeScriptConvertorOptions', () => { + test('mergeOptions merges into defaults', () => { + const opts = new TypeScriptConvertorOptions(); + opts.mergeOptions({ quoteNumbers: true, variableName: 'myList' }); + expect(opts.options.quoteNumbers).toBe(true); + expect(opts.options.variableName).toBe('myList'); + expect(opts.options.collectionType).toBe('list'); + }); + + test('mergeOptions accepts options wrapper', () => { + const opts = new TypeScriptConvertorOptions(); + opts.mergeOptions({ options: { collectionType: 'array' } }); + expect(opts.options.collectionType).toBe('array'); + }); +}); diff --git a/tests/generator/data-generator-page.test.js b/tests/generator/data-generator-page.test.js index 7488696c..ea96d772 100644 --- a/tests/generator/data-generator-page.test.js +++ b/tests/generator/data-generator-page.test.js @@ -492,9 +492,19 @@ describe('DataGeneratorPage', () => { test('populateFormatOptions adds Code optgroup after other format options', () => { class FakeExporterWithCode extends FakeExporter { canExport(type) { - return ['csv', 'json', 'markdown', 'dsv', 'html', 'gherkin', 'asciitable', 'javascript', 'python'].includes( - type - ); + return [ + 'csv', + 'json', + 'markdown', + 'dsv', + 'html', + 'gherkin', + 'asciitable', + 'java', + 'javascript', + 'python', + 'typescript', + ].includes(type); } } @@ -520,10 +530,12 @@ describe('DataGeneratorPage', () => { expect(lastChild.tagName.toLowerCase()).toBe('optgroup'); expect(lastChild.label).toBe('-- Code --'); - // Optgroup must contain javascript and python options + // Optgroup must contain all programming-language options const codeOptionValues = Array.from(lastChild.querySelectorAll('option')).map((o) => o.value); + expect(codeOptionValues).toContain('java'); expect(codeOptionValues).toContain('javascript'); expect(codeOptionValues).toContain('python'); + expect(codeOptionValues).toContain('typescript'); }); test('default alert invocation does not throw on validation errors', () => { diff --git a/tests/utils/options-typescript-panel.test.js b/tests/utils/options-typescript-panel.test.js new file mode 100644 index 00000000..eab651cd --- /dev/null +++ b/tests/utils/options-typescript-panel.test.js @@ -0,0 +1,83 @@ +import { JSDOM } from 'jsdom'; +import { TypeScriptOptionsPanel } from '../../js/gui_components/options_panels/options-typescript-panel.js'; +import { TypeScriptConvertorOptions } from '../../js/data_formats/typescript-convertor.js'; + +describe('TypeScriptOptionsPanel', () => { + let dom; + let parent; + let panel; + + beforeEach(() => { + dom = new JSDOM(`
`); + global.window = dom.window; + global.document = dom.window.document; + parent = document.getElementById('host'); + panel = new TypeScriptOptionsPanel(parent); + panel.addToGui(); + }); + + afterEach(() => { + dom.window.close(); + }); + + test('renders typescript options panel and returns TypeScriptConvertorOptions', () => { + expect(parent.querySelector('.typescript-options')).toBeTruthy(); + + const options = panel.getOptionsFromGui(); + + expect(options).toBeInstanceOf(TypeScriptConvertorOptions); + }); + + test('setFromOptions applies values to controls', () => { + const options = new TypeScriptConvertorOptions(); + options.options.collectionType = 'array'; + options.options.assignToVariable = false; + options.options.variableName = 'rows'; + options.options.quoteNumbers = true; + options.options.useAnonymousObjects = false; + options.options.objectClassName = 'Employee'; + options.options.blankValueBehavior = 'empty-string'; + options.options.prettyPrint = false; + options.options.prettyPrintDelimiter = ' '; + + panel.setFromOptions(options); + + expect(parent.querySelector("select[name='collectiontype']").value).toBe('array'); + expect(parent.querySelector("input[name='assigntovariable']").checked).toBe(false); + expect(parent.querySelector("input[name='variablename']").value).toBe('rows'); + expect(parent.querySelector("input[name='quotenumbers']").checked).toBe(true); + expect(parent.querySelector("input[name='useanonymousobjects']").checked).toBe(false); + expect(parent.querySelector("input[name='objectclassname']").value).toBe('Employee'); + expect(parent.querySelector("select[name='blankvaluebehavior']").value).toBe('empty-string'); + expect(parent.querySelector("input[name='prettyprint']").checked).toBe(false); + expect(parent.querySelector("select[name='prettydelimiter']").value).toBe('custom'); + expect(parent.querySelector("input[name='custom-pretty-delimiter']").value).toBe(' '); + }); + + test('setApplyCallback invokes callback with current options', () => { + const callback = jest.fn(); + panel.setApplyCallback(callback); + + parent.querySelector("input[name='quotenumbers']").checked = true; + parent.querySelector("input[name='useanonymousobjects']").checked = false; + parent.querySelector('.apply-options').click(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback.mock.calls[0][0]).toBeInstanceOf(TypeScriptConvertorOptions); + expect(callback.mock.calls[0][0].options.quoteNumbers).toBe(true); + expect(callback.mock.calls[0][0].options.useAnonymousObjects).toBe(false); + }); + + test('getOptionsFromGui round-trip with defaults', () => { + const options = panel.getOptionsFromGui(); + + expect(options.options.collectionType).toBe('list'); + expect(options.options.assignToVariable).toBe(true); + expect(options.options.variableName).toBe('data'); + expect(options.options.quoteNumbers).toBe(false); + expect(options.options.useAnonymousObjects).toBe(true); + expect(options.options.objectClassName).toBe('Row'); + expect(options.options.blankValueBehavior).toBe('null'); + expect(options.options.prettyPrint).toBe(true); + }); +});