diff --git a/docs/yaml-examples.md b/docs/yaml-examples.md new file mode 100644 index 0000000000..23ffc192aa --- /dev/null +++ b/docs/yaml-examples.md @@ -0,0 +1,339 @@ +# Example: Using YAML Support in SurveyJS Creator + +This example demonstrates how to use the new YAML support features. + +## HTML Example + +```html + + + + Survey Creator with YAML Support + + + + +
+ + + + +``` + +## React Example + +```jsx +import React, { useRef, useEffect } from 'react'; +import { SurveyCreatorModel } from 'survey-creator-core'; + +const YAMLSurveyCreator = () => { + const creatorRef = useRef(null); + const creator = useRef(null); + + useEffect(() => { + // Initialize creator + creator.current = new SurveyCreatorModel({ + showJSONEditorTab: true + }); + + // Sample YAML + const yamlSurvey = ` +title: React YAML Survey +pages: + - name: page1 + elements: + - type: text + name: userName + title: Your Name + - type: rating + name: experience + title: Rate your experience + rateMax: 5 +`; + + // Load YAML + creator.current.yamlText = yamlSurvey; + + // Render + creator.current.render(creatorRef.current); + + return () => { + creator.current?.dispose(); + }; + }, []); + + const exportToYAML = () => { + creator.current?.exportToYAMLFile('react-survey.yml'); + }; + + const importFromYAML = () => { + creator.current?.importFromYAMLFileDOM(); + }; + + const logCurrentYAML = () => { + console.log('Current YAML:'); + console.log(creator.current?.yamlText); + }; + + return ( +
+
+ + + +
+
+
+ ); +}; + +export default YAMLSurveyCreator; +``` + +## Node.js Example + +```javascript +const { SurveyYAML, SurveyCreatorModel } = require('survey-creator-core'); +const fs = require('fs'); + +// Read YAML file +const yamlContent = fs.readFileSync('survey.yml', 'utf8'); + +// Parse YAML to object +const surveyDefinition = SurveyYAML.parse(yamlContent); + +// Create creator and set survey +const creator = new SurveyCreatorModel(); +creator.JSON = surveyDefinition; + +// Make some modifications +creator.survey.title = "Modified Survey Title"; + +// Export back to YAML +const updatedYAML = creator.yamlText; +fs.writeFileSync('updated-survey.yml', updatedYAML); + +console.log('Survey processed and saved'); +``` + +## Converting Existing JSON Surveys to YAML + +```javascript +const { SurveyYAML } = require('survey-creator-core'); +const fs = require('fs'); + +// Read existing JSON survey +const jsonContent = fs.readFileSync('existing-survey.json', 'utf8'); + +// Convert to YAML +const yamlContent = SurveyYAML.jsonToYaml(jsonContent); + +// Save as YAML file +fs.writeFileSync('converted-survey.yml', yamlContent); + +console.log('Conversion complete!'); +``` + +## Error Handling Example + +```javascript +function loadSurveyFromYAML(yamlString) { + try { + // Validate YAML first + if (!SurveyYAML.isValidYAML(yamlString)) { + throw new Error('Invalid YAML format'); + } + + // Parse YAML + const surveyObj = SurveyYAML.parse(yamlString); + + // Validate required fields + if (!surveyObj.pages || !Array.isArray(surveyObj.pages)) { + throw new Error('Survey must have pages array'); + } + + // Set in creator + creator.JSON = surveyObj; + + return { success: true }; + } catch (error) { + console.error('Failed to load survey:', error.message); + return { + success: false, + error: error.message + }; + } +} +``` + +## Sample YAML Survey Files + +### Basic Survey +```yaml +# survey-basic.yml +title: Basic Customer Survey +description: We value your feedback +pages: + - name: feedback + elements: + - type: text + name: name + title: Your Name + isRequired: true + - type: rating + name: satisfaction + title: Satisfaction Rating + rateMax: 5 + - type: comment + name: comments + title: Additional Comments +``` + +### Advanced Survey with Logic +```yaml +# survey-advanced.yml +title: Product Feedback Survey +pages: + - name: product-usage + elements: + - type: radiogroup + name: hasUsedProduct + title: Have you used our product? + choices: + - value: yes + text: "Yes" + - value: no + text: "No" + + - type: dropdown + name: productVersion + title: Which version did you use? + visibleIf: "{hasUsedProduct} = 'yes'" + choices: + - v1.0 + - v2.0 + - v3.0 + + - type: matrix + name: features + title: Rate these features + visibleIf: "{hasUsedProduct} = 'yes'" + columns: + - value: 1 + text: Poor + - value: 2 + text: Fair + - value: 3 + text: Good + - value: 4 + text: Very Good + - value: 5 + text: Excellent + rows: + - value: usability + text: Ease of Use + - value: performance + text: Performance + - value: design + text: Design + +triggers: + - type: skip + expression: "{hasUsedProduct} = 'no'" + gotoName: feedback + + - type: complete + expression: "{hasUsedProduct} = 'yes' and {features.usability} >= 4" + +calculatedValues: + - name: avgRating + expression: "({features.usability} + {features.performance} + {features.design}) / 3" +``` \ No newline at end of file diff --git a/docs/yaml-support.md b/docs/yaml-support.md new file mode 100644 index 0000000000..10aa94bab2 --- /dev/null +++ b/docs/yaml-support.md @@ -0,0 +1,240 @@ +# YAML Support for SurveyJS Creator + +This document describes the YAML support functionality added to SurveyJS Creator. + +## Overview + +YAML support allows users to work with survey definitions in YAML format as an alternative to JSON. This includes: + +- Converting between JSON and YAML formats +- Importing survey definitions from YAML files +- Exporting survey definitions to YAML files +- YAML editor tab in the Creator UI + +## Key Components + +### 1. SurveyYAML Utility Class (`src/yaml-utils.ts`) + +The core utility for YAML operations: + +```typescript +import { SurveyYAML } from 'survey-creator-core'; + +// Parse YAML string to object +const surveyObj = SurveyYAML.parse(yamlString); + +// Convert object to YAML string +const yamlString = SurveyYAML.stringify(surveyObj); + +// Validate YAML +const isValid = SurveyYAML.isValidYAML(yamlString); + +// Convert between formats +const yamlFromJson = SurveyYAML.jsonToYaml(jsonString); +const jsonFromYaml = SurveyYAML.yamlToJson(yamlString); +``` + +### 2. SurveyCreatorBase Extensions + +New properties and methods added to the main creator class: + +```typescript +import { SurveyCreatorModel } from 'survey-creator-core'; + +const creator = new SurveyCreatorModel(); + +// Get/Set survey as YAML +creator.yamlText = yamlString; +const yamlString = creator.yamlText; + +// Export/Import methods +creator.exportToYAMLFile('my-survey.yml'); +creator.importFromYAMLFileDOM(); // Opens file dialog + +// Programmatic import +creator.importFromYAMLFile(file).then(() => { + console.log('Survey imported from YAML'); +}); + +// Utility methods +const yamlString = creator.toYAML(); +creator.fromYAML(yamlString); +``` + +### 3. YAML Editor Plugin (`src/components/tabs/yaml-editor-plugin.ts`) + +A new editor tab that extends the JSON editor functionality: + +```typescript +import { TabYamlEditorPlugin } from 'survey-creator-core'; + +// The plugin provides: +// - YAML syntax highlighting (when integrated with code editors) +// - YAML validation with error reporting +// - Export/Import actions in the toolbar +// - Real-time conversion between YAML and survey model +``` + +### 4. File Operations (`src/components/yaml-export-import.ts`) + +Helper functions for file import/export: + +```typescript +import { YAMLFileHelper, createExportYAMLAction, createImportYAMLAction } from 'survey-creator-core'; + +// Create actions for toolbars +const exportAction = createExportYAMLAction(() => { + // Export logic +}); + +const importAction = createImportYAMLAction(() => { + // Import logic +}, true); // needSeparator + +// File operations +YAMLFileHelper.exportToYAMLFile(surveyObj, 'survey.yml'); +YAMLFileHelper.importFromYAMLFile(file).then(obj => { + // Handle imported object +}); +``` + +## Usage Examples + +### Basic Survey Definition in YAML + +```yaml +title: Customer Satisfaction Survey +description: Please help us improve our service +pages: + - name: page1 + title: Basic Information + elements: + - type: text + name: firstName + title: First Name + isRequired: true + - type: text + name: lastName + title: Last Name + isRequired: true + - type: dropdown + name: country + title: Country + choices: + - value: us + text: United States + - value: ca + text: Canada + - value: uk + text: United Kingdom + + - name: page2 + title: Feedback + elements: + - type: rating + name: satisfaction + title: Overall Satisfaction + rateMin: 1 + rateMax: 10 + rateStep: 1 + - type: comment + name: suggestions + title: Additional Comments + placeholder: Please share your suggestions... +``` + +### Advanced Features + +```yaml +# Survey with logic and calculated values +title: Advanced Survey Example +pages: + - name: demographics + elements: + - type: radiogroup + name: hasChildren + title: Do you have children? + choices: + - value: yes + text: "Yes" + - value: no + text: "No" + + - type: text + name: numberOfChildren + title: How many children do you have? + inputType: number + visibleIf: "{hasChildren} = 'yes'" + +# Triggers +triggers: + - type: complete + expression: "{satisfaction} >= 8" + +# Calculated values +calculatedValues: + - name: totalScore + expression: "{satisfaction} + {likelihood}" + +# Custom CSS +css: + question: + title: "font-weight: bold;" +``` + +### Error Handling + +```typescript +try { + const surveyObj = SurveyYAML.parse(yamlString); + creator.JSON = surveyObj; +} catch (error) { + console.error('YAML parsing failed:', error.message); + // Handle error - show user-friendly message +} +``` + +## Integration with Existing Code + +The YAML support is designed to be a seamless extension of existing JSON functionality: + +1. **No Breaking Changes**: All existing JSON APIs continue to work +2. **Interchangeable Formats**: Easily convert between JSON and YAML +3. **Consistent API**: YAML methods follow the same patterns as JSON methods +4. **Error Handling**: Comprehensive error messages for debugging + +## File Format Support + +Supported file extensions: +- `.yaml` +- `.yml` + +MIME type: `text/yaml` + +## Localization + +New localization keys added to English translation: +- `yamlExportButton`: "Export to YAML" +- `yamlImportButton`: "Import from YAML" + +## Testing + +Comprehensive test coverage includes: +- YAML parsing and stringification +- Round-trip conversion (JSON ↔ YAML ↔ JSON) +- Error handling for invalid YAML +- File import/export operations +- Special characters and Unicode support +- Empty and null value handling + +## Browser Compatibility + +The YAML functionality works in all modern browsers that support: +- ES6 features (Promise, const/let, arrow functions) +- File API for file import/export +- Blob API for file downloads + +## Dependencies + +- `js-yaml`: ^4.1.0 - Core YAML parsing and stringification +- `@types/js-yaml`: ^4.0.5 - TypeScript type definitions \ No newline at end of file diff --git a/packages/survey-creator-core/package.json b/packages/survey-creator-core/package.json index 723b507956..0b17b2d3a7 100644 --- a/packages/survey-creator-core/package.json +++ b/packages/survey-creator-core/package.json @@ -29,6 +29,7 @@ "lint:fix": "eslint . --max-warnings=0 --fix" }, "dependencies": { + "js-yaml": "^4.1.0", "survey-core": "../../../survey-library/packages/survey-core/build" }, "devDependencies": { @@ -38,6 +39,7 @@ "@rollup/plugin-typescript": "^12.1.2", "@types/ace": "0.0.32", "@types/jest": "^26.0.24", + "@types/js-yaml": "^4.0.9", "@types/node": "7.0.4", "@types/papaparse": "^5.0.4", "@types/qunit": "2.0.31", diff --git a/packages/survey-creator-core/src/components/tabs/yaml-editor-plugin.ts b/packages/survey-creator-core/src/components/tabs/yaml-editor-plugin.ts new file mode 100644 index 0000000000..96bbbfe6dd --- /dev/null +++ b/packages/survey-creator-core/src/components/tabs/yaml-editor-plugin.ts @@ -0,0 +1,162 @@ +import { Action } from "survey-core"; +import { SurveyCreatorModel } from "../../creator-base"; +import { ICreatorPlugin } from "../../creator-settings"; +import { JsonEditorBaseModel, TabJsonEditorBasePlugin } from "./json-editor-plugin"; +import { SurveyYAML } from "../../yaml-utils"; +import { saveToFileHandler } from "../../utils/html-element-utils"; + +export class YamlEditorModel extends JsonEditorBaseModel { + protected getText(): string { + try { + return this.creator.yamlText; + } catch (error) { + return "# Error converting to YAML: " + (error as Error).message; + } + } + + protected setText(val: string): void { + try { + this.creator.yamlText = val; + this.isJSONChanged = true; + } catch (error) { + // Don't set the text if there's an error, just mark as changed + this.isJSONChanged = true; + } + } + + public processErrors(text: string): void { + const errors: any[] = []; + try { + SurveyYAML.parse(text); + this.setErrors([]); + } catch (error) { + errors.push({ + position: { start: 0, end: text.length }, + text: (error as Error).message + }); + this.setErrors(errors); + } + } +} + +export class TabYamlEditorPlugin extends TabJsonEditorBasePlugin implements ICreatorPlugin { + public static id = "yaml-editor"; + private yamlModel: YamlEditorModel; + + constructor(creator: SurveyCreatorModel) { + super(creator); + this.yamlModel = new YamlEditorModel(creator); + } + + public get model(): YamlEditorModel { + return this.yamlModel; + } + + public activate(): void { + this.model.onPluginActivate(); + } + + public deactivate(): boolean { + if (this.model.isJSONChanged) { + this.model.processErrors(this.model.text); + if (!this.model.hasErrors) { + this.creator.yamlText = this.model.text; + } + this.model.isJSONChanged = false; + } + return true; + } + + public createActions(): Action[] { + const actions: Action[] = []; + + // Export to YAML file action + const exportAction = new Action({ + id: "svd-yaml-export", + iconName: "icon-download", + iconSize: "auto", + locTitleName: "ed.yamlExportButton", + locTooltipName: "ed.yamlExportButton", + mode: "small", + component: "sv-action-bar-item", + action: () => { + this.exportToYAMLFile("survey.yml"); + } + }); + actions.push(exportAction); + + // Import from YAML file action + const importAction = new Action({ + id: "svd-yaml-import", + iconName: "icon-load", + iconSize: "auto", + locTitleName: "ed.yamlImportButton", + locTooltipName: "ed.yamlImportButton", + mode: "small", + component: "sv-action-bar-item", + action: () => { + this.importFromYAMLFileDOM(); + } + }); + actions.push(importAction); + + // Copy YAML to clipboard action + const copyAction = new Action({ + id: "svd-yaml-copy", + iconName: "icon-copy", + iconSize: "auto", + locTitleName: "ed.copyToClipboard", + locTooltipName: "ed.copyToClipboard", + mode: "small", + component: "sv-action-bar-item", + action: () => { + if (typeof navigator !== "undefined" && navigator.clipboard) { + navigator.clipboard.writeText(this.model.text); + } + } + }); + actions.push(copyAction); + + return actions; + } + + public exportToYAMLFile(fileName: string): void { + if (this.model) { + const yamlBlob = new Blob([this.model.text], { type: "text/yaml" }); + saveToFileHandler(fileName, yamlBlob); + } + } + + public importFromYAMLFile(file: File, callback?: (yaml: string) => void): void { + const fileReader = new FileReader(); + fileReader.onload = (e) => { + const yamlText = fileReader.result as string; + if (this.model) { + this.model.text = yamlText; + this.model.isJSONChanged = true; + } + if (callback) callback(yamlText); + }; + fileReader.readAsText(file); + } + + public importFromYAMLFileDOM(): void { + if (typeof document === "undefined") return; + + const inputElement = document.createElement("input"); + inputElement.type = "file"; + inputElement.accept = ".yaml,.yml"; + inputElement.style.display = "none"; + + inputElement.onchange = () => { + if (inputElement.files && inputElement.files.length > 0) { + this.importFromYAMLFile(inputElement.files[0]); + inputElement.value = ""; // Reset for subsequent imports + } + }; + + document.body.appendChild(inputElement); + inputElement.click(); + document.body.removeChild(inputElement); + } +} \ No newline at end of file diff --git a/packages/survey-creator-core/src/components/yaml-export-import.ts b/packages/survey-creator-core/src/components/yaml-export-import.ts new file mode 100644 index 0000000000..551565b318 --- /dev/null +++ b/packages/survey-creator-core/src/components/yaml-export-import.ts @@ -0,0 +1,113 @@ +import { Action } from "survey-core"; +import { SurveyYAML } from "../yaml-utils"; + +/** + * Creates an action for exporting survey to YAML file + */ +export function createExportYAMLAction(action: () => void, isInEditor: boolean = false): Action { + return new Action({ + id: "svc-yaml-export", + iconName: "icon-download", + iconSize: "auto", + locTitleName: "ed.yamlExportButton", + locTooltipName: "ed.yamlExportButton", + mode: isInEditor ? "large" : "small", + component: "sv-action-bar-item", + action: action + }); +} + +/** + * Creates an action for importing survey from YAML file + */ +export function createImportYAMLAction(action: () => void, needSeparator: boolean, isInEditor: boolean = false): Action { + return new Action({ + id: "svc-yaml-import", + iconName: "icon-load", + iconSize: "auto", + locTitleName: "ed.yamlImportButton", + locTooltipName: "ed.yamlImportButton", + mode: isInEditor ? "large" : "small", + component: "sv-action-bar-item", + needSeparator: needSeparator, + action: action + }); +} + +/** + * Helper class for YAML file operations + */ +export class YAMLFileHelper { + /** + * Export object to YAML file + */ + public static exportToYAMLFile(obj: any, fileName: string): void { + if (typeof window === "undefined") return; + + try { + const yamlData = SurveyYAML.stringify(obj); + const blob = new Blob([yamlData], { type: "text/yaml" }); + + if ((window.navigator as any)["msSaveOrOpenBlob"]) { + (window.navigator as any)["msSaveBlob"](blob, fileName); + } else { + const elem = window.document.createElement("a"); + elem.href = window.URL.createObjectURL(blob); + elem.download = fileName; + document.body.appendChild(elem); + elem.click(); + document.body.removeChild(elem); + } + } catch (error) { + throw new Error(`Failed to export YAML file: ${(error as Error).message}`); + } + } + + /** + * Import YAML file and parse to object + */ + public static importFromYAMLFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (event) => { + try { + const yamlString = event.target?.result as string; + const obj = SurveyYAML.parse(yamlString); + resolve(obj); + } catch (error) { + reject(new Error(`Failed to parse YAML file: ${(error as Error).message}`)); + } + }; + + reader.onerror = () => { + reject(new Error("Failed to read file")); + }; + + reader.readAsText(file); + }); + } + + /** + * Create file input element for YAML import + */ + public static createFileInput(onFileSelected: (file: File) => void): HTMLInputElement { + if (typeof document === "undefined") { + throw new Error("Document is not available"); + } + + const inputElement = document.createElement("input"); + inputElement.type = "file"; + inputElement.accept = ".yaml,.yml"; + inputElement.style.display = "none"; + + inputElement.onchange = () => { + if (inputElement.files && inputElement.files.length > 0) { + onFileSelected(inputElement.files[0]); + inputElement.value = ""; // Reset for subsequent imports + } + }; + + return inputElement; + } +} \ No newline at end of file diff --git a/packages/survey-creator-core/src/creator-base.ts b/packages/survey-creator-core/src/creator-base.ts index 4fa2f9cf4f..016df2b994 100644 --- a/packages/survey-creator-core/src/creator-base.ts +++ b/packages/survey-creator-core/src/creator-base.ts @@ -14,6 +14,7 @@ import { import { ICreatorPlugin, ISurveyCreatorOptions, settings, ICollectionItemAllowOperations, ITabOptions } from "./creator-settings"; import { editorLocalization, setupLocale } from "./editorLocalization"; import { SurveyJSON5 } from "./json5"; +import { SurveyYAML } from "./yaml-utils"; import { DragDropChoices } from "survey-core"; import { IsTouch } from "survey-core"; import { QuestionConverter } from "./questionconverter"; @@ -2906,6 +2907,118 @@ export class SurveyCreatorModel extends Base this.initSurveyWithJSON(val, true); } } + + /** + * A survey YAML schema as a string. + * + * This property allows you to get or set the YAML schema of a survey being configured. + */ + public get yamlText(): string { + try { + const json = this.JSON; + return SurveyYAML.stringify(json); + } catch (error) { + throw new Error(`Failed to convert survey to YAML: ${error.message}`); + } + } + public set yamlText(value: string) { + try { + const obj = SurveyYAML.parse(value); + this.JSON = obj; + } catch (error) { + throw new Error(`Failed to parse YAML: ${error.message}`); + } + } + + /** + * Export survey as YAML string + */ + public toYAML(): string { + return this.yamlText; + } + + /** + * Import survey from YAML string + */ + public fromYAML(yamlString: string): void { + this.yamlText = yamlString; + } + + /** + * Export survey definition to YAML file + */ + public exportToYAMLFile(fileName: string = "survey.yml"): void { + try { + const yamlData = this.yamlText; + if (typeof window === "undefined") return; + + const blob = new Blob([yamlData], { type: "text/yaml" }); + + if ((window.navigator as any)["msSaveOrOpenBlob"]) { + (window.navigator as any)["msSaveBlob"](blob, fileName); + } else { + const elem = window.document.createElement("a"); + elem.href = window.URL.createObjectURL(blob); + elem.download = fileName; + document.body.appendChild(elem); + elem.click(); + document.body.removeChild(elem); + } + } catch (error) { + throw new Error(`Failed to export YAML file: ${(error as Error).message}`); + } + } + + /** + * Import survey definition from YAML file + */ + public importFromYAMLFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (event) => { + try { + const yamlString = event.target?.result as string; + this.yamlText = yamlString; + resolve(); + } catch (error) { + reject(new Error(`Failed to parse YAML file: ${(error as Error).message}`)); + } + }; + + reader.onerror = () => { + reject(new Error("Failed to read file")); + }; + + reader.readAsText(file); + }); + } + + /** + * Create DOM file input for YAML import + */ + public importFromYAMLFileDOM(): void { + if (typeof document === "undefined") return; + + const inputElement = document.createElement("input"); + inputElement.type = "file"; + inputElement.accept = ".yaml,.yml"; + inputElement.style.display = "none"; + + inputElement.onchange = () => { + if (inputElement.files && inputElement.files.length > 0) { + this.importFromYAMLFile(inputElement.files[0]).catch(error => { + console.error("Failed to import YAML file:", error); + // You could emit an event here for error handling + }); + inputElement.value = ""; // Reset for subsequent imports + } + }; + + document.body.appendChild(inputElement); + inputElement.click(); + document.body.removeChild(inputElement); + } public loadSurvey(surveyId: string): void { // eslint-disable-next-line no-console console.warn("Self-hosted Form Library no longer supports integration with SurveyJS Demo Service. Learn more: https://surveyjs.io/stay-updated/release-notes/v2.0.0#form-library-removes-apis-for-integration-with-surveyjs-demo-service"); diff --git a/packages/survey-creator-core/src/entries/index.ts b/packages/survey-creator-core/src/entries/index.ts index 7e9f405fc1..54b9be5c78 100644 --- a/packages/survey-creator-core/src/entries/index.ts +++ b/packages/survey-creator-core/src/entries/index.ts @@ -18,6 +18,7 @@ export * from "../creator-responsivity-manager"; export * from "../components/tabs/json-editor-ace"; export * from "../components/tabs/json-editor-plugin"; export * from "../components/tabs/json-editor-textarea"; +export * from "../components/tabs/yaml-editor-plugin"; export * from "../components/tabs/test"; export * from "../components/tabs/test-plugin"; export * from "../components/tabs/theme-custom-questions/color-alpha"; @@ -99,6 +100,8 @@ export { MenuButton } from "../utils/actions"; export * from "../question-editor/definition"; export * from "../question-editor/properties"; export * from "../survey-helper"; +export * from "../yaml-utils"; +export * from "../components/yaml-export-import"; export * from "../utils/resizer"; export * from "../plugins/undo-redo"; export * from "../plugins/undo-redo/undo-redo-manager"; diff --git a/packages/survey-creator-core/src/localization/english.ts b/packages/survey-creator-core/src/localization/english.ts index 5c7a629c56..56c8c64cf8 100644 --- a/packages/survey-creator-core/src/localization/english.ts +++ b/packages/survey-creator-core/src/localization/english.ts @@ -163,6 +163,8 @@ export var enStrings = { translationNoStrings: "No strings to translate. Please change the filter.", translationExportToSCVButton: "Export to CSV", translationImportFromSCVButton: "Import from CSV", + yamlExportButton: "Export to YAML", + yamlImportButton: "Import from YAML", translateUsigAI: "Auto-translate All", translateUsigAIFrom: "Translate from: ", translationDialogTitle: "Untranslated strings", diff --git a/packages/survey-creator-core/src/yaml-utils.ts b/packages/survey-creator-core/src/yaml-utils.ts new file mode 100644 index 0000000000..ade299c235 --- /dev/null +++ b/packages/survey-creator-core/src/yaml-utils.ts @@ -0,0 +1,83 @@ +import * as yaml from "js-yaml"; + +/** + * Utility class for YAML operations in Survey Creator + */ +export class SurveyYAML { + /** + * Parse YAML string to JavaScript object + * @param yamlString - The YAML string to parse + * @returns Parsed JavaScript object + * @throws Error if YAML parsing fails + */ + public static parse(yamlString: string): any { + try { + return yaml.load(yamlString); + } catch (error) { + throw new Error(`YAML parsing error: ${error.message}`); + } + } + + /** + * Convert JavaScript object to YAML string + * @param obj - The object to convert to YAML + * @param options - Optional YAML stringification options + * @returns YAML string representation + */ + public static stringify(obj: any, options?: yaml.DumpOptions): string { + try { + const defaultOptions: yaml.DumpOptions = { + indent: 2, + lineWidth: 120, + noRefs: true, + sortKeys: false, + ...options + }; + return yaml.dump(obj, defaultOptions); + } catch (error) { + throw new Error(`YAML stringification error: ${error.message}`); + } + } + + /** + * Check if a string is valid YAML + * @param yamlString - The string to validate + * @returns true if valid YAML, false otherwise + */ + public static isValidYAML(yamlString: string): boolean { + try { + yaml.load(yamlString); + return true; + } catch { + return false; + } + } + + /** + * Convert JSON string to YAML string + * @param jsonString - JSON string to convert + * @returns YAML string + */ + public static jsonToYaml(jsonString: string): string { + try { + const obj = JSON.parse(jsonString); + return this.stringify(obj); + } catch (error) { + throw new Error(`JSON to YAML conversion error: ${error.message}`); + } + } + + /** + * Convert YAML string to JSON string + * @param yamlString - YAML string to convert + * @returns JSON string + */ + public static yamlToJson(yamlString: string): string { + try { + const obj = this.parse(yamlString); + return JSON.stringify(obj, null, 2); + } catch (error) { + throw new Error(`YAML to JSON conversion error: ${error.message}`); + } + } +} \ No newline at end of file diff --git a/packages/survey-creator-core/tests/yaml-file-operations.tests.ts b/packages/survey-creator-core/tests/yaml-file-operations.tests.ts new file mode 100644 index 0000000000..217f08cfaa --- /dev/null +++ b/packages/survey-creator-core/tests/yaml-file-operations.tests.ts @@ -0,0 +1,272 @@ +import { YAMLFileHelper, createExportYAMLAction, createImportYAMLAction } from "../src/components/yaml-export-import"; + +// Mock DOM environment for testing +const mockWindow = { + document: { + createElement: jest.fn(), + body: { + appendChild: jest.fn(), + removeChild: jest.fn() + } + }, + URL: { + createObjectURL: jest.fn(), + revokeObjectURL: jest.fn() + }, + navigator: {} +}; + +const mockBlob = jest.fn(); +const mockFileReader = jest.fn(); + +// Mock globals +Object.defineProperty(global, 'window', { value: mockWindow }); +Object.defineProperty(global, 'document', { value: mockWindow.document }); +Object.defineProperty(global, 'Blob', { value: mockBlob }); +Object.defineProperty(global, 'FileReader', { value: mockFileReader }); + +describe("YAML Export/Import Functionality", () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Setup Blob mock + mockBlob.mockImplementation((content, options) => ({ + content, + type: options.type + })); + + // Setup createElement mock + mockWindow.document.createElement.mockImplementation((tagName) => { + if (tagName === 'input') { + return { + type: '', + accept: '', + style: { display: '' }, + value: '', + files: null, + onchange: null, + click: jest.fn() + }; + } + if (tagName === 'a') { + return { + href: '', + download: '', + click: jest.fn() + }; + } + return {}; + }); + + // Setup URL mock + mockWindow.URL.createObjectURL.mockReturnValue('blob:mock-url'); + }); + + describe("YAMLFileHelper", () => { + test("should create export YAML action", () => { + const mockAction = jest.fn(); + const action = createExportYAMLAction(mockAction, false); + + expect(action).toBeDefined(); + expect(action.id).toBe("svc-yaml-export"); + expect(action.iconName).toBe("icon-download"); + expect(action.locTitleName).toBe("ed.yamlExportButton"); + expect(action.mode).toBe("small"); + }); + + test("should create export YAML action for editor", () => { + const mockAction = jest.fn(); + const action = createExportYAMLAction(mockAction, true); + + expect(action.mode).toBe("large"); + }); + + test("should create import YAML action", () => { + const mockAction = jest.fn(); + const action = createImportYAMLAction(mockAction, true, false); + + expect(action).toBeDefined(); + expect(action.id).toBe("svc-yaml-import"); + expect(action.iconName).toBe("icon-load"); + expect(action.locTitleName).toBe("ed.yamlImportButton"); + expect(action.needSeparator).toBe(true); + expect(action.mode).toBe("small"); + }); + + test("should export to YAML file", () => { + const testObj = { + title: "Test Survey", + pages: [{ + name: "page1", + elements: [{ + type: "text", + name: "question1" + }] + }] + }; + + YAMLFileHelper.exportToYAMLFile(testObj, "test-survey.yml"); + + expect(mockBlob).toHaveBeenCalledWith( + expect.stringContaining("title: Test Survey"), + { type: "text/yaml" } + ); + expect(mockWindow.document.createElement).toHaveBeenCalledWith("a"); + expect(mockWindow.URL.createObjectURL).toHaveBeenCalled(); + expect(mockWindow.document.body.appendChild).toHaveBeenCalled(); + expect(mockWindow.document.body.removeChild).toHaveBeenCalled(); + }); + + test("should handle export error gracefully", () => { + // Mock an object that will cause YAML stringify to fail + const circularObj = {}; + circularObj.self = circularObj; + + expect(() => { + YAMLFileHelper.exportToYAMLFile(circularObj, "test.yml"); + }).toThrow("Failed to export YAML file"); + }); + + test("should create file input for import", () => { + const mockCallback = jest.fn(); + const mockInput = { + type: '', + accept: '', + style: { display: '' }, + onchange: null + }; + + mockWindow.document.createElement.mockReturnValue(mockInput); + + const input = YAMLFileHelper.createFileInput(mockCallback); + + expect(input.type).toBe("file"); + expect(input.accept).toBe(".yaml,.yml"); + expect(input.style.display).toBe("none"); + expect(input.onchange).toBeDefined(); + }); + + test("should handle file input callback", () => { + const mockCallback = jest.fn(); + const mockFile = new File(['test: value'], 'test.yml', { type: 'text/yaml' }); + const mockInput = { + type: 'file', + accept: '.yaml,.yml', + style: { display: 'none' }, + value: '', + files: [mockFile], + onchange: null + }; + + mockWindow.document.createElement.mockReturnValue(mockInput); + + const input = YAMLFileHelper.createFileInput(mockCallback); + + // Simulate file selection + input.files = [mockFile]; + input.onchange(); + + expect(mockCallback).toHaveBeenCalledWith(mockFile); + expect(input.value).toBe(''); + }); + + test("should import from YAML file", async () => { + const yamlContent = ` +title: Test Survey +pages: + - name: page1 + elements: + - type: text + name: question1 +`; + + const mockFile = new File([yamlContent], 'test.yml', { type: 'text/yaml' }); + + // Mock FileReader + const mockFileReader = { + onload: null, + onerror: null, + readAsText: jest.fn(), + result: yamlContent + }; + + global.FileReader = jest.fn(() => mockFileReader); + + const promise = YAMLFileHelper.importFromYAMLFile(mockFile); + + // Simulate FileReader onload + mockFileReader.onload({ target: { result: yamlContent } }); + + const result = await promise; + + expect(result).toBeDefined(); + expect(result.title).toBe("Test Survey"); + expect(result.pages[0].name).toBe("page1"); + expect(mockFileReader.readAsText).toHaveBeenCalledWith(mockFile); + }); + + test("should handle import file read error", async () => { + const mockFile = new File(['test: value'], 'test.yml', { type: 'text/yaml' }); + + const mockFileReader = { + onload: null, + onerror: null, + readAsText: jest.fn() + }; + + global.FileReader = jest.fn(() => mockFileReader); + + const promise = YAMLFileHelper.importFromYAMLFile(mockFile); + + // Simulate FileReader error + mockFileReader.onerror(); + + await expect(promise).rejects.toThrow("Failed to read file"); + }); + + test("should handle import YAML parse error", async () => { + const invalidYaml = "invalid: yaml: structure:"; + const mockFile = new File([invalidYaml], 'test.yml', { type: 'text/yaml' }); + + const mockFileReader = { + onload: null, + onerror: null, + readAsText: jest.fn() + }; + + global.FileReader = jest.fn(() => mockFileReader); + + const promise = YAMLFileHelper.importFromYAMLFile(mockFile); + + // Simulate FileReader onload with invalid YAML + mockFileReader.onload({ target: { result: invalidYaml } }); + + await expect(promise).rejects.toThrow("Failed to parse YAML file"); + }); + + test("should handle missing document for createFileInput", () => { + const originalDocument = global.document; + delete global.document; + + expect(() => { + YAMLFileHelper.createFileInput(() => {}); + }).toThrow("Document is not available"); + + global.document = originalDocument; + }); + + test("should handle missing window for export", () => { + const originalWindow = global.window; + delete global.window; + + const testObj = { test: "value" }; + + // Should return early without throwing + expect(() => { + YAMLFileHelper.exportToYAMLFile(testObj, "test.yml"); + }).not.toThrow(); + + global.window = originalWindow; + }); + }); +}); \ No newline at end of file diff --git a/packages/survey-creator-core/tests/yaml-support.tests.ts b/packages/survey-creator-core/tests/yaml-support.tests.ts new file mode 100644 index 0000000000..7415e85c69 --- /dev/null +++ b/packages/survey-creator-core/tests/yaml-support.tests.ts @@ -0,0 +1,214 @@ +import { SurveyCreatorModel } from "../src/creator-base"; +import { SurveyYAML } from "../src/yaml-utils"; + +test("SurveyYAML - parse valid YAML", () => { + const yamlString = ` +pages: + - name: "page1" + elements: + - type: "text" + name: "firstName" + title: "First Name" + - type: "text" + name: "lastName" + title: "Last Name" +`; + + const result = SurveyYAML.parse(yamlString); + expect(result).toBeDefined(); + expect(result.pages).toBeDefined(); + expect(result.pages.length).toBe(1); + expect(result.pages[0].elements.length).toBe(2); + expect(result.pages[0].elements[0].name).toBe("firstName"); +}); + +test("SurveyYAML - stringify object to YAML", () => { + const obj = { + pages: [{ + name: "page1", + elements: [{ + type: "text", + name: "firstName", + title: "First Name" + }] + }] + }; + + const yamlString = SurveyYAML.stringify(obj); + expect(yamlString).toBeDefined(); + expect(yamlString).toContain("pages:"); + expect(yamlString).toContain("name: page1"); + expect(yamlString).toContain("type: text"); + expect(yamlString).toContain("name: firstName"); +}); + +test("SurveyYAML - parse invalid YAML throws error", () => { + const invalidYaml = ` +pages: + - name: "page1" + elements: + invalid: yaml: structure: +`; + + expect(() => SurveyYAML.parse(invalidYaml)).toThrow("YAML parsing error"); +}); + +test("SurveyYAML - isValidYAML returns correct values", () => { + const validYaml = "key: value\narray:\n - item1\n - item2"; + const invalidYaml = "key: value\n invalid: yaml: structure:"; + + expect(SurveyYAML.isValidYAML(validYaml)).toBe(true); + expect(SurveyYAML.isValidYAML(invalidYaml)).toBe(false); +}); + +test("SurveyYAML - jsonToYaml conversion", () => { + const jsonString = JSON.stringify({ + pages: [{ + name: "page1", + elements: [{ + type: "text", + name: "firstName" + }] + }] + }); + + const yamlString = SurveyYAML.jsonToYaml(jsonString); + expect(yamlString).toContain("pages:"); + expect(yamlString).toContain("name: page1"); + expect(yamlString).toContain("type: text"); +}); + +test("SurveyYAML - yamlToJson conversion", () => { + const yamlString = ` +pages: + - name: page1 + elements: + - type: text + name: firstName +`; + + const jsonString = SurveyYAML.yamlToJson(yamlString); + const obj = JSON.parse(jsonString); + expect(obj.pages).toBeDefined(); + expect(obj.pages[0].name).toBe("page1"); + expect(obj.pages[0].elements[0].type).toBe("text"); +}); + +test("SurveyCreator - yamlText property getter", () => { + const creator = new SurveyCreatorModel({}); + + // Set a simple survey + creator.JSON = { + pages: [{ + name: "page1", + elements: [{ + type: "text", + name: "firstName", + title: "First Name" + }] + }] + }; + + const yamlText = creator.yamlText; + expect(yamlText).toBeDefined(); + expect(yamlText).toContain("pages:"); + expect(yamlText).toContain("name: page1"); + expect(yamlText).toContain("type: text"); + expect(yamlText).toContain("name: firstName"); +}); + +test("SurveyCreator - yamlText property setter", () => { + const creator = new SurveyCreatorModel({}); + + const yamlString = ` +pages: + - name: testPage + elements: + - type: text + name: testQuestion + title: Test Question +`; + + creator.yamlText = yamlString; + + const json = creator.JSON; + expect(json.pages).toBeDefined(); + expect(json.pages.length).toBe(1); + expect(json.pages[0].name).toBe("testPage"); + expect(json.pages[0].elements.length).toBe(1); + expect(json.pages[0].elements[0].name).toBe("testQuestion"); + expect(json.pages[0].elements[0].title).toBe("Test Question"); +}); + +test("SurveyCreator - toYAML method", () => { + const creator = new SurveyCreatorModel({}); + + creator.JSON = { + pages: [{ + name: "page1", + elements: [{ + type: "text", + name: "firstName" + }] + }] + }; + + const yamlString = creator.toYAML(); + expect(yamlString).toBeDefined(); + expect(yamlString).toContain("pages:"); + expect(yamlString).toContain("name: page1"); +}); + +test("SurveyCreator - fromYAML method", () => { + const creator = new SurveyCreatorModel({}); + + const yamlString = ` +pages: + - name: testPage + elements: + - type: text + name: testQuestion +`; + + creator.fromYAML(yamlString); + + const json = creator.JSON; + expect(json.pages[0].name).toBe("testPage"); + expect(json.pages[0].elements[0].name).toBe("testQuestion"); +}); + +test("SurveyCreator - yamlText getter/setter round trip", () => { + const creator = new SurveyCreatorModel({}); + + const originalYaml = ` +pages: + - name: testPage + elements: + - type: text + name: testQuestion + title: Test Question + - type: dropdown + name: testDropdown + title: Test Dropdown + choices: + - value: option1 + text: Option 1 + - value: option2 + text: Option 2 +`; + + // Set YAML + creator.yamlText = originalYaml; + + // Get YAML back + const retrievedYaml = creator.yamlText; + + // Parse both to objects for comparison (since formatting might differ) + const originalObj = SurveyYAML.parse(originalYaml); + const retrievedObj = SurveyYAML.parse(retrievedYaml); + + expect(retrievedObj.pages[0].name).toBe(originalObj.pages[0].name); + expect(retrievedObj.pages[0].elements.length).toBe(originalObj.pages[0].elements.length); + expect(retrievedObj.pages[0].elements[0].name).toBe(originalObj.pages[0].elements[0].name); + expect(retrievedObj.pages[0].elements[1].choices.length).toBe(originalObj.pages[0].elements[1].choices.length); +}); \ No newline at end of file diff --git a/packages/survey-creator-core/tests/yaml-utils.tests.ts b/packages/survey-creator-core/tests/yaml-utils.tests.ts new file mode 100644 index 0000000000..f614608a94 --- /dev/null +++ b/packages/survey-creator-core/tests/yaml-utils.tests.ts @@ -0,0 +1,274 @@ +import { SurveyYAML } from "../src/yaml-utils"; + +describe("SurveyYAML Utility Tests", () => { + test("should parse valid YAML string", () => { + const yamlString = ` +name: testSurvey +pages: + - name: page1 + elements: + - type: text + name: firstName + title: First Name +`; + + const result = SurveyYAML.parse(yamlString); + expect(result).toBeDefined(); + expect(result.name).toBe("testSurvey"); + expect(result.pages).toBeDefined(); + expect(Array.isArray(result.pages)).toBe(true); + expect(result.pages.length).toBe(1); + expect(result.pages[0].name).toBe("page1"); + expect(result.pages[0].elements.length).toBe(1); + expect(result.pages[0].elements[0].type).toBe("text"); + expect(result.pages[0].elements[0].name).toBe("firstName"); + }); + + test("should stringify object to YAML", () => { + const obj = { + name: "testSurvey", + pages: [{ + name: "page1", + elements: [{ + type: "text", + name: "firstName", + title: "First Name" + }, { + type: "dropdown", + name: "country", + title: "Select Country", + choices: [ + { value: "us", text: "United States" }, + { value: "ca", text: "Canada" } + ] + }] + }] + }; + + const yamlString = SurveyYAML.stringify(obj); + expect(yamlString).toBeDefined(); + expect(typeof yamlString).toBe("string"); + expect(yamlString).toContain("name: testSurvey"); + expect(yamlString).toContain("pages:"); + expect(yamlString).toContain("- name: page1"); + expect(yamlString).toContain("type: text"); + expect(yamlString).toContain("name: firstName"); + expect(yamlString).toContain("type: dropdown"); + expect(yamlString).toContain("choices:"); + }); + + test("should throw error for invalid YAML", () => { + const invalidYaml = ` +pages: + - name: page1 + elements: + invalid: yaml: structure: + more: invalid: syntax +`; + + expect(() => SurveyYAML.parse(invalidYaml)).toThrow("YAML parsing error"); + }); + + test("should validate YAML correctly", () => { + const validYaml = "name: test\nvalue: 123\narray:\n - item1\n - item2"; + const invalidYaml = "name: test\ninvalid: yaml: structure:"; + + expect(SurveyYAML.isValidYAML(validYaml)).toBe(true); + expect(SurveyYAML.isValidYAML(invalidYaml)).toBe(false); + expect(SurveyYAML.isValidYAML("")).toBe(true); // Empty string is valid YAML + }); + + test("should convert JSON string to YAML", () => { + const jsonString = JSON.stringify({ + pages: [{ + name: "page1", + elements: [{ + type: "text", + name: "firstName" + }] + }] + }); + + const yamlString = SurveyYAML.jsonToYaml(jsonString); + expect(yamlString).toBeDefined(); + expect(yamlString).toContain("pages:"); + expect(yamlString).toContain("name: page1"); + expect(yamlString).toContain("type: text"); + }); + + test("should convert YAML string to JSON", () => { + const yamlString = ` +pages: + - name: page1 + elements: + - type: text + name: firstName + - type: rating + name: satisfaction + rateMin: 1 + rateMax: 5 +`; + + const jsonString = SurveyYAML.yamlToJson(yamlString); + const obj = JSON.parse(jsonString); + expect(obj.pages).toBeDefined(); + expect(obj.pages[0].name).toBe("page1"); + expect(obj.pages[0].elements[0].type).toBe("text"); + expect(obj.pages[0].elements[1].rateMin).toBe(1); + expect(obj.pages[0].elements[1].rateMax).toBe(5); + }); + + test("should handle round-trip conversion", () => { + const originalObj = { + title: "Customer Satisfaction Survey", + pages: [{ + name: "page1", + elements: [{ + type: "text", + name: "firstName", + title: "First Name", + isRequired: true + }, { + type: "dropdown", + name: "country", + title: "Country", + choices: [ + { value: "us", text: "United States" }, + { value: "ca", text: "Canada" }, + { value: "uk", text: "United Kingdom" } + ] + }, { + type: "rating", + name: "satisfaction", + title: "Overall Satisfaction", + rateMin: 1, + rateMax: 10, + rateStep: 1 + }] + }] + }; + + // Convert to YAML and back + const yamlString = SurveyYAML.stringify(originalObj); + const roundTripObj = SurveyYAML.parse(yamlString); + + // Compare key properties + expect(roundTripObj.title).toBe(originalObj.title); + expect(roundTripObj.pages.length).toBe(originalObj.pages.length); + expect(roundTripObj.pages[0].name).toBe(originalObj.pages[0].name); + expect(roundTripObj.pages[0].elements.length).toBe(originalObj.pages[0].elements.length); + + // Check each element + const originalElements = originalObj.pages[0].elements; + const roundTripElements = roundTripObj.pages[0].elements; + + for (let i = 0; i < originalElements.length; i++) { + expect(roundTripElements[i].type).toBe(originalElements[i].type); + expect(roundTripElements[i].name).toBe(originalElements[i].name); + expect(roundTripElements[i].title).toBe(originalElements[i].title); + } + }); + + test("should handle special characters and Unicode", () => { + const obj = { + title: "Survey with Special Characters: !@#$%^&*()", + description: "Unicode test: 你好世界 🌍 こんにちは", + pages: [{ + name: "page1", + elements: [{ + type: "text", + name: "email", + title: "Email Address (user@domain.com)", + placeholder: "Enter your email..." + }] + }] + }; + + const yamlString = SurveyYAML.stringify(obj); + const parsedObj = SurveyYAML.parse(yamlString); + + expect(parsedObj.title).toBe(obj.title); + expect(parsedObj.description).toBe(obj.description); + expect(parsedObj.pages[0].elements[0].placeholder).toBe(obj.pages[0].elements[0].placeholder); + }); + + test("should handle nested objects and arrays", () => { + const complexObj = { + pages: [{ + name: "page1", + elements: [{ + type: "matrixdynamic", + name: "matrix", + title: "Matrix Question", + columns: [ + { name: "col1", title: "Column 1", cellType: "text" }, + { name: "col2", title: "Column 2", cellType: "dropdown", + choices: ["Option 1", "Option 2"] } + ], + choices: [1, 2, 3, 4, 5], + cellType: "text", + confirmDelete: true + }] + }], + triggers: [{ + type: "complete", + expression: "{question1} = 'yes'" + }], + calculatedValues: [{ + name: "var1", + expression: "{question1} + {question2}" + }] + }; + + const yamlString = SurveyYAML.stringify(complexObj); + const parsedObj = SurveyYAML.parse(yamlString); + + expect(parsedObj.pages[0].elements[0].columns.length).toBe(2); + expect(parsedObj.pages[0].elements[0].columns[0].cellType).toBe("text"); + expect(parsedObj.pages[0].elements[0].columns[1].choices.length).toBe(2); + expect(parsedObj.triggers.length).toBe(1); + expect(parsedObj.calculatedValues.length).toBe(1); + }); + + test("should handle custom YAML options", () => { + const obj = { + title: "Test Survey", + pages: [{ name: "page1", elements: [] }] + }; + + const customOptions = { + indent: 4, + lineWidth: 80, + sortKeys: true + }; + + const yamlString = SurveyYAML.stringify(obj, customOptions); + expect(yamlString).toBeDefined(); + expect(typeof yamlString).toBe("string"); + + // Verify we can still parse it back + const parsedObj = SurveyYAML.parse(yamlString); + expect(parsedObj.title).toBe(obj.title); + }); + + test("should handle empty and null values", () => { + const obj = { + title: "", + description: null, + pages: [], + variables: {}, + showTitle: false, + maxValue: 0 + }; + + const yamlString = SurveyYAML.stringify(obj); + const parsedObj = SurveyYAML.parse(yamlString); + + expect(parsedObj.title).toBe(""); + expect(parsedObj.description).toBe(null); + expect(Array.isArray(parsedObj.pages)).toBe(true); + expect(parsedObj.pages.length).toBe(0); + expect(parsedObj.showTitle).toBe(false); + expect(parsedObj.maxValue).toBe(0); + }); +}); \ No newline at end of file