diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a554e..7bab1b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.12.0 + +This version introduces the localization feature. Now you can localize the editor to any language you want. + ## 0.11.3 This version improves the behavior of the `Dynamic` value editor, when the sub editor contains a control visible in the property header. diff --git a/README.md b/README.md index c8a7b21..ffd4f0b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Powerful workflow editor builder for sequential workflows. Written in TypeScript * [🛠 Playground](https://nocode-js.github.io/sequential-workflow-editor/webpack-app/public/playground.html) * [📖 Editors](https://nocode-js.github.io/sequential-workflow-editor/webpack-app/public/editors.html) * [🎯 Placement Restrictions](https://nocode-js.github.io/sequential-workflow-editor/webpack-app/public/placement-restrictions.html) +* [🚩 Internationalization](https://nocode-js.github.io/sequential-workflow-editor/webpack-app/public/i18n.html) * [🚢 Vanilla JS](https://nocode-js.github.io/sequential-workflow-editor/vanilla-js-app/vanilla-js.html) Pro: diff --git a/demos/vanilla-js-app/vanilla-js.html b/demos/vanilla-js-app/vanilla-js.html index 2d430fb..474359e 100644 --- a/demos/vanilla-js-app/vanilla-js.html +++ b/demos/vanilla-js-app/vanilla-js.html @@ -23,9 +23,9 @@ } - - - + + + diff --git a/demos/webpack-app/package.json b/demos/webpack-app/package.json index 73eaa17..9e92ecd 100644 --- a/demos/webpack-app/package.json +++ b/demos/webpack-app/package.json @@ -16,10 +16,10 @@ "dependencies": { "xstate": "^4.38.2", "sequential-workflow-model": "^0.2.0", - "sequential-workflow-designer": "^0.17.0", + "sequential-workflow-designer": "^0.21.1", "sequential-workflow-machine": "^0.4.0", - "sequential-workflow-editor-model": "^0.11.3", - "sequential-workflow-editor": "^0.11.3" + "sequential-workflow-editor-model": "^0.12.0", + "sequential-workflow-editor": "^0.12.0" }, "devDependencies": { "ts-loader": "^9.4.2", diff --git a/demos/webpack-app/public/assets/i18n.css b/demos/webpack-app/public/assets/i18n.css new file mode 100644 index 0000000..76cccaa --- /dev/null +++ b/demos/webpack-app/public/assets/i18n.css @@ -0,0 +1,59 @@ +html, +body { + margin: 0; + padding: 0; + width: 100vw; + height: 100vh; + overflow: hidden; +} +body { + display: flex; + flex-direction: column; +} +body, +input, +h1, +textarea { + font: 14px/1.3em Arial, Verdana, sans-serif; +} +.header { + width: 100%; + display: flex; + align-items: center; + background: #203fd2; + color: #fff; +} +.header h1 { + margin: 0; + padding: 0; +} +.header a { + color: #fff; +} +.header .column { + padding: 10px; +} +.header .column.flex-1 { + flex: 1; +} +.header .text-center { + text-align: center; +} +.header .column.text-end { + text-align: right; +} +@media only screen and (max-width: 700px) { + .header .column.hidden-mobile { + display: none; + } +} +a { + color: #000; + text-decoration: underline; +} +a:hover { + text-decoration: none; +} +#designer { + flex: 1; +} diff --git a/demos/webpack-app/public/i18n.html b/demos/webpack-app/public/i18n.html new file mode 100644 index 0000000..bf8f0e5 --- /dev/null +++ b/demos/webpack-app/public/i18n.html @@ -0,0 +1,29 @@ + + + + + 🚩 I18n Example - Sequential Workflow Editor + + + + + + +
+
+

🚩 I18n Example

+
+
+ Language: + +
+
+ GitHub +
+
+
+ + diff --git a/demos/webpack-app/src/i18n/app.ts b/demos/webpack-app/src/i18n/app.ts new file mode 100644 index 0000000..5da379f --- /dev/null +++ b/demos/webpack-app/src/i18n/app.ts @@ -0,0 +1,156 @@ +import { EditorProvider } from 'sequential-workflow-editor'; +import { ChownStep, I18nDefinition, definitionModel } from './definition-model'; +import { Designer, Uid } from 'sequential-workflow-designer'; +import { defaultI18n } from 'sequential-workflow-editor-model'; + +import 'sequential-workflow-designer/css/designer.css'; +import 'sequential-workflow-designer/css/designer-light.css'; +import 'sequential-workflow-editor/css/editor.css'; + +const designerDict: Record> = { + pl: { + 'controlBar.resetView': 'Resetuj widok', + 'controlBar.zoomIn': 'Przybliż', + 'controlBar.zoomOut': 'Oddal', + 'controlBar.turnOnOffDragAndDrop': 'Włącz/wyłącz przeciąganie i upuszczanie', + 'controlBar.deleteSelectedStep': 'Usuń wybrany krok', + 'controlBar.undo': 'Cofnij', + 'controlBar.redo': 'Dalej', + 'smartEditor.toggle': 'Zwiń/rozwiń', + 'toolbox.title': 'Przybornik', + 'toolbox.search': 'Szukaj', + 'contextMenu.select': 'Zaznacz', + 'contextMenu.unselect': 'Odznacz', + 'contextMenu.delete': 'Usuń', + 'contextMenu.resetView': 'Resetuj widok', + 'contextMenu.duplicate': 'Duplikuj', + + // steps + 'toolbox.item.chown.label': 'Uprawnienia' + } +}; + +const editorDict: Record> = { + pl: { + 'toolbox.defaultGroupName': 'Inne', + 'stringDictionary.noItems': 'Brak elementów', + 'stringDictionary.addItem': 'Dodaj element', + 'stringDictionary.key': 'Klucz', + 'stringDictionary.value': 'Wartość', + 'stringDictionary.delete': 'Usuń', + 'stringDictionary.valueTooShort': 'Wartość musi mieć conajmniej :min znaków', + 'stringDictionary.duplicatedKey': 'Klucz jest zduplikowany', + 'stringDictionary.keyIsRequired': 'Klucz jest wymagany', + + 'number.valueMustBeNumber': 'Wartość musi być liczbą', + 'number.valueTooLow': 'Wartość musi być minimum :min.', + 'number.valueTooHigh': 'Wartość musi być maximum :max.', + + 'boolean.false': 'Fałsz', + 'boolean.true': 'Prawda', + + 'string.valueTooShort': 'Wartość musi mieć minimum :min znaków.', + 'string.valueDoesNotMatchPattern': 'Wartość nie pasuje do oczekiwanego wzorca.', + + 'dynamic.string.label': 'Tekst', + 'dynamic.number.label': 'Liczba', + + // root + 'root.property:properties/timeout': 'Przekroczenie czasu', + 'root.property:properties/debug': 'Tryb debug', + + // steps + 'step.chown.name': 'Uprawnienia', + 'step.chown.property:name': 'Nazwa', + 'step.chown.property:properties/stringOrNumber': 'Tekst lub liczba', + 'step.chown.property:properties/users': 'Użytkownik' + } +}; + +export class App { + public static create() { + const placeholder = document.getElementById('designer') as HTMLElement; + const langInput = document.getElementById('lang') as HTMLInputElement; + const app = new App(placeholder, langInput.value); + app.reload(); + langInput.addEventListener('change', () => { + app.setLang(langInput.value); + app.reload(); + }); + return app; + } + + private designer: Designer | null = null; + private definition: I18nDefinition | null = null; + + public constructor(private readonly placeholder: HTMLElement, private lang: string) {} + + private readonly designerI18n = (key: string, defaultValue: string) => { + const dict = designerDict[this.lang]; + if (dict) { + const translation = dict[key]; + if (translation) { + return translation; + } + } + console.log(``, key, defaultValue); + return defaultValue; + }; + + private readonly editorI18n = (key: string, defaultValue: string, replacements?: { [key: string]: string }) => { + const dict = editorDict[this.lang]; + if (dict) { + const translation = dict[key]; + if (translation) { + defaultValue = translation; + } else { + console.log(``, key, defaultValue); + } + } + return defaultI18n(key, defaultValue, replacements); + }; + + public setLang(lang: string) { + this.lang = lang; + } + + public reload() { + if (this.designer) { + this.designer.destroy(); + } + + const editorProvider = EditorProvider.create(definitionModel, { + uidGenerator: Uid.next, + i18n: this.editorI18n + }); + + if (!this.definition) { + this.definition = editorProvider.activateDefinition(); + const step = editorProvider.activateStep('chown') as ChownStep; + this.definition.sequence.push(step); + } + + this.designer = Designer.create(this.placeholder, this.definition, { + controlBar: true, + editors: { + rootEditorProvider: editorProvider.createRootEditorProvider(), + stepEditorProvider: editorProvider.createStepEditorProvider() + }, + validator: { + step: editorProvider.createStepValidator(), + root: editorProvider.createRootValidator() + }, + steps: { + iconUrlProvider: () => './assets/icon-task.svg' + }, + toolbox: { + groups: editorProvider.getToolboxGroups(), + labelProvider: editorProvider.createStepLabelProvider() + }, + i18n: this.designerI18n + }); + this.designer.onDefinitionChanged.subscribe(d => (this.definition = d)); + } +} + +document.addEventListener('DOMContentLoaded', App.create, false); diff --git a/demos/webpack-app/src/i18n/definition-model.ts b/demos/webpack-app/src/i18n/definition-model.ts new file mode 100644 index 0000000..709955d --- /dev/null +++ b/demos/webpack-app/src/i18n/definition-model.ts @@ -0,0 +1,69 @@ +import { + Dynamic, + StringDictionary, + createBooleanValueModel, + createDefinitionModel, + createDynamicValueModel, + createNumberValueModel, + createStepModel, + createStringDictionaryValueModel, + createStringValueModel +} from 'sequential-workflow-editor-model'; +import { Definition, Step } from 'sequential-workflow-model'; + +export interface I18nDefinition extends Definition { + properties: { + timeout: number; + debug: boolean; + }; +} + +export interface ChownStep extends Step { + type: 'chown'; + componentType: 'task'; + properties: { + stringOrNumber: Dynamic; + users: StringDictionary; + }; +} + +export const definitionModel = createDefinitionModel(model => { + model.root(root => { + root.property('timeout').value( + createNumberValueModel({ + min: 100, + max: 200, + defaultValue: 150 + }) + ); + root.property('debug').value( + createBooleanValueModel({ + defaultValue: false + }) + ); + }); + model.steps([ + createStepModel('chown', 'task', step => { + step.property('stringOrNumber').value( + createDynamicValueModel({ + models: [ + createStringValueModel({ + pattern: /^[a-zA-Z0-9]+$/ + }), + createNumberValueModel({ + min: 1, + max: 100, + defaultValue: 50 + }) + ] + }) + ); + step.property('users').value( + createStringDictionaryValueModel({ + valueMinLength: 1, + uniqueKeys: true + }) + ); + }) + ]); +}); diff --git a/demos/webpack-app/webpack.config.js b/demos/webpack-app/webpack.config.js index 174c1ee..f7e41d5 100644 --- a/demos/webpack-app/webpack.config.js +++ b/demos/webpack-app/webpack.config.js @@ -28,7 +28,8 @@ function bundle(name) { } module.exports = [ - bundle('playground'), bundle('editors'), - bundle('placement-restrictions') + bundle('i18n'), + bundle('placement-restrictions'), + bundle('playground'), ]; diff --git a/docs/I18N-KEYS.md b/docs/I18N-KEYS.md new file mode 100644 index 0000000..1ce22ac --- /dev/null +++ b/docs/I18N-KEYS.md @@ -0,0 +1,52 @@ +# I18N Keys + +This document lists all the I18N keys used in the Sequential Workflow Editor. + +```json +{ + "anyVariable.addVariable": "Add variable", + "anyVariable.delete": "Delete", + "anyVariable.select": "- Select -", + "anyVariables.invalidVariableType": "Variable :name has invalid type", + "anyVariables.noVariablesSelected": "No variables selected", + "anyVariables.variableIsLost": "Variable :name is lost", + "boolean.false": "False", + "boolean.invalidType": "The value must be a boolean.", + "boolean.true": "True", + "branches.empty": "No branches defined.", + "branches.invalidLength": "Invalid number of branches.", + "branches.missingBranch": "Missing branch: :name.", + "branches.mustBeObject": "The value must be object.", + "choice.notSupportedValue": "Value is not supported.", + "generatedString.differentValue": "Generator returns different value than the current value", + "nullableAnyVariable.invalidVariableType": "The variable :name has invalid type", + "nullableAnyVariable.select": "- Select -", + "nullableAnyVariable.variableIsLost": "The variable :name is lost", + "nullableAnyVariable.variableIsRequired": "The variable is required", + "nullableVariable.select": "- Select -", + "nullableVariable.variableIsLost": "The variable :name is not found", + "nullableVariable.variableIsRequired": "The variable is required", + "nullableVariableDefinition.expectedType": "Variable type must be :type", + "nullableVariableDefinition.variableIsDuplicated": "Variable name is already used", + "nullableVariableDefinition.variableIsRequired": "The variable is required", + "number.valueMustBeNumber": "The value must be a number.", + "number.valueTooHigh": "The value must be at most :max.", + "number.valueTooLow": "The value must be at least :min.", + "string.valueDoesNotMatchPattern": "The value does not match the required pattern.", + "string.valueMustBeString": "The value must be a string.", + "string.valueTooShort": "The value must be at least :min characters long.", + "stringDictionary.addItem": "Add item", + "stringDictionary.delete": "Delete", + "stringDictionary.duplicatedKey": "Key name is duplicated", + "stringDictionary.key": "Key", + "stringDictionary.keyIsRequired": "Key is required", + "stringDictionary.noItems": "No items", + "stringDictionary.value": "Value", + "stringDictionary.valueTooShort": "Value must be at least :min characters long", + "toolbox.defaultGroupName": "Others", + "variableDefinitions.delete": "Delete", + "variableDefinitions.namePlaceholder": "Variable name", + "variableDefinitions.valueTypeIsNotAllowed": "Value type is not allowed", + "variableDefinitions.variableNameIsDuplicated": "Variable name is already used" +} +``` diff --git a/editor/package.json b/editor/package.json index 9fbe34a..13996c2 100644 --- a/editor/package.json +++ b/editor/package.json @@ -1,6 +1,6 @@ { "name": "sequential-workflow-editor", - "version": "0.11.3", + "version": "0.12.0", "type": "module", "main": "./lib/esm/index.js", "types": "./lib/index.d.ts", @@ -46,11 +46,11 @@ "prettier:fix": "prettier --write ./src ./css" }, "dependencies": { - "sequential-workflow-editor-model": "^0.11.3", + "sequential-workflow-editor-model": "^0.12.0", "sequential-workflow-model": "^0.2.0" }, "peerDependencies": { - "sequential-workflow-editor-model": "^0.11.3", + "sequential-workflow-editor-model": "^0.12.0", "sequential-workflow-model": "^0.2.0" }, "devDependencies": { diff --git a/editor/src/components/dynamic-list-component.ts b/editor/src/components/dynamic-list-component.ts index 073fe65..8ad0c10 100644 --- a/editor/src/components/dynamic-list-component.ts +++ b/editor/src/components/dynamic-list-component.ts @@ -1,4 +1,4 @@ -import { SimpleEvent, ValueContext } from 'sequential-workflow-editor-model'; +import { I18n, SimpleEvent, ValueContext } from 'sequential-workflow-editor-model'; import { Html } from '../core/html'; import { Component } from './component'; import { validationErrorComponent } from './validation-error-component'; @@ -23,7 +23,7 @@ export interface DynamicListItemComponent extends Component { export function dynamicListComponent = DynamicListItemComponent>( initialItems: TItem[], - itemComponentFactory: (item: TItem) => TItemComponent, + itemComponentFactory: (item: TItem, i18n: I18n) => TItemComponent, context: ValueContext, configuration?: DynamicListComponentConfiguration ): DynamicListComponent { @@ -74,7 +74,7 @@ export function dynamicListComponent 0) { items.forEach((item, index) => { - const component = itemComponentFactory(item); + const component = itemComponentFactory(item, context.i18n); component.onItemChanged.subscribe(item => onItemChanged(item, index)); component.onDeleteClicked.subscribe(() => onItemDeleted(index)); view.insertBefore(component.view, validation.view); diff --git a/editor/src/editor-header.ts b/editor/src/editor-header.ts index 2786d77..3755f94 100644 --- a/editor/src/editor-header.ts +++ b/editor/src/editor-header.ts @@ -1,3 +1,4 @@ +import { I18n } from 'sequential-workflow-editor-model'; import { Component } from './components/component'; import { Html } from './core'; import { appendMultilineText } from './core/append-multiline-text'; @@ -8,17 +9,18 @@ export interface EditorHeaderData { } export class EditorHeader implements Component { - public static create(data: EditorHeaderData): EditorHeader { + public static create(data: EditorHeaderData, stepType: string, i18n: I18n): EditorHeader { const view = Html.element('div', { class: 'swe-editor-header' }); const title = Html.element('h3', { class: 'swe-editor-header-title' }); - title.textContent = data.label; + title.textContent = i18n(`step.${stepType}.name`, data.label); view.appendChild(title); if (data.description) { - const description = Html.element('p', { class: 'swe-editor-header-description' }); - appendMultilineText(description, data.description); - view.appendChild(description); + const description = i18n(`step.${stepType}.description`, data.description); + const p = Html.element('p', { class: 'swe-editor-header-description' }); + appendMultilineText(p, description); + view.appendChild(p); } return new EditorHeader(view); } diff --git a/editor/src/editor-provider-configuration.ts b/editor/src/editor-provider-configuration.ts index 6b177f5..7219e5c 100644 --- a/editor/src/editor-provider-configuration.ts +++ b/editor/src/editor-provider-configuration.ts @@ -1,10 +1,11 @@ -import { UidGenerator } from 'sequential-workflow-editor-model'; +import { I18n, UidGenerator } from 'sequential-workflow-editor-model'; import { DefinitionWalker } from 'sequential-workflow-model'; import { EditorExtension } from './editor-extension'; export interface EditorProviderConfiguration { uidGenerator: UidGenerator; definitionWalker?: DefinitionWalker; + i18n?: I18n; isHeaderHidden?: boolean; extensions?: EditorExtension[]; } diff --git a/editor/src/editor-provider.ts b/editor/src/editor-provider.ts index becd919..e9677a4 100644 --- a/editor/src/editor-provider.ts +++ b/editor/src/editor-provider.ts @@ -6,7 +6,9 @@ import { ModelActivator, DefinitionValidator, Path, - StepValidatorContext + StepValidatorContext, + defaultI18n, + I18n } from 'sequential-workflow-editor-model'; import { EditorServices, ValueEditorFactoryResolver } from './value-editors'; import { @@ -28,15 +30,17 @@ export class EditorProvider { configuration: EditorProviderConfiguration ): EditorProvider { const definitionWalker = configuration.definitionWalker ?? new DefinitionWalker(); + const i18n = configuration.i18n ?? defaultI18n; const activator = ModelActivator.create(definitionModel, configuration.uidGenerator); const validator = DefinitionValidator.create(definitionModel, definitionWalker); const valueEditorFactoryResolver = ValueEditorFactoryResolver.create(configuration.extensions); - return new EditorProvider(activator, validator, definitionModel, definitionWalker, valueEditorFactoryResolver, configuration); + return new EditorProvider(activator, validator, definitionModel, definitionWalker, i18n, valueEditorFactoryResolver, configuration); } private readonly services: EditorServices = { activator: this.activator, - valueEditorFactoryResolver: this.valueEditorFactoryResolver + valueEditorFactoryResolver: this.valueEditorFactoryResolver, + i18n: this.i18n }; private constructor( @@ -44,15 +48,15 @@ export class EditorProvider { private readonly validator: DefinitionValidator, private readonly definitionModel: DefinitionModel, private readonly definitionWalker: DefinitionWalker, + private readonly i18n: I18n, private readonly valueEditorFactoryResolver: ValueEditorFactoryResolver, private readonly configuration: EditorProviderConfiguration ) {} public createRootEditorProvider(): RootEditorProvider { return (definition: Definition, context: GlobalEditorContext): HTMLElement => { - const rootContext = DefinitionContext.createForRoot(definition, this.definitionModel, this.definitionWalker); - const typeClassName = 'root'; - const editor = Editor.create(null, null, this.definitionModel.root.properties, rootContext, this.services, typeClassName); + const rootContext = DefinitionContext.createForRoot(definition, this.definitionModel, this.definitionWalker, this.i18n); + const editor = Editor.create(null, null, this.definitionModel.root.properties, null, rootContext, this.services); editor.onValuesChanged.subscribe(() => { context.notifyPropertiesChanged(); }); @@ -62,9 +66,14 @@ export class EditorProvider { public createStepEditorProvider(): StepEditorProvider { return (step: Step, context: StepEditorContext, definition: Definition) => { - const definitionContext = DefinitionContext.createForStep(step, definition, this.definitionModel, this.definitionWalker); + const definitionContext = DefinitionContext.createForStep( + step, + definition, + this.definitionModel, + this.definitionWalker, + this.i18n + ); const stepModel = this.definitionModel.steps[step.type]; - const typeClassName = stepModel.type; const propertyModels = [stepModel.name, ...stepModel.properties]; const headerData: EditorHeaderData | null = this.configuration.isHeaderHidden @@ -81,7 +90,7 @@ export class EditorProvider { validator = () => stepValidator.validate(stepValidatorContext); } - const editor = Editor.create(headerData, validator, propertyModels, definitionContext, this.services, typeClassName); + const editor = Editor.create(headerData, validator, propertyModels, stepModel.type, definitionContext, this.services); editor.onValuesChanged.subscribe((paths: Path[]) => { const isNameChanged = paths.some(path => path.equals(stepModel.name.value.path)); @@ -125,7 +134,7 @@ export class EditorProvider { const groups: ToolboxGroup[] = []; const categories = new Set(stepModels.map(step => step.category)); categories.forEach((category: string | undefined) => { - const name = category ?? 'Others'; + const name = category ?? this.i18n('toolbox.defaultGroupName', 'Others'); const groupStepModels = stepModels.filter(step => step.category === category); const groupSteps = groupStepModels.map(step => this.activateStep(step.type)); groupSteps.sort((a, b) => a.name.localeCompare(b.name)); diff --git a/editor/src/editor.ts b/editor/src/editor.ts index 3aceb41..1f54e33 100644 --- a/editor/src/editor.ts +++ b/editor/src/editor.ts @@ -12,15 +12,15 @@ export class Editor { headerData: EditorHeaderData | null, validator: EditorValidator | null, propertyModels: PropertyModels, + stepType: string | null, definitionContext: DefinitionContext, - editorServices: EditorServices, - typeClassName: string + editorServices: EditorServices ): Editor { const root = document.createElement('div'); - root.className = `swe-editor swe-type-${typeClassName}`; + root.className = `swe-editor swe-type-${stepType ?? 'root'}`; if (headerData) { - const header = EditorHeader.create(headerData); + const header = EditorHeader.create(headerData, stepType ?? 'root', editorServices.i18n); root.appendChild(header.view); } @@ -36,7 +36,7 @@ export class Editor { continue; } - const propertyEditor = PropertyEditor.create(propertyModel, definitionContext, editorServices); + const propertyEditor = PropertyEditor.create(propertyModel, stepType, definitionContext, editorServices); root.appendChild(propertyEditor.view); editors.set(propertyModel, propertyEditor); } diff --git a/editor/src/property-editor/property-editor.ts b/editor/src/property-editor/property-editor.ts index 9eca6e9..fa92041 100644 --- a/editor/src/property-editor/property-editor.ts +++ b/editor/src/property-editor/property-editor.ts @@ -16,10 +16,16 @@ import { PropertyHintComponent, propertyHint } from './property-hint'; export class PropertyEditor implements Component { public static create( propertyModel: PropertyModel, + stepType: string | null, definitionContext: DefinitionContext, editorServices: EditorServices ): PropertyEditor { - const valueContext = ValueContext.createFromDefinitionContext(propertyModel.value, propertyModel, definitionContext); + const valueContext = ValueContext.createFromDefinitionContext( + propertyModel.value, + propertyModel, + definitionContext, + editorServices.i18n + ); const valueEditorFactory = editorServices.valueEditorFactoryResolver.resolve(propertyModel.value.id, propertyModel.value.editorId); const valueEditor = valueEditorFactory(valueContext, editorServices); let hint: PropertyHintComponent | null = null; @@ -34,7 +40,8 @@ export class PropertyEditor implements Component { const label = Html.element('h4', { class: 'swe-property-header-label' }); - label.innerText = propertyModel.label; + const i18nPrefix = stepType ? `step.${stepType}.property:` : 'root.property:'; + label.innerText = editorServices.i18n(i18nPrefix + propertyModel.path.toString(), propertyModel.label); header.appendChild(label); view.appendChild(header); @@ -64,7 +71,12 @@ export class PropertyEditor implements Component { let validationError: PropertyValidationErrorComponent | null = null; if (propertyModel.validator) { - const valueContext = ValueContext.createFromDefinitionContext(propertyModel.value, propertyModel, definitionContext); + const valueContext = ValueContext.createFromDefinitionContext( + propertyModel.value, + propertyModel, + definitionContext, + editorServices.i18n + ); const validatorContext = PropertyValidatorContext.create(valueContext); validationError = propertyValidationErrorComponent(propertyModel.validator, validatorContext); view.appendChild(validationError.view); diff --git a/editor/src/value-editors/any-variables/any-variable-item-component.ts b/editor/src/value-editors/any-variables/any-variable-item-component.ts index a8065b7..ccbf8ec 100644 --- a/editor/src/value-editors/any-variables/any-variable-item-component.ts +++ b/editor/src/value-editors/any-variables/any-variable-item-component.ts @@ -1,4 +1,4 @@ -import { AnyVariable, SimpleEvent } from 'sequential-workflow-editor-model'; +import { AnyVariable, I18n, SimpleEvent } from 'sequential-workflow-editor-model'; import { Html } from '../../core/html'; import { validationErrorComponent } from '../../components/validation-error-component'; import { buttonComponent } from '../../components/button-component'; @@ -9,7 +9,7 @@ import { Icons } from '../../core/icons'; export type AnyVariableItemComponent = DynamicListItemComponent; -export function anyVariableItemComponent(variable: AnyVariable): AnyVariableItemComponent { +export function anyVariableItemComponent(variable: AnyVariable, i18n: I18n): AnyVariableItemComponent { function validate(error: string | null) { validation.setError(error); } @@ -21,7 +21,7 @@ export function anyVariableItemComponent(variable: AnyVariable): AnyVariableItem const name = Html.element('span'); name.innerText = formatVariableNameWithType(variable.name, variable.type); - const deleteButton = buttonComponent('Delete', { + const deleteButton = buttonComponent(i18n('anyVariable.delete', 'Delete'), { size: 'small', theme: 'secondary', icon: Icons.close diff --git a/editor/src/value-editors/any-variables/any-variable-selector-component.ts b/editor/src/value-editors/any-variables/any-variable-selector-component.ts index dd6336c..7f6bc7c 100644 --- a/editor/src/value-editors/any-variables/any-variable-selector-component.ts +++ b/editor/src/value-editors/any-variables/any-variable-selector-component.ts @@ -31,7 +31,7 @@ export function anyVariableSelectorComponent(context: ValueContext formatVariableName(variable.name)); - variableSelect.setValues(['- Select -', ...variableNames]); + variableSelect.setValues([context.i18n('anyVariable.select', '- Select -'), ...variableNames]); } function onAddClicked() { @@ -57,7 +57,7 @@ export function anyVariableSelectorComponent(context: ValueContext(context.getValue().variables, anyVariableItemComponent, context, { - emptyMessage: 'No variables selected' + emptyMessage: context.i18n('anyVariables.noVariablesSelected', 'No variables selected') }); list.onChanged.subscribe(onChanged); diff --git a/editor/src/value-editors/boolean/boolean-value-editor.ts b/editor/src/value-editors/boolean/boolean-value-editor.ts index 2739d42..57845a9 100644 --- a/editor/src/value-editors/boolean/boolean-value-editor.ts +++ b/editor/src/value-editors/boolean/boolean-value-editor.ts @@ -20,7 +20,7 @@ export function booleanValueEditor(context: ValueContext): Va const select = selectComponent({ stretched: true }); - select.setValues(['False', 'True']); + select.setValues([context.i18n('boolean.false', 'False'), context.i18n('boolean.true', 'True')]); select.selectIndex(context.getValue() ? 1 : 0); select.onSelected.subscribe(onSelected); diff --git a/editor/src/value-editors/dynamic/dynamic-value-editor.ts b/editor/src/value-editors/dynamic/dynamic-value-editor.ts index a0c6da0..e916215 100644 --- a/editor/src/value-editors/dynamic/dynamic-value-editor.ts +++ b/editor/src/value-editors/dynamic/dynamic-value-editor.ts @@ -65,7 +65,11 @@ export function dynamicValueEditor(context: ValueContext, ser const subModelSelect = selectComponent({ size: 'small' }); - subModelSelect.setValues(context.model.subModels.map(model => model.label)); + subModelSelect.setValues( + context.model.subModels.map(model => { + return context.i18n(`dynamic.${model.id}.label`, model.label); + }) + ); subModelSelect.selectIndex(context.model.subModels.findIndex(model => model.id === startValue.modelId)); subModelSelect.onSelected.subscribe(onTypeChanged); control.appendChild(subModelSelect.view); diff --git a/editor/src/value-editors/nullable-any-variable/nullable-any-variable-editor.ts b/editor/src/value-editors/nullable-any-variable/nullable-any-variable-editor.ts index 38591ea..f70a5b4 100644 --- a/editor/src/value-editors/nullable-any-variable/nullable-any-variable-editor.ts +++ b/editor/src/value-editors/nullable-any-variable/nullable-any-variable-editor.ts @@ -35,7 +35,10 @@ export function nullableAnyVariableValueEditor( const select = selectComponent({ stretched: true }); - select.setValues(['- Select -', ...variables.map(variable => formatVariableNameWithType(variable.name, variable.type))]); + select.setValues([ + context.i18n('nullableAnyVariable.select', '- Select -'), + ...variables.map(variable => formatVariableNameWithType(variable.name, variable.type)) + ]); if (startValue) { select.selectIndex(variables.findIndex(variable => variable.name === startValue.name) + 1); } else { diff --git a/editor/src/value-editors/nullable-variable/nullable-variable-value-editor.ts b/editor/src/value-editors/nullable-variable/nullable-variable-value-editor.ts index ff7952c..8f6817e 100644 --- a/editor/src/value-editors/nullable-variable/nullable-variable-value-editor.ts +++ b/editor/src/value-editors/nullable-variable/nullable-variable-value-editor.ts @@ -31,7 +31,10 @@ export function nullableVariableValueEditor(context: ValueContext formatVariableNameWithType(variable.name, variable.type))]); + select.setValues([ + context.i18n('nullableVariable.select', '- Select -'), + ...variables.map(variable => formatVariableNameWithType(variable.name, variable.type)) + ]); if (startValue) { select.selectIndex(variables.findIndex(variable => variable.name === startValue.name) + 1); } else { diff --git a/editor/src/value-editors/string-dictionary/string-dictionary-item-component.ts b/editor/src/value-editors/string-dictionary/string-dictionary-item-component.ts index 3258a56..6dd92af 100644 --- a/editor/src/value-editors/string-dictionary/string-dictionary-item-component.ts +++ b/editor/src/value-editors/string-dictionary/string-dictionary-item-component.ts @@ -1,4 +1,4 @@ -import { SimpleEvent, StringDictionaryItem } from 'sequential-workflow-editor-model'; +import { I18n, SimpleEvent, StringDictionaryItem } from 'sequential-workflow-editor-model'; import { validationErrorComponent } from '../../components/validation-error-component'; import { Html } from '../../core'; import { rowComponent } from '../../components/row-component'; @@ -9,7 +9,7 @@ import { Icons } from '../../core/icons'; export type StringDictionaryItemComponent = DynamicListItemComponent; -export function stringDictionaryItemComponent(item: StringDictionaryItem): StringDictionaryItemComponent { +export function stringDictionaryItemComponent(item: StringDictionaryItem, i18n: I18n): StringDictionaryItemComponent { function validate(error: string | null) { validation.setError(error); } @@ -22,16 +22,16 @@ export function stringDictionaryItemComponent(item: StringDictionaryItem): Strin const onDeleteClicked = new SimpleEvent(); const keyInput = inputComponent(item.key, { - placeholder: 'Key' + placeholder: i18n('stringDictionary.key', 'Key') }); keyInput.onChanged.subscribe(onChanged); const valueInput = inputComponent(item.value, { - placeholder: 'Value' + placeholder: i18n('stringDictionary.value', 'Value') }); valueInput.onChanged.subscribe(onChanged); - const deleteButton = buttonComponent('Delete', { + const deleteButton = buttonComponent(i18n('stringDictionary.delete', 'Delete'), { size: 'small', theme: 'secondary', icon: Icons.close diff --git a/editor/src/value-editors/string-dictionary/string-dictionary-value-editor.ts b/editor/src/value-editors/string-dictionary/string-dictionary-value-editor.ts index 8809392..6bb2127 100644 --- a/editor/src/value-editors/string-dictionary/string-dictionary-value-editor.ts +++ b/editor/src/value-editors/string-dictionary/string-dictionary-value-editor.ts @@ -23,13 +23,13 @@ export function stringDictionaryValueEditor(context: ValueContext(context.getValue().items, stringDictionaryItemComponent, context, { - emptyMessage: 'No items' + emptyMessage: context.i18n('stringDictionary.noItems', 'No items') }); list.onChanged.subscribe(onChanged); const container = valueEditorContainerComponent([list.view]); - const addButton = buttonComponent('Add item', { + const addButton = buttonComponent(context.i18n('stringDictionary.addItem', 'Add item'), { size: 'small', icon: Icons.add }); diff --git a/editor/src/value-editors/value-editor.ts b/editor/src/value-editors/value-editor.ts index f6f565c..43a28bd 100644 --- a/editor/src/value-editors/value-editor.ts +++ b/editor/src/value-editors/value-editor.ts @@ -1,4 +1,4 @@ -import { ModelActivator, ValueModel, ValueContext } from 'sequential-workflow-editor-model'; +import { ModelActivator, ValueModel, ValueContext, I18n } from 'sequential-workflow-editor-model'; import { ValueEditorFactoryResolver } from './value-editor-factory-resolver'; import { Component } from '../components/component'; @@ -16,4 +16,5 @@ export type ValueEditorFactory = ( export interface EditorServices { valueEditorFactoryResolver: ValueEditorFactoryResolver; activator: ModelActivator; + i18n: I18n; } diff --git a/editor/src/value-editors/variable-definitions/variable-definition-item-component.ts b/editor/src/value-editors/variable-definitions/variable-definition-item-component.ts index 4188f8b..d1a87dd 100644 --- a/editor/src/value-editors/variable-definitions/variable-definition-item-component.ts +++ b/editor/src/value-editors/variable-definitions/variable-definition-item-component.ts @@ -41,7 +41,7 @@ export function variableDefinitionItemComponent( const input = prependedInputComponent( '$', inputComponent(variable.name, { - placeholder: 'Variable name' + placeholder: context.i18n('variableDefinitions.namePlaceholder', 'Variable name') }) ); input.onChanged.subscribe(onNameChanged); @@ -55,7 +55,7 @@ export function variableDefinitionItemComponent( typeSelect.selectIndex(valueTypes.findIndex(type => type === variable.type)); typeSelect.onSelected.subscribe(onTypeChanged); - const deleteButton = buttonComponent('Delete', { + const deleteButton = buttonComponent(context.i18n('variableDefinitions.delete', 'Delete'), { size: 'small', theme: 'secondary', icon: Icons.close diff --git a/model/package.json b/model/package.json index 8cef089..a2cbdda 100644 --- a/model/package.json +++ b/model/package.json @@ -1,6 +1,6 @@ { "name": "sequential-workflow-editor-model", - "version": "0.11.3", + "version": "0.12.0", "homepage": "https://nocode-js.com/", "author": { "name": "NoCode JS", diff --git a/model/src/context/definition-context.ts b/model/src/context/definition-context.ts index 3e11249..b533ba3 100644 --- a/model/src/context/definition-context.ts +++ b/model/src/context/definition-context.ts @@ -1,24 +1,27 @@ import { Definition, DefinitionWalker, Step } from 'sequential-workflow-model'; import { DefinitionModel } from '../model'; import { ParentsProvider } from './variables-provider'; +import { I18n } from '../i18n'; export class DefinitionContext { public static createForStep( step: Step, definition: Definition, definitionModel: DefinitionModel, - definitionWalker: DefinitionWalker + definitionWalker: DefinitionWalker, + i18n: I18n ): DefinitionContext { - const parentsProvider = ParentsProvider.createForStep(step, definition, definitionModel, definitionWalker); + const parentsProvider = ParentsProvider.createForStep(step, definition, definitionModel, definitionWalker, i18n); return new DefinitionContext(step, definition, definitionModel, parentsProvider); } public static createForRoot( definition: Definition, definitionModel: DefinitionModel, - definitionWalker: DefinitionWalker + definitionWalker: DefinitionWalker, + i18n: I18n ): DefinitionContext { - const parentsProvider = ParentsProvider.createForRoot(definition, definitionModel, definitionWalker); + const parentsProvider = ParentsProvider.createForRoot(definition, definitionModel, definitionWalker, i18n); return new DefinitionContext(definition, definition, definitionModel, parentsProvider); } diff --git a/model/src/context/scoped-property-context.ts b/model/src/context/scoped-property-context.ts index e1e3c07..2ccf67d 100644 --- a/model/src/context/scoped-property-context.ts +++ b/model/src/context/scoped-property-context.ts @@ -3,17 +3,20 @@ import { ContextVariable } from '../model'; import { ParentsProvider } from './variables-provider'; import { PropertyContext } from './property-context'; import { ValueType } from '../types'; +import { I18n } from '../i18n'; export class ScopedPropertyContext { public static create( propertyContext: PropertyContext, - parentsProvider: ParentsProvider + parentsProvider: ParentsProvider, + i18n: I18n ): ScopedPropertyContext { - return new ScopedPropertyContext(propertyContext, parentsProvider); + return new ScopedPropertyContext(propertyContext, i18n, parentsProvider); } private constructor( public readonly propertyContext: PropertyContext, + public readonly i18n: I18n, private readonly parentsProvider: ParentsProvider ) {} diff --git a/model/src/context/value-context.ts b/model/src/context/value-context.ts index db385d4..e930337 100644 --- a/model/src/context/value-context.ts +++ b/model/src/context/value-context.ts @@ -4,15 +4,17 @@ import { Path, SimpleEvent } from '../core'; import { ScopedPropertyContext } from './scoped-property-context'; import { PropertyContext } from './property-context'; import { DefinitionContext } from './definition-context'; +import { I18n } from '../i18n'; export class ValueContext { public static createFromDefinitionContext( valueModel: TValModel, propertyModel: PropertyModel, - definitionContext: DefinitionContext + definitionContext: DefinitionContext, + i18n: I18n ) { const propertyContext = PropertyContext.create(definitionContext.object, propertyModel, definitionContext.definitionModel); - const scopedPropertyContext = ScopedPropertyContext.create(propertyContext, definitionContext.parentsProvider); + const scopedPropertyContext = ScopedPropertyContext.create(propertyContext, definitionContext.parentsProvider, i18n); return new ValueContext(valueModel, scopedPropertyContext); } @@ -30,6 +32,7 @@ export class ValueContext => { return this.model.path.read>(this.scopedPropertyContext.propertyContext.object); diff --git a/model/src/context/variables-provider.ts b/model/src/context/variables-provider.ts index 0b136d3..fc49e13 100644 --- a/model/src/context/variables-provider.ts +++ b/model/src/context/variables-provider.ts @@ -3,36 +3,40 @@ import { ContextVariable, DefinitionModel } from '../model'; import { DefinitionContext } from './definition-context'; import { PropertyModels } from '../model'; import { ValueContext } from './value-context'; +import { I18n } from '../i18n'; export class ParentsProvider { public static createForStep( step: Step, definition: Definition, definitionModel: DefinitionModel, - definitionWalker: DefinitionWalker + definitionWalker: DefinitionWalker, + i18n: I18n ): ParentsProvider { - return new ParentsProvider(step, definition, definitionModel, definitionWalker); + return new ParentsProvider(step, definition, definitionModel, definitionWalker, i18n); } public static createForRoot( definition: Definition, definitionModel: DefinitionModel, - definitionWalker: DefinitionWalker + definitionWalker: DefinitionWalker, + i18n: I18n ): ParentsProvider { - return new ParentsProvider(null, definition, definitionModel, definitionWalker); + return new ParentsProvider(null, definition, definitionModel, definitionWalker, i18n); } private constructor( private readonly step: Step | null, private readonly definition: Definition, private readonly definitionModel: DefinitionModel, - private readonly definitionWalker: DefinitionWalker + private readonly definitionWalker: DefinitionWalker, + private readonly i18n: I18n ) {} public getVariables(): ContextVariable[] { const result: ContextVariable[] = []; - const rootContext = DefinitionContext.createForRoot(this.definition, this.definitionModel, this.definitionWalker); + const rootContext = DefinitionContext.createForRoot(this.definition, this.definitionModel, this.definitionWalker, this.i18n); this.appendVariables(result, null, this.definitionModel.root.properties, rootContext); if (this.step) { @@ -49,7 +53,13 @@ export class ParentsProvider { throw new Error(`Unknown step type: ${parent.type}`); } - const parentContext = DefinitionContext.createForStep(parent, this.definition, this.definitionModel, this.definitionWalker); + const parentContext = DefinitionContext.createForStep( + parent, + this.definition, + this.definitionModel, + this.definitionWalker, + this.i18n + ); this.appendVariables(result, parent.id, parentModel.properties, parentContext); } } @@ -63,7 +73,7 @@ export class ParentsProvider { definitionContext: DefinitionContext ) { for (const propertyModel of propertyModels) { - const valueContext = ValueContext.createFromDefinitionContext(propertyModel.value, propertyModel, definitionContext); + const valueContext = ValueContext.createFromDefinitionContext(propertyModel.value, propertyModel, definitionContext, this.i18n); const definitions = propertyModel.value.getVariableDefinitions(valueContext); if (!definitions) { diff --git a/model/src/i18n.spec.ts b/model/src/i18n.spec.ts new file mode 100644 index 0000000..418fd3a --- /dev/null +++ b/model/src/i18n.spec.ts @@ -0,0 +1,18 @@ +import { defaultI18n } from './i18n'; + +describe('defaultI18n', () => { + it('returns expected value', () => { + expect(defaultI18n('key', 'test')).toBe('test'); + expect( + defaultI18n('key', 'We need :min users', { + min: '10' + }) + ).toBe('We need 10 users'); + expect( + defaultI18n('key', 'Your name :name should have :n characters', { + name: 'Alice', + n: '4' + }) + ).toBe('Your name Alice should have 4 characters'); + }); +}); diff --git a/model/src/i18n.ts b/model/src/i18n.ts new file mode 100644 index 0000000..1a39a1e --- /dev/null +++ b/model/src/i18n.ts @@ -0,0 +1,12 @@ +export type I18n = (key: string, defaultValue: string, replacements?: Record) => string; + +export const defaultI18n: I18n = (_, defaultValue, replacements) => { + if (replacements) { + let result = defaultValue; + Object.keys(replacements).forEach(key => { + result = result.replace(':' + key, replacements[key]); + }); + return result; + } + return defaultValue; +}; diff --git a/model/src/index.ts b/model/src/index.ts index 8b3912a..8b79aca 100644 --- a/model/src/index.ts +++ b/model/src/index.ts @@ -5,5 +5,6 @@ export * from './core'; export * from './validator'; export * from './value-models'; export * from './external-types'; +export * from './i18n'; export * from './model'; export * from './types'; diff --git a/model/src/model.ts b/model/src/model.ts index 433d662..c22de6b 100644 --- a/model/src/model.ts +++ b/model/src/model.ts @@ -66,6 +66,9 @@ export interface ValueModel< id: ValueModelId; editorId?: string; path: Path; + /** + * Default translation for the label. + */ label: string; configuration: TConfiguration; subModels?: ValueModel[]; diff --git a/model/src/test-tools/value-context-stub.ts b/model/src/test-tools/value-context-stub.ts index 0baa3bc..5353acf 100644 --- a/model/src/test-tools/value-context-stub.ts +++ b/model/src/test-tools/value-context-stub.ts @@ -1,4 +1,5 @@ import { ValueContext } from '../context'; +import { defaultI18n } from '../i18n'; import { ValueModel } from '../model'; export function createValueContextStub( @@ -9,6 +10,7 @@ export function createValueContextStub( getValue: () => value, model: { configuration - } + }, + i18n: defaultI18n } as unknown as ValueContext; } diff --git a/model/src/validator/definition-validator.spec.ts b/model/src/validator/definition-validator.spec.ts index 1b0b3c9..c9d31fd 100644 --- a/model/src/validator/definition-validator.spec.ts +++ b/model/src/validator/definition-validator.spec.ts @@ -2,6 +2,7 @@ import { Definition, DefinitionWalker, Step } from 'sequential-workflow-model'; import { createDefinitionModel, createRootModel, createStepModel } from '../builders'; import { createNumberValueModel } from '../value-models'; import { DefinitionValidator } from './definition-validator'; +import { defaultI18n } from '../i18n'; interface FooDefinition extends Definition { properties: { @@ -38,7 +39,7 @@ describe('DefinitionValidator', () => { ); }); const walker = new DefinitionWalker(); - const validator = DefinitionValidator.create(model, walker); + const validator = DefinitionValidator.create(model, walker, defaultI18n); it('returns error when root is invalid', () => { const def: FooDefinition = { diff --git a/model/src/validator/definition-validator.ts b/model/src/validator/definition-validator.ts index 66472f3..9a4af97 100644 --- a/model/src/validator/definition-validator.ts +++ b/model/src/validator/definition-validator.ts @@ -4,15 +4,17 @@ import { DefinitionContext, ValueContext } from '../context'; import { PropertyValidatorContext } from './property-validator-context'; import { Path } from '../core'; import { StepValidatorContext } from './step-validator-context'; +import { I18n, defaultI18n } from '../i18n'; export class DefinitionValidator { - public static create(definitionModel: DefinitionModel, definitionWalker: DefinitionWalker): DefinitionValidator { - return new DefinitionValidator(definitionModel, definitionWalker); + public static create(definitionModel: DefinitionModel, definitionWalker: DefinitionWalker, i18n?: I18n): DefinitionValidator { + return new DefinitionValidator(definitionModel, definitionWalker, i18n ?? defaultI18n); } private constructor( private readonly model: DefinitionModel, - private readonly walker: DefinitionWalker + private readonly walker: DefinitionWalker, + private readonly i18n: I18n ) {} /** @@ -44,7 +46,7 @@ export class DefinitionValidator { } public validateStep(step: Step, definition: Definition): PropertyValidationError | null { - const definitionContext = DefinitionContext.createForStep(step, definition, this.model, this.walker); + const definitionContext = DefinitionContext.createForStep(step, definition, this.model, this.walker, this.i18n); const stepModel = this.model.steps[step.type]; if (!stepModel) { @@ -73,7 +75,7 @@ export class DefinitionValidator { } public validateRoot(definition: Definition): PropertyValidationError | null { - const definitionContext = DefinitionContext.createForRoot(definition, this.model, this.walker); + const definitionContext = DefinitionContext.createForRoot(definition, this.model, this.walker, this.i18n); return this.validateProperties(this.model.root.properties, definitionContext); } @@ -91,7 +93,7 @@ export class DefinitionValidator { } private validateProperty(propertyModel: PropertyModel, definitionContext: DefinitionContext): ValidationResult { - const valueContext = ValueContext.createFromDefinitionContext(propertyModel.value, propertyModel, definitionContext); + const valueContext = ValueContext.createFromDefinitionContext(propertyModel.value, propertyModel, definitionContext, this.i18n); const valueError = propertyModel.value.validate(valueContext); if (valueError) { return valueError; diff --git a/model/src/value-models/any-variables/any-variables-value-model.ts b/model/src/value-models/any-variables/any-variables-value-model.ts index 1b4ed14..4f9d3d8 100644 --- a/model/src/value-models/any-variables/any-variables-value-model.ts +++ b/model/src/value-models/any-variables/any-variables-value-model.ts @@ -31,11 +31,15 @@ export const createAnyVariablesValueModel = ( value.variables.forEach((variable, index) => { if (!context.hasVariable(variable.name, variable.type)) { - errors[index] = `Variable ${variable.name} is lost`; + errors[index] = context.i18n('anyVariables.variableIsLost', 'Variable :name is lost', { + name: variable.name + }); return; } if (configuration.valueTypes && !configuration.valueTypes.includes(variable.type)) { - errors[index] = `Variable ${variable.name} has invalid type`; + errors[index] = context.i18n('anyVariables.invalidVariableType', 'Variable :name has invalid type', { + name: variable.name + }); return; } }); diff --git a/model/src/value-models/boolean/boolean-value-model-validator.ts b/model/src/value-models/boolean/boolean-value-model-validator.ts index 255e54b..8ce3cdc 100644 --- a/model/src/value-models/boolean/boolean-value-model-validator.ts +++ b/model/src/value-models/boolean/boolean-value-model-validator.ts @@ -5,7 +5,7 @@ import { BooleanValueModel } from './boolean-value-model'; export function booleanValueModelValidator(context: ValueContext): ValidationResult { const value = context.getValue(); if (typeof value !== 'boolean') { - return createValidationSingleError('The value must be a boolean.'); + return createValidationSingleError(context.i18n('boolean.invalidType', 'The value must be a boolean.')); } return null; } diff --git a/model/src/value-models/branches/branches-value-model-validator.ts b/model/src/value-models/branches/branches-value-model-validator.ts index 840b6f6..aeef989 100644 --- a/model/src/value-models/branches/branches-value-model-validator.ts +++ b/model/src/value-models/branches/branches-value-model-validator.ts @@ -10,20 +10,22 @@ export function branchesValueModelValidator( const branches = context.getValue(); if (typeof branches !== 'object') { - return createValidationSingleError('The value must be object.'); + return createValidationSingleError(context.i18n('branches.mustBeObject', 'The value must be object.')); } const branchNames = Object.keys(branches); if (branchNames.length === 0) { - return createValidationSingleError('No branches defined.'); + return createValidationSingleError(context.i18n('branches.empty', 'No branches defined.')); } if (!configuration.dynamic) { const configurationBranchNames = Object.keys(configuration.branches); if (branchNames.length !== configurationBranchNames.length) { - return createValidationSingleError('Invalid number of branches.'); + return createValidationSingleError(context.i18n('branches.invalidLength', 'Invalid number of branches.')); } const missingBranchName = configurationBranchNames.find(branchName => !branchNames.includes(branchName)); if (missingBranchName) { - return createValidationSingleError(`Missing branch: ${missingBranchName}.`); + return createValidationSingleError( + context.i18n('branches.missingBranch', 'Missing branch: :name.', { name: missingBranchName }) + ); } } return null; diff --git a/model/src/value-models/choice/choice-value-model.ts b/model/src/value-models/choice/choice-value-model.ts index 0bfcb96..ba73f36 100644 --- a/model/src/value-models/choice/choice-value-model.ts +++ b/model/src/value-models/choice/choice-value-model.ts @@ -37,7 +37,7 @@ export function createChoiceValueModel( validate(context: ValueContext>): ValidationResult { const value = context.getValue(); if (!configuration.choices.includes(value)) { - return createValidationSingleError('Choice is not supported.'); + return createValidationSingleError(context.i18n('choice.notSupportedValue', 'Value is not supported.')); } return null; } diff --git a/model/src/value-models/generated-string/generated-string-model.ts b/model/src/value-models/generated-string/generated-string-model.ts index 64e0217..118a04f 100644 --- a/model/src/value-models/generated-string/generated-string-model.ts +++ b/model/src/value-models/generated-string/generated-string-model.ts @@ -34,7 +34,9 @@ export function createGeneratedStringValueModel): ValidationResult { const value = context.getValue(); if (configuration.isRequired && !value) { - return createValidationSingleError(`The variable is required.`); + return createValidationSingleError(context.i18n('nullableAnyVariable.variableIsRequired', 'The variable is required')); } if (value) { if (!context.hasVariable(value.name, value.type)) { - return createValidationSingleError(`The variable ${value.name} is lost`); + return createValidationSingleError( + context.i18n('nullableAnyVariable.variableIsLost', 'The variable :name is lost', { name: value.name }) + ); } if (configuration.valueTypes && !configuration.valueTypes.includes(value.type)) { - return createValidationSingleError(`The variable ${value.name} has invalid type`); + return createValidationSingleError( + context.i18n('nullableAnyVariable.invalidVariableType', 'The variable :name has invalid type', { name: value.name }) + ); } } return null; diff --git a/model/src/value-models/nullable-variable-definition/nullable-variable-definition-value-model.ts b/model/src/value-models/nullable-variable-definition/nullable-variable-definition-value-model.ts index 48cbe41..f9a2ea0 100644 --- a/model/src/value-models/nullable-variable-definition/nullable-variable-definition-value-model.ts +++ b/model/src/value-models/nullable-variable-definition/nullable-variable-definition-value-model.ts @@ -38,7 +38,9 @@ export const createNullableVariableDefinitionValueModel = ( validate(context: ValueContext): ValidationResult { const value = context.getValue(); if (configuration.isRequired && !value) { - return createValidationSingleError('Variable name is required.'); + return createValidationSingleError( + context.i18n('nullableVariableDefinition.variableIsRequired', 'The variable is required') + ); } if (value) { const nameError = variableNameValidator(value.name); @@ -46,10 +48,16 @@ export const createNullableVariableDefinitionValueModel = ( return createValidationSingleError(nameError); } if (value.type !== configuration.valueType) { - return createValidationSingleError(`Variable type must be ${configuration.valueType}.`); + return createValidationSingleError( + context.i18n('nullableVariableDefinition.expectedType', 'Variable type must be :type', { + type: configuration.valueType + }) + ); } if (context.isVariableDuplicated(value.name)) { - return createValidationSingleError('Variable name is already used.'); + return createValidationSingleError( + context.i18n('nullableVariableDefinition.variableIsDuplicated', 'Variable name is already used') + ); } } return null; diff --git a/model/src/value-models/nullable-variable/nullable-variable-value-model.ts b/model/src/value-models/nullable-variable/nullable-variable-value-model.ts index 3216847..c362fa9 100644 --- a/model/src/value-models/nullable-variable/nullable-variable-value-model.ts +++ b/model/src/value-models/nullable-variable/nullable-variable-value-model.ts @@ -28,11 +28,15 @@ export const createNullableVariableValueModel = ( validate(context: ValueContext): ValidationResult { const value = context.getValue(); if (configuration.isRequired && !value) { - return createValidationSingleError(`The variable is required.`); + return createValidationSingleError(context.i18n('nullableVariable.variableIsRequired', 'The variable is required')); } if (value && value.name) { if (!context.hasVariable(value.name, configuration.valueType)) { - return createValidationSingleError(`The variable ${value.name} is not found.`); + return createValidationSingleError( + context.i18n('nullableVariable.variableIsLost', 'The variable :name is not found', { + name: value.name + }) + ); } } return null; diff --git a/model/src/value-models/number/number-value-model-validator.ts b/model/src/value-models/number/number-value-model-validator.ts index e653cb5..89e1ae2 100644 --- a/model/src/value-models/number/number-value-model-validator.ts +++ b/model/src/value-models/number/number-value-model-validator.ts @@ -7,13 +7,21 @@ export function numberValueModelValidator(context: ValueContext configuration.max) { - return createValidationSingleError(`The value must be at most ${configuration.max}.`); + return createValidationSingleError( + context.i18n('number.valueTooHigh', 'The value must be at most :max.', { + max: String(configuration.max) + }) + ); } return null; } diff --git a/model/src/value-models/string-dictionary/string-dictionary-value-model-validator.ts b/model/src/value-models/string-dictionary/string-dictionary-value-model-validator.ts index 2171d01..0afba29 100644 --- a/model/src/value-models/string-dictionary/string-dictionary-value-model-validator.ts +++ b/model/src/value-models/string-dictionary/string-dictionary-value-model-validator.ts @@ -13,7 +13,7 @@ export function stringDictionaryValueModelValidator(context: ValueContext i !== index && item.key === key); if (duplicate >= 0) { - errors[index] = 'Key name is duplicated'; + errors[index] = context.i18n('stringDictionary.duplicatedKey', 'Key name is duplicated'); } } } @@ -21,10 +21,12 @@ export function stringDictionaryValueModelValidator(context: ValueContext i !== index && v.name === variable.name); if (isDuplicated) { - errors[index] = 'Variable name is duplicated'; + errors[index] = context.i18n('variableDefinitions.variableNameIsDuplicated', 'Variable name is duplicated'); return; } if (context.isVariableDuplicated(variable.name)) { - errors[index] = 'Variable name is already used'; + errors[index] = context.i18n('variableDefinitions.variableNameIsDuplicated', 'Variable name is already used'); return; } if (configuration.valueTypes && !configuration.valueTypes.includes(variable.type)) { - errors[index] = 'Value type is not allowed'; + errors[index] = context.i18n('variableDefinitions.valueTypeIsNotAllowed', 'Value type is not allowed'); } }); diff --git a/scripts/generate-i18n-keys.cjs b/scripts/generate-i18n-keys.cjs new file mode 100644 index 0000000..521d040 --- /dev/null +++ b/scripts/generate-i18n-keys.cjs @@ -0,0 +1,57 @@ +// Usage: node generate-i18n-keys.cjs + +const fs = require('fs'); +const path = require('path'); + +function* walkDir(dirPath) { + const files = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const file of files) { + if (file.isDirectory()) { + yield* walkDir(path.join(dirPath, file.name)); + } else { + yield path.join(dirPath, file.name); + } + } +} + +function processDir(dirPath) { + const dict = {}; + for (const file of walkDir(path.join(__dirname, dirPath))) { + if (file.endsWith('.ts')) { + const content = fs.readFileSync(file, 'utf8'); + const items = content.match(/i18n\s*\([^)]+/g); + if (items) { + items.forEach(item => { + const values = item.match(/'([^']+)'/g); + if (values?.length === 2) { + dict[values[0].slice(1, -1)] = values[1].slice(1, -1); + } + }); + } + } + } + return dict; +} + +function sortDict(dict) { + const keys = Object.keys(dict); + keys.sort((a, b) => a.localeCompare(b)); + return keys.reduce((result, key) => { + result[key] = dict[key]; + return result; + }, {}); + +} + +const sortedDict = sortDict({ + ...processDir('../model/src'), + ...processDir('../editor/src') +}); + +let output = '# I18N Keys\n\n'; +output += 'This document lists all the I18N keys used in the Sequential Workflow Editor.\n\n'; +output += '```json\n' + JSON.stringify(sortedDict, null, 2) + '\n```\n'; + +fs.writeFileSync(path.join(__dirname, '../docs/I18N-KEYS.md'), output); + +console.log(sortedDict); diff --git a/yarn.lock b/yarn.lock index d36e17c..19775b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5269,10 +5269,10 @@ semver@^7.5.4: dependencies: lru-cache "^6.0.0" -sequential-workflow-designer@^0.17.0: - version "0.17.0" - resolved "https://registry.yarnpkg.com/sequential-workflow-designer/-/sequential-workflow-designer-0.17.0.tgz#fb126b4a45fef107ac54b041e116ddaca607e48f" - integrity sha512-RzKn99irxTqsLn84RkMp7Q6XnvVjufG3xTKUy89uSC9a4CgG0k70jL36zLXxpW7cGEjjzGK/LgIeQ9/kiUOInQ== +sequential-workflow-designer@^0.21.1: + version "0.21.1" + resolved "https://registry.yarnpkg.com/sequential-workflow-designer/-/sequential-workflow-designer-0.21.1.tgz#943a4c7737b5726068e7c973d94aad565c1f85cf" + integrity sha512-w20IHGTx/+3SCvwVJBXYGGOjAQJ/YTTR3TKSnqfSa7atwXlAd/NbScmuVtBIjyRubZ7E6isPIfin19eMEv8WKw== dependencies: sequential-workflow-model "^0.2.0"