diff --git a/packages/pluggable-widgets-tools/CHANGELOG.md b/packages/pluggable-widgets-tools/CHANGELOG.md index 507891c3..367a3f47 100644 --- a/packages/pluggable-widgets-tools/CHANGELOG.md +++ b/packages/pluggable-widgets-tools/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Changed + +- We changed the order of imports in generated widget prop types to match that of the eslint sort-imports rule. + ## [11.8.1] - 2026-03-16 ### Fixed diff --git a/packages/pluggable-widgets-tools/package.json b/packages/pluggable-widgets-tools/package.json index bbee58e0..37b2f6b0 100644 --- a/packages/pluggable-widgets-tools/package.json +++ b/packages/pluggable-widgets-tools/package.json @@ -1,6 +1,6 @@ { "name": "@mendix/pluggable-widgets-tools", - "version": "11.8.1", + "version": "11.8.2", "description": "Mendix Pluggable Widgets Tools", "engines": { "node": ">=20" diff --git a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/generateImports.spec.ts b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/generateImports.spec.ts new file mode 100644 index 00000000..61e498ff --- /dev/null +++ b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/generateImports.spec.ts @@ -0,0 +1,134 @@ +import { ImportableModule, generateImports, ImportStatement } from "../generateImports"; + +describe("generateImports", () => { + + describe("with no modules", () => { + it("returns no import statements for empty code", () => { + const result = generateImports([], ""); + expect(result).toEqual([]); + }); + + it("returns no import statements", () => { + const result = generateImports([], "const x = 5; const y = 10;"); + expect(result).toEqual([]); + }); + }); + + describe("with modules", () => { + const foobarModule = new ImportableModule("foobar", ["foo", "bar", "baz"]); + const numbersModule = new ImportableModule("numbers", ["pi", "e", "infinity"]); + const animalsModule = new ImportableModule("animals", ["cat", "dog", "capibara"]); + + describe("with no matching members in code", () => { + + it("returns no import statements", () => { + const code = "x y z"; + const result = generateImports([foobarModule, numbersModule, animalsModule], code); + + expect(result).toEqual([]); + }); + + }); + + describe("with matching members in code", () => { + + it("returns import statements for each module with matching members", () => { + const code = " foo cat "; + const result = generateImports([foobarModule, animalsModule], code); + + expect(result).toHaveLength(2); + expect(result[0].from).toBe("animals"); + expect(result[1].from).toBe("foobar"); + }); + + it("orders import statements alphabetically by module name", () => { + const code = " foo pi cat "; + const result = generateImports([foobarModule, numbersModule, animalsModule], code); + + expect(result.map(s => s.from)).toEqual(["animals", "foobar", "numbers"]); + }); + + describe("with mixed import types", () => { + const code = " foo bar pi e cat "; + const result = generateImports([numbersModule, animalsModule, foobarModule], code); + + it("groups import statements by type (multiple, single)", () => { + // Multiple-member imports should come first + expect(result[0].members).toHaveLength(2); + expect(result[1].members).toHaveLength(2); + + // Single-member imports should come last + expect(result[2].members).toHaveLength(1); + }); + + it("sorts grouped imports alphabetically", () => { + expect(result[0].from).toBe("foobar") + expect(result[1].from).toBe("numbers") + }); + }) + + }); + + }); + +}); + +describe("ImportableModule", () => { + + describe("generateImportStatement()", () => { + const module = new ImportableModule("test-module", ["TypeA", "TypeB", "helper"]); + + it("finds no members for empty code", () => { + const statement = module.generateImportStatement(""); + expect(statement.members).toEqual([]); + expect(statement.from).toBe("test-module"); + }); + + it("finds no members for code with names containing members", () => { + // Should NOT match partial names like "TypeAbc" or "myhelper" + const code = "TypeAbc myhelper"; + const statement = module.generateImportStatement(code); + expect(statement.members).toEqual([]); + }); + + it.each([" TypeA ", "(TypeA)", "{TypeA}", "", "'TypeA'", '"TypeA"', " TypeA.foo", " TypeA:"])( + "matches member TypeA in code `%s`", (code) => { + const statement = module.generateImportStatement(code); + expect(statement.members).toContain("TypeA"); + }); + }); +}); + +describe("ImportStatement", () => { + + describe("type property", () => { + + it("returns 'none' for statement with no members", () => { + const statement = new ImportStatement("test-module", []); + expect(statement.type).toBe("none"); + }); + + it("returns 'single' for statement with 1 member", () => { + const statement = new ImportStatement("test-module", ["TypeA"]); + expect(statement.type).toBe("single"); + }); + + it("returns 'multiple' for statement with multiple members", () => { + const statement = new ImportStatement("test-module", ["TypeA", "TypeB"]); + expect(statement.type).toBe("multiple"); + }); + }); + + describe("toString() formats string properly", () => { + + it("for statement with 1 member", () => { + const statement = new ImportStatement("test-module", ["TypeA"]); + expect(statement.toString()).toBe('import { TypeA } from "test-module";'); + }); + + it("for statement with multiple members", () => { + const statement = new ImportStatement("test-module", ["TypeA", "TypeB", "helper"]); + expect(statement.toString()).toBe('import { TypeA, TypeB, helper } from "test-module";'); + }); + }); +}); diff --git a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/association.ts b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/association.ts index 573d043b..9890abb8 100644 --- a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/association.ts +++ b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/association.ts @@ -3,8 +3,8 @@ export const associationWebOutput = `/** * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ +import { ListAttributeValue, ListValue, ReferenceSetValue, ReferenceValue } from "mendix"; import { CSSProperties } from "react"; -import { ListValue, ListAttributeValue, ReferenceValue, ReferenceSetValue } from "mendix"; export interface MyWidgetContainerProps { name: string; diff --git a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/containment.ts b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/containment.ts index 3e1d1482..57778d13 100644 --- a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/containment.ts +++ b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/containment.ts @@ -3,8 +3,8 @@ export const containmentWebOutput = `/** * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ -import { ComponentType, CSSProperties, ReactNode } from "react"; import { ActionValue, EditableValue } from "mendix"; +import { ComponentType, CSSProperties, ReactNode } from "react"; export interface MyWidgetContainerProps { name: string; diff --git a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/datasource.ts b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/datasource.ts index 6d483614..be6b873e 100644 --- a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/datasource.ts +++ b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/datasource.ts @@ -3,8 +3,8 @@ export const datasourceWebOutput = `/** * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ +import { ActionValue, EditableValue, ListActionValue, ListAttributeValue, ListExpressionValue, ListValue, ListWidgetValue } from "mendix"; import { ComponentType, ReactNode } from "react"; -import { ActionValue, EditableValue, ListValue, ListActionValue, ListAttributeValue, ListExpressionValue, ListWidgetValue } from "mendix"; import { Big } from "big.js"; export interface DatasourcePropertiesType { diff --git a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/expression.ts b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/expression.ts index ad0df9e1..24f31188 100644 --- a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/expression.ts +++ b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/expression.ts @@ -3,9 +3,9 @@ export const expressionWebOutput = `/** * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ -import { CSSProperties } from "react"; -import { DynamicValue, EditableValue, ListValue, ListExpressionValue } from "mendix"; +import { DynamicValue, EditableValue, ListExpressionValue, ListValue } from "mendix"; import { Big } from "big.js"; +import { CSSProperties } from "react"; export interface MyWidgetContainerProps { name: string; diff --git a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/file.ts b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/file.ts index e158fc85..65c66083 100644 --- a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/file.ts +++ b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/file.ts @@ -3,7 +3,7 @@ export const fileWebOutput = `/** * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ -import { ActionValue, DynamicValue, EditableValue, EditableFileValue, FileValue } from "mendix"; +import { ActionValue, DynamicValue, EditableFileValue, EditableValue, FileValue } from "mendix"; export interface MyWidgetContainerProps { name: string; diff --git a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/image.ts b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/image.ts index bb6afa0a..ce446d89 100644 --- a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/image.ts +++ b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/image.ts @@ -3,7 +3,7 @@ export const imageWebOutput = `/** * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ -import { ActionValue, DynamicValue, EditableValue, EditableImageValue, WebImage } from "mendix"; +import { ActionValue, DynamicValue, EditableImageValue, EditableValue, WebImage } from "mendix"; export interface MyWidgetContainerProps { name: string; diff --git a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/index.ts b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/index.ts index 93c5783a..1e46d122 100644 --- a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/index.ts +++ b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/index.ts @@ -3,9 +3,9 @@ export const nativeResult = `/** * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ -import { CSSProperties } from "react"; import { ActionValue, DynamicValue, EditableValue, FileValue, NativeImage } from "mendix"; import { Big } from "big.js"; +import { CSSProperties } from "react"; export type BootstrapStyleEnum = "default" | "primary" | "success" | "info" | "inverse" | "warning" | "danger"; @@ -75,9 +75,9 @@ export const webResult = `/** * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ -import { CSSProperties } from "react"; import { ActionValue, DynamicValue, EditableValue, FileValue, WebImage } from "mendix"; import { Big } from "big.js"; +import { CSSProperties } from "react"; export type BootstrapStyleEnum = "default" | "primary" | "success" | "info" | "inverse" | "warning" | "danger"; diff --git a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/list-association.ts b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/list-association.ts index 80a9cb52..26a16ae3 100644 --- a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/list-association.ts +++ b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/list-association.ts @@ -3,8 +3,8 @@ export const listAssociationWebOutput = `/** * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ +import { ListAttributeValue, ListReferenceSetValue, ListReferenceValue, ListValue } from "mendix"; import { CSSProperties } from "react"; -import { ListValue, ListAttributeValue, ListReferenceValue, ListReferenceSetValue } from "mendix"; export interface MyWidgetContainerProps { name: string; diff --git a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/list-attribute-refset.ts b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/list-attribute-refset.ts index 65c9b456..d5f4e798 100644 --- a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/list-attribute-refset.ts +++ b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/list-attribute-refset.ts @@ -3,8 +3,8 @@ export const listAttributeWebOutput = `/** * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ +import { ListAttributeListValue, ListAttributeValue, ListValue } from "mendix"; import { CSSProperties } from "react"; -import { ListValue, ListAttributeValue, ListAttributeListValue } from "mendix"; export interface MyWidgetContainerProps { name: string; diff --git a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/metadata-association.ts b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/metadata-association.ts index b56312fd..ddcddb01 100644 --- a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/metadata-association.ts +++ b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/metadata-association.ts @@ -3,8 +3,8 @@ export const associationMetaDataWebOutput = `/** * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ -import { CSSProperties } from "react"; import { AssociationMetaData, ListValue } from "mendix"; +import { CSSProperties } from "react"; export interface MyWidgetContainerProps { name: string; diff --git a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/metadata-attribute.ts b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/metadata-attribute.ts index 1efafa7f..db0add7b 100644 --- a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/metadata-attribute.ts +++ b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/metadata-attribute.ts @@ -3,9 +3,9 @@ export const attributeMetaDataWebOutput = `/** * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ -import { CSSProperties } from "react"; import { AttributeMetaData, ListValue } from "mendix"; import { Big } from "big.js"; +import { CSSProperties } from "react"; export interface MyWidgetContainerProps { name: string; diff --git a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/non-linked-list-attribute-refset.ts b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/non-linked-list-attribute-refset.ts index ec54c675..1069c60d 100644 --- a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/non-linked-list-attribute-refset.ts +++ b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/non-linked-list-attribute-refset.ts @@ -3,8 +3,8 @@ export const nonLinkedListAttributeWebOutput = `/** * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ +import { EditableListValue, EditableValue } from "mendix"; import { CSSProperties } from "react"; -import { EditableValue, EditableListValue } from "mendix"; export interface MyWidgetContainerProps { name: string; diff --git a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/selection.ts b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/selection.ts index 73ca1536..67713888 100644 --- a/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/selection.ts +++ b/packages/pluggable-widgets-tools/src/typings-generator/__tests__/outputs/selection.ts @@ -3,8 +3,8 @@ export const selectionWebOutput = `/** * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ +import { ListValue, SelectionMultiValue, SelectionSingleValue } from "mendix"; import { CSSProperties } from "react"; -import { ListValue, SelectionSingleValue, SelectionMultiValue } from "mendix"; export interface MyWidgetContainerProps { name: string; diff --git a/packages/pluggable-widgets-tools/src/typings-generator/generate.ts b/packages/pluggable-widgets-tools/src/typings-generator/generate.ts index c4bc4f48..9e5fcfb0 100644 --- a/packages/pluggable-widgets-tools/src/typings-generator/generate.ts +++ b/packages/pluggable-widgets-tools/src/typings-generator/generate.ts @@ -1,35 +1,40 @@ import { generateClientTypes } from "./generateClientTypes"; +import { generateImports, ImportableModule } from "./generateImports"; import { generatePreviewTypes } from "./generatePreviewTypes"; import { extractProperties, extractSystemProperties } from "./helpers"; import { WidgetXml } from "./WidgetXml"; -const mxExports = [ - "ActionValue", - "AssociationMetaData", - "AttributeMetaData", - "DynamicValue", - "EditableValue", - "EditableListValue", - "EditableFileValue", - "EditableImageValue", - "FileValue", - "ListValue", - "NativeIcon", - "NativeImage", - "Option", - "ListActionValue", - "ListAttributeValue", - "ListAttributeListValue", - "ListExpressionValue", - "ListReferenceValue", - "ListReferenceSetValue", - "ListWidgetValue", - "ReferenceValue", - "ReferenceSetValue", - "SelectionSingleValue", - "SelectionMultiValue", - "WebIcon", - "WebImage" +const importableModules = [ + new ImportableModule("mendix", [ + "ActionValue", + "AssociationMetaData", + "AttributeMetaData", + "DynamicValue", + "EditableFileValue", + "EditableImageValue", + "EditableListValue", + "EditableValue", + "FileValue", + "ListActionValue", + "ListAttributeListValue", + "ListAttributeValue", + "ListExpressionValue", + "ListReferenceSetValue", + "ListReferenceValue", + "ListValue", + "ListWidgetValue", + "NativeIcon", + "NativeImage", + "Option", + "ReferenceSetValue", + "ReferenceValue", + "SelectionMultiValue", + "SelectionSingleValue", + "WebIcon", + "WebImage" + ]), + new ImportableModule("react", ["ComponentType", "CSSProperties", "ReactNode"]), + new ImportableModule("big.js", ["Big"]) ]; export function generateForWidget(widgetXml: WidgetXml, widgetName: string) { @@ -55,24 +60,15 @@ export function generateForWidget(widgetXml: WidgetXml, widgetName: string) { .concat([clientTypes[clientTypes.length - 1], modelerTypes[modelerTypes.length - 1]]) .join("\n\n"); - const imports = [ - generateImport("react", generatedTypesCode, ["ComponentType", "CSSProperties", "ReactNode"]), - generateImport("mendix", generatedTypesCode, mxExports), - generateImport("big.js", generatedTypesCode, ["Big"]) - ] - .filter(line => line) - .join("\n"); + const imports = generateImports(importableModules, generatedTypesCode); return `/** * This file was generated from ${widgetName}.xml * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ -${imports.length ? imports + "\n\n" : ""}${generatedTypesCode} +${imports.length ? imports.join("\n") + "\n\n" : ""}${generatedTypesCode} `; } -function generateImport(from: string, code: string, availableNames: string[]) { - const usedNames = availableNames.filter(type => new RegExp(`\\W${type}\\W`).test(code)); - return usedNames.length ? `import { ${usedNames.join(", ")} } from "${from}";` : ""; -} + diff --git a/packages/pluggable-widgets-tools/src/typings-generator/generateImports.ts b/packages/pluggable-widgets-tools/src/typings-generator/generateImports.ts new file mode 100644 index 00000000..013b7da4 --- /dev/null +++ b/packages/pluggable-widgets-tools/src/typings-generator/generateImports.ts @@ -0,0 +1,43 @@ +import { groupBy } from "./helpers"; + +export class ImportableModule { + + constructor( + public readonly from: string, + public readonly exportedMembers: string[] + ) { } + + /** + * Creates an import statement which binds names found in the given code to exported members of the module. + */ + generateImportStatement(unboundCode: string): ImportStatement { + const usedNames = this.exportedMembers.filter(type => new RegExp(`\\W${type}\\W`).test(unboundCode)); + return new ImportStatement(this.from, usedNames); + } +} + +export class ImportStatement { + constructor( + public readonly from: string, + public readonly members: string[] + ) { } + + get type() { + return this.members.length > 0 ? this.members.length === 1 ? "single" : "multiple" : "none"; + } + + public toString() { + return `import { ${this.members.join(", ")} } from "${this.from}";`; + } +} + +export function generateImports(importableModules: ImportableModule[], generatedTypesCode: string): ImportStatement[] { + const importsByType = importableModules + .map(m => m.generateImportStatement(generatedTypesCode)) + .reduce(groupBy(({ type }: ImportStatement) => type), {}); + + // Sort according to eslint sort-imports default settings. + return [importsByType.multiple ?? [], importsByType.single ?? []] + .flatMap(s => s.sort((a, b) => a.from < b.from ? -1 : a.from > b.from ? 1 : 0)); +} + diff --git a/packages/pluggable-widgets-tools/src/typings-generator/helpers.ts b/packages/pluggable-widgets-tools/src/typings-generator/helpers.ts index 468b116e..9ebda694 100644 --- a/packages/pluggable-widgets-tools/src/typings-generator/helpers.ts +++ b/packages/pluggable-widgets-tools/src/typings-generator/helpers.ts @@ -21,3 +21,19 @@ export function capitalizeFirstLetter(text: string): string { export function commasAnd(arr: string[]) { return arr.slice(0, -1).join(", ") + (arr.length > 1 ? " and " : "") + arr[arr.length - 1]; } + +/** +* Factory method for creating an `Array.reduce()` callback for partitioning the array into groups. +* @example +* ```js +* [1, 4, 3, 13].reduce(groupBy(x => x % 2 ? "odd" : "even")).even // [ 4 ] +* ``` +* @param groupSelector Callback that maps array members to their group +* @returns Callback for Array.reduce() +*/ +export function groupBy(groupSelector: (item: T) => Groups) { + return function reducer(reduction: { [Group in Groups]?: T[] }, item: T): { [Group in Groups]?: T[] } { + const group = groupSelector(item); + return { ...reduction, [group]: [...(reduction[group] ?? []), item] } + } +}