├── .vscode ├── database.json └── tasks.json ├── src ├── static │ └── add_files_here ├── common │ ├── babelExample.js │ └── internalLogic.ts ├── ide │ ├── ide.css │ └── index.ts ├── browser │ └── index.ts ├── @types │ └── assets │ │ └── index.d.ts ├── images │ └── icon.svg └── runtime │ └── index.ts ├── Entities └── add_xml_entities_here ├── .gitignore ├── .env.sample ├── .prettierrc.json ├── index.html ├── .gitlab-ci.yml ├── tsconfig.json ├── webpack.config.ts ├── .github └── workflows │ └── release.yml ├── .releaserc.json ├── .releaserc-gitlab.json ├── webpack ├── moduleSourceUrlUpdaterPlugin.ts ├── widgetMetadataGeneratorPlugin.ts ├── uploadToThingworxPlugin.ts ├── webpack.common.ts ├── transformers │ └── descriptionTransformer.js └── TWClient.ts ├── package.json ├── .eslintrc.cjs ├── CHANGELOG.md └── README.md /.vscode/database.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/static/add_files_here: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Entities/add_xml_entities_here: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/common/babelExample.js: -------------------------------------------------------------------------------- 1 | export const x = () => { 2 | return 'bar'; 3 | }; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | ui 4 | .gradle 5 | build 6 | zip 7 | .idea/ 8 | .vscode/ 9 | .env -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | THINGWORX_SERVER=http://localhost:8015 2 | THINGWORX_USER=Administrator 3 | THINGWORX_PASSWORD=Administrator12345 -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 4 7 | } 8 | -------------------------------------------------------------------------------- /src/ide/ide.css: -------------------------------------------------------------------------------- 1 | /** 2 | * This CSS will apply in the mashup builder 3 | **/ 4 | .widget-demo-viewer::after { 5 | background-image: url(../images/icon.svg); 6 | content: ' '; 7 | background-repeat: no-repeat; 8 | width: 20px; 9 | height: 20px; 10 | display: inline-block; 11 | } 12 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Hello from Typescript! 7 | 8 | 9 | 10 | 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/browser/index.ts: -------------------------------------------------------------------------------- 1 | import { createDataElement } from '../common/internalLogic'; 2 | 3 | window.onload = function (): void { 4 | // Program starts here. Creates a sample graph in the 5 | // DOM node with the specified ID. This function is invoked 6 | // from the onLoad event handler of the document (see below). 7 | createDataElement(document.getElementById('container'), 'test foo'); 8 | }; 9 | -------------------------------------------------------------------------------- /src/@types/assets/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.jpg' { 2 | const content: string; 3 | export default content; 4 | } 5 | 6 | declare module '*.png' { 7 | const content: string; 8 | export default content; 9 | } 10 | 11 | declare module '*.json' { 12 | const content: string; 13 | export default content; 14 | } 15 | 16 | declare module '*.svg' { 17 | const content: string; 18 | export default content; 19 | } 20 | -------------------------------------------------------------------------------- /src/common/internalLogic.ts: -------------------------------------------------------------------------------- 1 | import { x } from './babelExample'; 2 | 3 | /** 4 | * Example of a function that takes in as a parameter an container and renders some text into it 5 | * @param container element to render data into 6 | * @param value value to render 7 | * @returns the same container element 8 | */ 9 | export function createDataElement(container: HTMLElement, value: string): HTMLElement { 10 | const t = x(1); 11 | container.innerText = value + t; 12 | return container; 13 | } 14 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: node:latest 2 | 3 | 4 | cache: 5 | paths: 6 | - node_modules/ 7 | 8 | stages: 9 | - build 10 | - release 11 | 12 | Build: 13 | stage: build 14 | before_script: 15 | - mkdir dist 16 | - yarn install 17 | script: 18 | - yarn run build 19 | - cp zip/* dist 20 | - yarn run buildDev 21 | - cp zip/* dist 22 | artifacts: 23 | paths: 24 | - dist/* 25 | 26 | Publish: 27 | stage: release 28 | script: 29 | - npx semantic-release 30 | only: 31 | - release -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "noImplicitAny": false, 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "target": "es5", 9 | "experimentalDecorators": true, 10 | "typeRoots": [ 11 | "./src/@types", 12 | "./node_modules/@types" 13 | ], 14 | "lib": [ 15 | "es2015", 16 | "dom" 17 | ], 18 | }, 19 | "include": [ 20 | "./src/**/*" 21 | ] 22 | } -------------------------------------------------------------------------------- /src/images/icon.svg: -------------------------------------------------------------------------------- 1 | icon -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": [], 10 | "group": { 11 | "kind": "build", 12 | "isDefault": true 13 | } 14 | }, 15 | { 16 | "type": "npm", 17 | "script": "server", 18 | "problemMatcher": [] 19 | }, 20 | { 21 | "type": "npm", 22 | "script": "build", 23 | "problemMatcher": [] 24 | }, 25 | { 26 | "type": "npm", 27 | "script": "packageExtension", 28 | "problemMatcher": [] 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import { mergeWithCustomize, customizeObject } from 'webpack-merge'; 2 | import { createConfig } from './webpack/webpack.common'; 3 | 4 | module.exports = (env, argv) => 5 | mergeWithCustomize({ 6 | customizeObject: customizeObject({ 7 | entry: 'replace', 8 | externals: 'replace', 9 | }), 10 | })(createConfig(env, argv), { 11 | entry: { 12 | // the entry point when viewing the index.html page 13 | htmlDemo: './src/browser/index.ts', 14 | // the entry point for the runtime widget 15 | widgetRuntime: `./src/runtime/index.ts`, 16 | // the entry point for the ide widget 17 | widgetIde: `./src/ide/index.ts`, 18 | }, 19 | // moment is available directly on window inside the thingworx runtime and mashup builder 20 | externals: { moment: 'moment' }, 21 | }); 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | permissions: 8 | contents: read # for checkout 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write # to be able to publish a GitHub release 16 | issues: write # to be able to comment on released issues 17 | pull-requests: write # to be able to comment on released pull requests 18 | id-token: write # to enable use of OIDC for npm provenance 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | with: 23 | fetch-depth: 0 24 | - name: Setup Node.js 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: "lts/*" 28 | - name: Install dependencies 29 | run: yarn install --frozen-lockfile 30 | - name: Release 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | run: npx semantic-release -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "release", 4 | "master" 5 | ], 6 | "plugins": [ 7 | "@semantic-release/commit-analyzer", 8 | "@semantic-release/release-notes-generator", 9 | [ 10 | "@semantic-release/changelog", 11 | { 12 | "changelogFile": "CHANGELOG.md" 13 | } 14 | ], 15 | [ 16 | "@semantic-release/npm", 17 | { 18 | "npmPublish": false 19 | } 20 | ], 21 | [ 22 | "@semantic-release/exec", 23 | { 24 | "publishCmd": "yarn prepublishOnly" 25 | } 26 | ], 27 | [ 28 | "@semantic-release/git", 29 | { 30 | "assets": [ 31 | "package.json", 32 | "CHANGELOG.md" 33 | ], 34 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 35 | } 36 | ], 37 | [ 38 | "@semantic-release/github", 39 | { 40 | "assets": [ 41 | { 42 | "path": "dist/*-prod-*.zip", 43 | "label": "Production version" 44 | }, 45 | { 46 | "path": "dist/*-dev-*.zip", 47 | "label": "Development distribution" 48 | } 49 | ] 50 | } 51 | ] 52 | ] 53 | } -------------------------------------------------------------------------------- /.releaserc-gitlab.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "release", 4 | "master" 5 | ], 6 | "plugins": [ 7 | "@semantic-release/commit-analyzer", 8 | "@semantic-release/release-notes-generator", 9 | [ 10 | "@semantic-release/changelog", 11 | { 12 | "changelogFile": "CHANGELOG.md" 13 | } 14 | ], 15 | [ 16 | "@semantic-release/npm", 17 | { 18 | "npmPublish": false 19 | } 20 | ], 21 | [ 22 | "@semantic-release/exec", 23 | { 24 | "publishCmd": "yarn prepublishOnly" 25 | } 26 | ], 27 | [ 28 | "@semantic-release/git", 29 | { 30 | "assets": [ 31 | "package.json", 32 | "CHANGELOG.md" 33 | ], 34 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 35 | } 36 | ], 37 | [ 38 | "@semantic-release/gitlab", 39 | { 40 | "gitlabUrl": "https://gitlab.com", 41 | "assets": [ 42 | { 43 | "path": "dist/*-prod-*.zip", 44 | "label": "Production version" 45 | }, 46 | { 47 | "path": "dist/*-dev-*.zip", 48 | "label": "Development distribution" 49 | } 50 | ] 51 | } 52 | ] 53 | ] 54 | } -------------------------------------------------------------------------------- /webpack/moduleSourceUrlUpdaterPlugin.ts: -------------------------------------------------------------------------------- 1 | import { Compilation, Compiler, WebpackPluginInstance, sources } from 'webpack'; 2 | 3 | /** 4 | * Webpack plugin that walks through all the compilation generated files (chunks) 5 | * and updates their source url to match the specified context 6 | * Used because otherwise the developer can confuse between sourcemaps, especially on Chrome 7 | */ 8 | export class ModuleSourceUrlUpdaterPlugin implements WebpackPluginInstance { 9 | options: { context: string }; 10 | 11 | constructor(options: { context: string }) { 12 | this.options = options; 13 | } 14 | 15 | apply(compiler: Compiler) { 16 | const options = this.options; 17 | compiler.hooks.compilation.tap('ModuleSourceUrlUpdaterPlugin', (compilation) => { 18 | compilation.hooks.processAssets.tap( 19 | { 20 | name: 'ModuleSourceUrlUpdaterPlugin', 21 | stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, 22 | }, 23 | () => { 24 | for (const chunk of compilation.chunks) { 25 | for (const file of chunk.files) { 26 | compilation.updateAsset(file, (old) => { 27 | return new sources.RawSource( 28 | getAssetSourceContents(old).replace( 29 | /\/\/# sourceURL=webpack-internal:\/\/\//g, 30 | '//# sourceURL=webpack-internal:///' + 31 | options.context + 32 | '/', 33 | ), 34 | ); 35 | }); 36 | } 37 | } 38 | }, 39 | ); 40 | }); 41 | } 42 | } 43 | 44 | /** 45 | * Returns the string representation of an assets source. 46 | * 47 | * @param source 48 | * @returns 49 | */ 50 | export const getAssetSourceContents = (assetSource: sources.Source): string => { 51 | const source = assetSource.source(); 52 | if (typeof source === 'string') { 53 | return source; 54 | } 55 | 56 | return source.toString(); 57 | }; 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo_thingworx_webpack_widget", 3 | "version": "1.4.1", 4 | "description": "Example of a widget built using typescript, babel and webpack", 5 | "packageName": "demoWebpackWidget", 6 | "author": "placatus@iqnox.com", 7 | "minimumThingWorxVersion": "6.0.0", 8 | "homepage": "https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget", 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1", 11 | "lint:check": "eslint --ext ts,tsx src", 12 | "lint:fix": "eslint --ext ts,tsx --fix src", 13 | "build": "webpack --mode production", 14 | "buildDev": "webpack --mode development", 15 | "watch": "webpack --watch --mode development", 16 | "server": "webpack serve", 17 | "upload": "webpack --mode development --env upload", 18 | "prepublishOnly": "rm -rf dist && mkdir dist && yarn run build && mv zip/* dist && yarn run buildDev && mv zip/* dist" 19 | }, 20 | "type": "module", 21 | "license": "ISC", 22 | "devDependencies": { 23 | "@babel/core": "^7.21.8", 24 | "@babel/preset-env": "^7.21.5", 25 | "@semantic-release/changelog": "^6.0.0", 26 | "@semantic-release/exec": "^6.0.1", 27 | "@semantic-release/git": "^10.0.0", 28 | "@semantic-release/gitlab": "^12.0.5", 29 | "@types/jquery": "^3.5.14", 30 | "@types/node": "^20.5.9", 31 | "@types/webpack-env": "^1.18.0", 32 | "@typescript-eslint/eslint-plugin": "^6.1.0", 33 | "@typescript-eslint/parser": "^6.1.0", 34 | "babel-loader": "^9.1.2", 35 | "clean-webpack-plugin": "^4.0.0", 36 | "copy-webpack-plugin": "^11.0.0", 37 | "css-loader": "^6.7.3", 38 | "cz-conventional-changelog": "^3.3.0", 39 | "dotenv": "^16.0.3", 40 | "dts-bundle-webpack": "^1.0.2", 41 | "esbuild-loader": "^4.0.2", 42 | "esbuild-register": "^3.4.2", 43 | "eslint": "^8.45.0", 44 | "eslint-config-prettier": "^9.0.0", 45 | "eslint-plugin-prettier": "^5.0.0", 46 | "eslint-plugin-react": "^7.32.2", 47 | "extract-loader": "^5.1.0", 48 | "file-loader": "^6.2.0", 49 | "fork-ts-checker-webpack-plugin": "^8.0.0", 50 | "prettier": "^3.0.0", 51 | "source-map-loader": "^4.0.1", 52 | "style-loader": "^3.3.2", 53 | "ts-declaration-webpack-plugin": "^1.2.3", 54 | "typescript": "^5.1.6", 55 | "webpack": "^5.82.0", 56 | "webpack-cli": "^5.0.2", 57 | "webpack-dev-server": "^4.13.3", 58 | "webpack-merge": "^5.3.0", 59 | "xml2js": "^0.6.2", 60 | "zip-webpack-plugin": "^4.0.1" 61 | }, 62 | "dependencies": { 63 | "typescriptwebpacksupport": "^2.2.2" 64 | }, 65 | "config": { 66 | "commitizen": { 67 | "path": "./node_modules/cz-conventional-changelog" 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 3 | extends: [ 4 | 'plugin:prettier/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 5 | 'plugin:@typescript-eslint/recommended-type-checked', 6 | 'plugin:@typescript-eslint/stylistic-type-checked', 7 | ], 8 | parserOptions: { 9 | ecmaFeatures: { 10 | jsx: true, // Allows for the parsing of JSX 11 | }, 12 | project: true, 13 | tsconfigRootDir: __dirname, 14 | }, 15 | plugins: ['prettier', '@typescript-eslint'], 16 | rules: { 17 | '@typescript-eslint/dot-notation': 'off', 18 | '@typescript-eslint/no-unsafe-member-access': 'warn', 19 | '@typescript-eslint/no-unsafe-return': 'warn', 20 | '@typescript-eslint/no-unsafe-assignment': 'warn', 21 | '@typescript-eslint/no-unsafe-call': 'error', 22 | '@typescript-eslint/no-unsafe-argument': 'warn', 23 | '@typescript-eslint/no-explicit-any': 'warn', 24 | '@typescript-eslint/no-non-null-asserted-optional-chain': 'warn', 25 | '@typescript-eslint/prefer-nullish-coalescing': 'off', 26 | '@typescript-eslint/no-floating-promises': 'off', 27 | '@typescript-eslint/consistent-type-definitions': 'off', 28 | '@typescript-eslint/class-literal-property-style': 'off', 29 | '@typescript-eslint/no-misused-promises': [ 30 | 'error', 31 | { 32 | checksVoidReturn: false, 33 | }, 34 | ], 35 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 36 | '@typescript-eslint/explicit-function-return-type': 'off', 37 | '@typescript-eslint/no-var-requires': 'off', 38 | '@typescript-eslint/no-unused-vars': [ 39 | 'warn', 40 | { 41 | argsIgnorePattern: '^_', 42 | varsIgnorePattern: '^_', 43 | caughtErrorsIgnorePattern: '^_', 44 | }, 45 | ], 46 | // Deactivated because it also disables the use of readonly T[] over ReadonlyArray 47 | // and readonly T[] can be confusing (is T or the array readonly) 48 | '@typescript-eslint/array-type': 'off', 49 | 50 | // Disabled because this defeats one the purposes of template expressions, to make it easy to 51 | // log values which may be incorrect or mistyped 52 | '@typescript-eslint/restrict-template-expressions': 'off', 53 | 54 | // Disabled because this prevents passing around unbound functions in all cases, even when they 55 | // don't reference this in the implementation at all and the workaround of specifying this: void 56 | // as the first argument is very awkward 57 | '@typescript-eslint/unbound-method': 'off', 58 | }, 59 | overrides: [ 60 | { 61 | // enable the rule specifically for TypeScript files 62 | files: ['*.ts', '*.tsx'], 63 | rules: { 64 | '@typescript-eslint/explicit-function-return-type': [ 65 | 'warn', 66 | { allowExpressions: true }, 67 | ], 68 | '@typescript-eslint/no-var-requires': ['error'], 69 | }, 70 | }, 71 | ], 72 | settings: { 73 | react: { 74 | version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use 75 | }, 76 | }, 77 | ignorePatterns: '.eslintrc.cjs', 78 | }; 79 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.4.1](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/compare/v1.4.0...v1.4.1) (2023-11-30) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * trigger a new release, with a production build ([2b998cf](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/2b998cf2ffb464d8f4f73e40651818c8a242df09)) 7 | 8 | # [1.4.0](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/compare/v1.3.0...v1.4.0) (2023-09-07) 9 | 10 | 11 | ### Features 12 | 13 | * Update all widget dependencies, switch to configs in typescript, use esbuild for transforming code, improve eslint rules ([3e1cca3](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/3e1cca3b36650fb8fef14d3329aefc80cf215d1d)) 14 | 15 | # [1.3.0](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/compare/v1.2.0...v1.3.0) (2022-08-22) 16 | 17 | 18 | ### Features 19 | 20 | * Generate automatic descriptions from javadoc for widget properties, services and classes ([#47](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/issues/47)) ([323fb21](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/323fb21271e1dd4dbeaf5cee105d45e2cabea2f9)) 21 | 22 | # [1.2.0](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/compare/v1.1.2...v1.2.0) (2021-09-23) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * Update dependencies ([d0c6ab0](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/d0c6ab0077397b044a40dbc96bf0165bed45d5d7)) 28 | 29 | 30 | ### Features 31 | 32 | * Simplify webpack config by adopting the asset resources in webpack 5. Include an example for css in ide ([3059caa](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/3059caa682cdad7b7359f8857dcd35937fd7aedc)) 33 | 34 | ## [1.1.2](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/compare/v1.1.1...v1.1.2) (2021-08-24) 35 | 36 | 37 | ### Bug Fixes 38 | 39 | * Resolve issue where the webpack dev server was using the incorrect public path ([700b9e8](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/700b9e861ddd0c38819c1c1d8f1382d7e3183e0e)) 40 | 41 | ## [1.1.1](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/compare/v1.1.0...v1.1.1) (2021-06-25) 42 | 43 | 44 | ### Bug Fixes 45 | 46 | * Bump dependency versions ([7c8420b](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/7c8420bd063c898ce78da482c93eafefe469a4a7)) 47 | * Improve documentation and provide additional links ([40eb014](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/40eb014db2eb8d06b103adbed3e2d0b90e4a3f6b)) 48 | 49 | # [1.1.0](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/compare/v1.0.1...v1.1.0) (2020-11-01) 50 | 51 | 52 | ### Bug Fixes 53 | 54 | * **deps:** Added proepr dependency to semantic-release ([3511ae2](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/3511ae2c5b0912d7f3add5182fd691217be7cd8f)) 55 | 56 | 57 | ### Features 58 | 59 | * Reorganized the naming of the default widget files, and remove the initialization step ([0a324fa](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/0a324fa89e402630c252a3e84f52ea69a51fc6e8)) 60 | 61 | ## [1.0.1](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/compare/v1.0.0...v1.0.1) (2020-11-01) 62 | 63 | 64 | ### Bug Fixes 65 | 66 | * Fixed case sensitivity issue ([d093a6d](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/d093a6d694ff38114580c69b6405565da03630a6)) 67 | * **build:** Remove the metadata.xml file and optimize the generator plugin ([417fd85](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/417fd8539663ff87ed25e80ec84e756d3b30fabf)) 68 | 69 | # 1.0.0 (2020-11-01) 70 | 71 | 72 | ### Bug Fixes 73 | 74 | * **build:** Split the webpack build system into separate files, with the option of using webpack-merge for widget specific changes ([352d400](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/352d4006d325d2d1481e4c54ea5d93887ddf67a9)) 75 | * **docs:** Added documentation about using semantic release and CI/CD pipelines ([bbdcb73](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/bbdcb73982c3ed8b639f06f9128d730f0ea707d6)) 76 | -------------------------------------------------------------------------------- /src/runtime/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TWWidgetDefinition, 3 | property, 4 | canBind, 5 | TWEvent, 6 | event, 7 | service, 8 | } from 'typescriptwebpacksupport/widgetRuntimeSupport'; 9 | 10 | /** 11 | * The `@TWWidgetDefinition` decorator marks a class as a Thingworx widget. It can only be applied to classes 12 | * that inherit from the `TWRuntimeWidget` class. 13 | */ 14 | @TWWidgetDefinition 15 | class DemoWebpackWidget extends TWRuntimeWidget { 16 | /** 17 | * The `@event` decorator can be applied to class member to mark them as events. 18 | * They must have the `TWEvent` type and can be invoked to trigger the associated event. 19 | * 20 | * Optionally, the decorator can receive the name of the event as its parameter; if it is not specified, 21 | * the name of the event will be considered to be the same as the name of the class member. 22 | */ 23 | @event clicked: TWEvent; 24 | 25 | /** 26 | * The `@property` decorator can be applied to class member to mark them as events. 27 | * The value of the class member and of the associated widget property will be kept in sync. 28 | * 29 | * The runtime will also automatically update the value of the property for bindings; because of this 30 | * the `updateProperty` method becomes optional. If `updateProperty` is overriden, you must invoke 31 | * the superclass implementation to ensure that decorated properties are updated correctly. 32 | * 33 | * Optionally, the decorator can receive a number of aspects as its parameters. 34 | */ 35 | @property clickMessage: string; 36 | 37 | /** 38 | * The `canBind` and `didBind` aspects can be used to specify callback methods to execute when the value of 39 | * the property is about to be updated or has been updated because of a binding. 40 | * 41 | * For `canBind`, the method can decide to reject the newly received value. 42 | */ 43 | @property(canBind('valueWillBind')) set value(value: string) { 44 | this.internalLogic.createDataElement(this.jqElement[0], value); 45 | } 46 | 47 | /** 48 | * Optionally, the first parameter of the `@property` decorator can be a string that specifies the 49 | * name of the property as it is defined in the IDE class. This can be used to have different names 50 | * in the definition and implementation. 51 | */ 52 | @property('clickedAmount') timesClicked: number; 53 | 54 | /** 55 | * This method is invoked whenever the `value` property is about to be updated because of a binding, 56 | * because it has been specified in the `canBind` aspect of the `value` property. 57 | * @param value Represents the property's new value. 58 | * @param info The complete updatePropertyInfo object. 59 | * @return `true` if the property should update to the new value, `false` otherwise. 60 | */ 61 | valueWillBind(value: string, info: TWUpdatePropertyInfo): boolean { 62 | alert(`Value will be updated to ${value}`); 63 | return true; 64 | } 65 | 66 | /** 67 | * Invoked to obtain the HTML structure corresponding to the widget. 68 | * @return The HTML structure. 69 | */ 70 | renderHtml(): string { 71 | return `
${this.value}
`; 72 | } 73 | 74 | internalLogic: typeof import('../common/internalLogic'); 75 | 76 | /** 77 | * Invoked after the widget's HTML element has been created. 78 | * The `jqElement` property will reference the correct element within this method. 79 | */ 80 | async afterRender(): Promise { 81 | this.internalLogic = await import('../common/internalLogic'); 82 | this.jqElement[0].addEventListener('click', (event: MouseEvent): void => { 83 | this.timesClicked++; 84 | this.clicked(); 85 | event.stopPropagation(); 86 | }); 87 | } 88 | 89 | /** 90 | * The `@service` decorator can be applied to methods to mark them as events. 91 | * 92 | * The runtime will also automatically call the method when the associated service is invoked. 93 | * 94 | * Optionally, the decorator can receive the name of the service as its parameter; if it is not specified, 95 | * the name of the service will be considered to be the same as the name of the method. 96 | */ 97 | @service testService(): void { 98 | alert(this.clickMessage); 99 | } 100 | 101 | /** 102 | * Invoked when this widget is destroyed. This method should be used to clean up any resources created by the widget 103 | * that cannot be reclaimed by the garbage collector automatically (e.g. HTML elements added to the page outside of the widget's HTML element) 104 | */ 105 | beforeDestroy?(): void { 106 | // add disposing logic 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /webpack/widgetMetadataGeneratorPlugin.ts: -------------------------------------------------------------------------------- 1 | import * as xml2js from 'xml2js'; 2 | import { Compiler, WebpackPluginInstance, sources } from 'webpack'; 3 | 4 | const XML_FILE_TEMPLATE = ` 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | `; 15 | 16 | interface Options { 17 | packageName: string; 18 | packageJson: { 19 | description: string; 20 | author: string; 21 | minimumThingWorxVersion: string; 22 | version: string; 23 | autoUpdate: string; 24 | }; 25 | } 26 | 27 | /** 28 | * Webpack plugin that generates the XML file representing the ThingWorx widget metadata 29 | * This build out the XML file based on data in parameters 30 | */ 31 | export class WidgetMetadataGenerator implements WebpackPluginInstance { 32 | options: Options; 33 | constructor(options: Options) { 34 | this.options = options; 35 | } 36 | 37 | apply(compiler: Compiler) { 38 | compiler.hooks.compilation.tap('ModuleSourceUrlUpdaterPlugin', (compilation) => { 39 | compilation.hooks.additionalAssets.tap('WidgetMetadataGeneratorPlugin', () => { 40 | const options = this.options; 41 | // read the metadata xml file using xml2js 42 | // transform the metadata to json 43 | xml2js.parseString(XML_FILE_TEMPLATE, function (err, result) { 44 | if (err) console.log('Error parsing metadata file' + err); 45 | const extensionPackageAttributes = 46 | result.Entities.ExtensionPackages[0].ExtensionPackage[0].$; 47 | // set the name of the extension package 48 | extensionPackageAttributes.name = options.packageName; 49 | // set the description from the package.json 50 | extensionPackageAttributes.description = options.packageJson.description; 51 | // set the vendor using the author field in package json 52 | extensionPackageAttributes.vendor = options.packageJson.author; 53 | // set the minimum thingworx version 54 | extensionPackageAttributes.minimumThingWorxVersion = 55 | options.packageJson.minimumThingWorxVersion; 56 | // set the version of the package 57 | extensionPackageAttributes.packageVersion = options.packageJson.version; 58 | // set the name of the widget itself 59 | result.Entities.Widgets[0].Widget[0].$.name = options.packageName; 60 | if (options.packageJson.autoUpdate) { 61 | extensionPackageAttributes.buildNumber = JSON.stringify( 62 | options.packageJson.autoUpdate, 63 | ); 64 | } 65 | // if there is no file resource set, then we must add a node in the xml 66 | if (!result.Entities.Widgets[0].Widget[0].UIResources[0].FileResource) { 67 | result.Entities.Widgets[0].Widget[0].UIResources[0] = {}; 68 | result.Entities.Widgets[0].Widget[0].UIResources[0].FileResource = []; 69 | } 70 | // add the ide file 71 | result.Entities.Widgets[0].Widget[0].UIResources[0].FileResource.push({ 72 | $: { 73 | type: 'JS', 74 | file: `${options.packageName}.ide.bundle.js`, 75 | description: '', 76 | isDevelopment: 'true', 77 | isRuntime: 'false', 78 | }, 79 | }); 80 | // add the runtime file 81 | result.Entities.Widgets[0].Widget[0].UIResources[0].FileResource.push({ 82 | $: { 83 | type: 'JS', 84 | file: `${options.packageName}.runtime.bundle.js`, 85 | description: '', 86 | isDevelopment: 'false', 87 | isRuntime: 'true', 88 | }, 89 | }); 90 | // transform the metadata back into xml 91 | const xml = new xml2js.Builder().buildObject(result); 92 | 93 | // insert the metadata xml as a file asset 94 | compilation.emitAsset('../../metadata.xml', new sources.RawSource(xml)); 95 | }); 96 | }); 97 | }); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /webpack/uploadToThingworxPlugin.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { TWClient } from './TWClient'; 3 | import * as path from 'path'; 4 | import { Compiler, WebpackPluginInstance } from 'webpack'; 5 | 6 | interface Options { 7 | packageVersion: string; 8 | packageName: string; 9 | isProduction: boolean; 10 | } 11 | 12 | /** 13 | * Utility webpack ThingWorx plugin that is used to upload the widget 14 | * into the target ThingWorx server. 15 | * Uses native node fetch to run. 16 | */ 17 | export class UploadToThingworxPlugin implements WebpackPluginInstance { 18 | options: Options; 19 | authorizationHeader: string; 20 | 21 | constructor(options: Options) { 22 | this.options = options; 23 | } 24 | 25 | apply(compiler: Compiler) { 26 | // this happens in the 'done' phase of the compilation so it will happen at the end 27 | compiler.hooks.afterDone.tap('UploadToThingworxPlugin', async () => { 28 | const extensionPath = path.join( 29 | process.cwd(), 30 | 'zip', 31 | `${this.options.packageName}-${this.options.isProduction ? 'prod' : 'dev'}-v${ 32 | this.options.packageVersion 33 | }.zip`, 34 | ); 35 | await this.uploadExtension(extensionPath, this.options.packageName); 36 | }); 37 | } 38 | 39 | /** 40 | * Uploads an extension zip at the specified path to the thingworx server. 41 | * @param path The path to the zip file to upload. 42 | * @param name If specified, the name of the project that should appear in the console. 43 | * @returns A promise that resolves when the operation completes. 44 | */ 45 | async uploadExtension(path: string, name?: string): Promise { 46 | process.stdout.write( 47 | `\x1b[2m❯\x1b[0m Uploading${name ? ` ${name}` : ''} to ${TWClient.server}`, 48 | ); 49 | 50 | const formData = new FormData(); 51 | 52 | formData.append( 53 | 'file', 54 | new Blob([fs.readFileSync(path)]), 55 | path.toString().split('/').pop(), 56 | ); 57 | 58 | const response = await TWClient.importExtension(formData); 59 | 60 | if (response.statusCode != 200) { 61 | process.stdout.write( 62 | `\r\x1b[1;31m✖\x1b[0m Unable to upload${name ? ` ${name}` : ''} to ${ 63 | TWClient.server 64 | }\n`, 65 | ); 66 | throw new Error( 67 | `\x1b[1;31mFailed to upload project to thingworx with status code ${ 68 | response.statusCode 69 | } (${response.statusMessage})\x1b[0m${this.formattedUploadStatus(response.body)}`, 70 | ); 71 | } 72 | 73 | process.stdout.write( 74 | `\r\x1b[1;32m✔\x1b[0m Uploaded${name ? ` ${name}` : ''} to ${TWClient.server} \n`, 75 | ); 76 | process.stdout.write(this.formattedUploadStatus(response.body)); 77 | } 78 | 79 | /** 80 | * Returns a formatted string that contains the validation and installation status extracted 81 | * from the specified server response. 82 | * @param response The server response. 83 | * @returns The formatted upload status. 84 | */ 85 | formattedUploadStatus(response): string { 86 | let infotable; 87 | let result = ''; 88 | try { 89 | infotable = JSON.parse(response); 90 | 91 | // The upload response is an infotable with rows with two possible properties: 92 | // validate - an infotable where each row contains the validation result for each attempted extension 93 | // install - if validation passed, an infotable where each row contains the installation result for each attempted extension 94 | const validations = infotable.rows.filter((r) => r.validate); 95 | const installations = infotable.rows.filter((r) => r.install); 96 | 97 | const validation = validations.length && { 98 | rows: Array.prototype.concat.apply( 99 | [], 100 | validations.map((v) => v.validate.rows), 101 | ), 102 | }; 103 | const installation = installations.length && { 104 | rows: Array.prototype.concat.apply( 105 | [], 106 | installations.map((i) => i.install.rows), 107 | ), 108 | }; 109 | 110 | // A value of 1 for extensionReportStatus indicates failure, 2 indicates warning, and 0 indicates success 111 | for (const row of validation.rows) { 112 | if (row.extensionReportStatus == 1) { 113 | result += `🛑 \x1b[1;31mValidation failed\x1b[0m for "${row.extensionPackage.rows[0].name}-${row.extensionPackage.rows[0].packageVersion}": "${row.reportMessage}"\n`; 114 | } else if (row.extensionReportStatus == 2) { 115 | result += `🔶 \x1b[1;33mValidation warning\x1b[0m for "${row.extensionPackage.rows[0].name}-${row.extensionPackage.rows[0].packageVersion}": "${row.reportMessage}"\n`; 116 | } else { 117 | result += `✅ \x1b[1;32mValidation passed\x1b[0m for "${row.extensionPackage.rows[0].name}-${row.extensionPackage.rows[0].packageVersion}": "${row.reportMessage}"\n`; 118 | } 119 | } 120 | 121 | if (!installation) return result; 122 | 123 | // If an installation status is provided, display it as well; it has the same format as validation 124 | for (const row of installation.rows) { 125 | if (row.extensionReportStatus == 1) { 126 | result += `🛑 \x1b[1;31mInstallation failed\x1b[0m for "${row.extensionPackage.rows[0].name}-${row.extensionPackage.rows[0].packageVersion}": "${row.reportMessage}"\n`; 127 | } else if (row.extensionReportStatus == 2) { 128 | result += `🔶 \x1b[1;33mInstallation warning\x1b[0m for "${row.extensionPackage.rows[0].name}-${row.extensionPackage.rows[0].packageVersion}": "${row.reportMessage}"\n`; 129 | } else { 130 | result += `✅ \x1b[1;32mInstalled\x1b[0m "${row.extensionPackage.rows[0].name}-${row.extensionPackage.rows[0].packageVersion}": "${row.reportMessage}"\n`; 131 | } 132 | } 133 | 134 | return result; 135 | } catch (e) { 136 | // If the response isn't a parsable response, it is most likely a simple message 137 | // that may be printed directly. 138 | return response; 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/ide/index.ts: -------------------------------------------------------------------------------- 1 | // automatically import the css file 2 | import './ide.css'; 3 | import { 4 | TWWidgetDefinition, 5 | autoResizable, 6 | description, 7 | property, 8 | defaultValue, 9 | bindingTarget, 10 | service, 11 | event, 12 | bindingSource, 13 | nonEditable, 14 | willSet, 15 | didSet, 16 | displayName, 17 | } from 'typescriptwebpacksupport/widgetIDESupport'; 18 | 19 | import widgetIconUrl from '../images/icon.svg'; 20 | 21 | /** 22 | * The `@TWWidgetDefinition` decorator marks a class as a Thingworx widget. It can only be applied to classes 23 | * that inherit from the `TWComposerWidget` class. It must receive the display name of the widget as its first parameter. 24 | * Afterwards any number of widget aspects may be specified. 25 | * 26 | * Because of this, the `widgetProperties` method is now optional. If overriden, you must invoke the superclass 27 | * implementation to ensure that decorated aspects are initialized correctly. 28 | */ 29 | @description('A widget') 30 | @TWWidgetDefinition('Test', autoResizable) 31 | class DemoWebpackWidget extends TWComposerWidget { 32 | /** 33 | * The `@property` decorator can be applied to class members to mark them as widget properties. 34 | * This must be applied with the base type of the property as its first parameter. 35 | * The decorator can then also receive a series of aspects to apply to that properties as parameters. 36 | * 37 | * Because of this, the `widgetProperties` method is now optional. If overriden, you must invoke the superclass 38 | * implementation to ensure that decorated properties are initialized correctly. 39 | */ 40 | @property('NUMBER', defaultValue(90)) width: number; 41 | 42 | /** 43 | * When the `@description` decorator is not used, the JSDoc documentation will be used as 44 | * the description for the property. 45 | */ 46 | @property('NUMBER', defaultValue(30)) height: number; 47 | 48 | /** 49 | * A number of aspects such as `willSet`, `didSet` and `didBind` can be used to specify various callback 50 | * that will be invoked when the value of the property is bound or updated by the user through using the composer. 51 | */ 52 | @description('A label to display on the widget.') 53 | @property( 54 | 'STRING', 55 | bindingTarget, 56 | defaultValue('my value'), 57 | willSet('valueWillSet'), 58 | didSet('valueDidSet'), 59 | ) 60 | value: string; 61 | 62 | /** 63 | * The `@description` decorator can be applied before widget definitions and property, event or service decorators to specify 64 | * the description of the decorated class member. That description will appear in the composer. 65 | * 66 | * A `displayName` aspect may be used to give the property a friendly name with which it will be rendered in the properties 67 | * pane in the mashup builder. 68 | */ 69 | @description('Tracks how many times the widget was clicked') 70 | @property('NUMBER', bindingSource, nonEditable, displayName('Clicked Amount')) 71 | clickedAmount: number; 72 | 73 | /** 74 | * Invoked to obtain the URL to this widget's icon. 75 | * @return The URL. 76 | */ 77 | widgetIconUrl(): string { 78 | return widgetIconUrl; 79 | } 80 | 81 | /** 82 | * Invoked to obtain the HTML structure corresponding to the widget. 83 | * @return The HTML structure. 84 | */ 85 | renderHtml(): string { 86 | return `
${this.value}
`; 87 | } 88 | 89 | /** 90 | * This method is invoked whenever the `value` property is about to be updated for any reason, either through 91 | * direct assignment or because the user has edited its value in the composer, 92 | * because it has been specified in the `willSet` aspect of the `value` property. 93 | * 94 | * Because of this, the `beforeSetProperty` method is now optional. If overriden, you must invoke the superclass 95 | * implementation to ensure that decorated properties are updated correctly. 96 | * 97 | * @param value Represents the property's new value. 98 | * @return A string, if the new value should be rejected, in which case the returned string will be 99 | * displayed as an error message to the user. If the value should be accepted, this method should return nothing. 100 | */ 101 | valueWillSet(value: string): string | void { 102 | if (value == 'test') return 'Invalid value specified'; 103 | } 104 | 105 | /** 106 | * This method is invoked whenever the `value` property has been updated for any reason, either through 107 | * direct assignment or because the user has edited its value in the composer, 108 | * because it has been specified in the `didSet` aspect of the `value` property. 109 | * 110 | * Because of this, the `afterSetProperty` method is now optional. If overriden, you must invoke the superclass 111 | * implementation to ensure that decorated properties are handled correctly. 112 | * 113 | * @param value Represents the property's new value. 114 | * @return `true` if the widget should be redrawn because of this change, nothing or `false` otherwise. 115 | */ 116 | valueDidSet(value: string): boolean | void { 117 | this.jqElement[0].innerText = value; 118 | } 119 | 120 | /** 121 | * The service decorator defines a service. 122 | * 123 | * Because of this, the `widgetServices` method is now optional. If overriden, you must invoke the superclass 124 | * implementation to ensure that decorated services are initialized correctly. 125 | */ 126 | @description('Prints out a message when invoked') 127 | @service 128 | testService; 129 | 130 | /** 131 | * Property declarations can be mixed in with other method and event or service declarations. 132 | */ 133 | @description('A message to display when the widget is clicked.') 134 | @property('STRING', bindingTarget, defaultValue('Invoked via decorator')) 135 | clickMessage: string; 136 | 137 | /** 138 | * The event decorator defines an event. 139 | * 140 | * Because of this, the `widgetEvents` method is now optional. If overriden, you must invoke the superclass 141 | * implementation to ensure that decorated events are initialized correctly. 142 | */ 143 | @description('Triggered when the widget is clicked') 144 | @event 145 | clicked; 146 | 147 | /** 148 | * Invoked after the widget's HTML element has been created. 149 | * The `jqElement` property will reference the correct element within this method. 150 | */ 151 | afterRender(): void { 152 | // add after logic render here 153 | } 154 | 155 | /** 156 | * Invoked when this widget is destroyed. This method should be used to clean up any resources created by the widget 157 | * that cannot be reclaimed by the garbage collector automatically (e.g. HTML elements added to the page outside of the widget's HTML element) 158 | */ 159 | beforeDestroy(): void { 160 | // add dispose logic here 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /webpack/webpack.common.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import CopyWebpackPlugin from 'copy-webpack-plugin'; 4 | // enable cleaning of the build and zip directories 5 | import { CleanWebpackPlugin } from 'clean-webpack-plugin'; 6 | // enable building of the widget 7 | import ZipPlugin from 'zip-webpack-plugin'; 8 | // enable reading master data from the package.json file 9 | // note that this is relative to the working directory, not to this file 10 | // import the extra plugins 11 | import { UploadToThingworxPlugin } from './uploadToThingworxPlugin'; 12 | import { WidgetMetadataGenerator } from './widgetMetadataGeneratorPlugin'; 13 | import { ModuleSourceUrlUpdaterPlugin } from './moduleSourceUrlUpdaterPlugin'; 14 | import webpack from 'webpack'; 15 | import { EsbuildPlugin } from 'esbuild-loader'; 16 | import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; 17 | import { WebpackConfiguration } from 'webpack-dev-server'; 18 | 19 | const packageJson = JSON.parse(fs.readFileSync('./package.json', { encoding: 'utf-8' })); 20 | 21 | export function createConfig(env, argv): WebpackConfiguration { 22 | // look if we are in initialization mode based on the --upload argument 23 | const uploadEnabled = env ? env.upload : false; 24 | const packageName = packageJson.packageName || `${packageJson.name}_ExtensionPackage`; 25 | require('dotenv').config({ path: '.env' }); 26 | 27 | // look if we are in production or not based on the mode we are running in 28 | const isProduction = argv.mode == 'production'; 29 | const result = { 30 | entry: { 31 | // the entry point when viewing the index.html page 32 | htmlDemo: './src/browser/index.ts', 33 | // the entry point for the runtime widget 34 | widgetRuntime: `./src/runtime/index.ts`, 35 | // the entry point for the ide widget 36 | widgetIde: `./src/ide/index.ts`, 37 | }, 38 | devServer: { 39 | port: 9011, 40 | }, 41 | output: { 42 | path: path.join(process.cwd(), 'build', 'ui', packageName), 43 | filename: '[name].bundle.js', 44 | chunkFilename: '[id].chunk.js', 45 | chunkLoadingGlobal: `webpackJsonp${packageName}`, 46 | // this is the path when viewing the widget in thingworx 47 | publicPath: `../Common/extensions/${packageName}/ui/${packageName}/`, 48 | devtoolNamespace: packageName, 49 | }, 50 | plugins: [ 51 | new ForkTsCheckerWebpackPlugin(), 52 | new webpack.DefinePlugin({ 53 | VERSION: JSON.stringify(packageJson.version), 54 | }), 55 | // delete build and zip folders 56 | new CleanWebpackPlugin({ 57 | cleanOnceBeforeBuildPatterns: [path.resolve('build/**'), path.resolve('zip/**')], 58 | }), 59 | new CopyWebpackPlugin({ 60 | patterns: [ 61 | // in case we just want to copy some resources directly to the widget package, then do it here 62 | { from: 'src/static', to: 'static', noErrorOnMissing: true }, 63 | // in case the extension contains entities, copy them as well 64 | { from: 'Entities/**/*.xml', to: '../../', noErrorOnMissing: true }, 65 | // Include the license document in the package 66 | { from: 'LICENSE.MD', to: '../../LICENSE.MD', noErrorOnMissing: true }, 67 | // Include the customer facing changelog in the package 68 | { from: 'CHANGELOG.MD', to: '../../CHANGELOG.MD', noErrorOnMissing: true }, 69 | // Include a customer facing README.MD file with general information 70 | { from: 'README_EXTERNAL.MD', to: '../../README.MD', noErrorOnMissing: true }, 71 | ], 72 | }), 73 | // generates the metadata xml file and adds it to the archive 74 | new WidgetMetadataGenerator({ packageName, packageJson }), 75 | // create the extension zip 76 | new ZipPlugin({ 77 | path: path.join(process.cwd(), 'zip'), // a top level directory called zip 78 | pathPrefix: `ui/${packageName}/`, // path to the extension source code 79 | filename: `${packageName}-${isProduction ? 'prod' : 'dev'}-v${ 80 | packageJson.version 81 | }.zip`, 82 | pathMapper: (assetPath) => { 83 | // handles renaming of the bundles 84 | if (assetPath == 'widgetRuntime.bundle.js') { 85 | return packageName + '.runtime.bundle.js'; 86 | } else if (assetPath == 'widgetIde.bundle.js') { 87 | return packageName + '.ide.bundle.js'; 88 | } else { 89 | return assetPath; 90 | } 91 | }, 92 | exclude: [/htmlDemo/, isProduction ? /(.*)\.map$/ : /a^/], 93 | }), 94 | ], 95 | // if we are in development mode, then use "eval-source-map". 96 | // See https://webpack.js.org/configuration/devtool/ for all available options 97 | devtool: isProduction ? undefined : 'eval-source-map', 98 | resolve: { 99 | // Add '.ts' and '.tsx' as resolvable extensions. 100 | extensions: ['.ts', '.tsx', '.js', '.json'], 101 | }, 102 | // enable a filesystem cache to speed up individual upload commands 103 | cache: { 104 | type: 'filesystem', 105 | compression: 'gzip', 106 | }, 107 | module: { 108 | rules: [ 109 | { 110 | // Match js, jsx, ts & tsx files 111 | test: /\.[jt]sx?$/, 112 | loader: 'esbuild-loader', 113 | }, 114 | { 115 | test: /\.scss$/, 116 | use: [ 117 | { 118 | loader: 'style-loader', 119 | options: { 120 | attributes: { 121 | 'data-description': `Styles for widget ${packageName}`, 122 | }, 123 | }, 124 | }, 125 | 'css-loader', 126 | 'sass-loader', 127 | ], 128 | }, 129 | { 130 | test: /\.css$/, 131 | use: [ 132 | { 133 | loader: 'style-loader', 134 | options: { 135 | attributes: { 136 | 'data-description': `Styles for widget ${packageName}`, 137 | }, 138 | }, 139 | }, 140 | 'css-loader', 141 | { 142 | loader: 'esbuild-loader', 143 | options: { 144 | minify: true, 145 | }, 146 | }, 147 | ], 148 | }, 149 | { 150 | test: /\.(png|jpe?g|gif|svg|eot|ttf|woff|woff2|xml)$/i, 151 | // More information here https://webpack.js.org/guides/asset-modules/ 152 | type: 'asset', 153 | }, 154 | ], 155 | }, 156 | } satisfies WebpackConfiguration; 157 | // if we are in production, enable the minimizer 158 | if (isProduction) { 159 | (result as WebpackConfiguration).optimization = { 160 | minimizer: [ 161 | new EsbuildPlugin({ 162 | minifyWhitespace: true, 163 | minifySyntax: true, 164 | // identifiers are left unminified because thingworx depends on the variable names for the widget names 165 | minifyIdentifiers: false, 166 | target: 'es2015', 167 | }), 168 | ], 169 | }; 170 | } else { 171 | // this handles nice debugging on chromium 172 | result.plugins.push( 173 | new ModuleSourceUrlUpdaterPlugin({ 174 | context: packageName, 175 | }), 176 | ); 177 | } 178 | // if the upload is enabled, then add the uploadToThingworxPlugin with the credentials from package.json 179 | if (uploadEnabled) { 180 | result.plugins.push( 181 | new UploadToThingworxPlugin({ 182 | thingworxServer: process.env.TARGET_THINGWORX_SERVER ?? '', 183 | thingworxUser: process.env.TARGET_THINGWORX_USER ?? '', 184 | thingworxPassword: process.env.TARGET_THINGWORX_PASSWORD ?? '', 185 | packageVersion: packageJson.version, 186 | packageName: packageName, 187 | isProduction: isProduction, 188 | }), 189 | ); 190 | } 191 | 192 | return result; 193 | } 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Starter kit of a ThingWorx widget using Webpack and TypeScript 2 | 3 | This repository contains an example that can act as a starter kit for building widget using modern web development techniques like [Webpack](https://webpack.js.org/) and [TypeScript](https://www.typescriptlang.org/) and [babel](https://babeljs.io/). 4 | 5 | ## Why use it 6 | 7 | There are many advantages to this, and here's some of them: 8 | 9 | ### Webpack advantages 10 | 11 | * Because of *dynamic imports and require calls*, webpack can load javascript files and other resources only they are needed. So, if you are using a very big external library, this library is only loaded if the widget is used in a mashup, rather than being always loaded in the `CombinedExtensions.js` files. 12 | * *Better resource management*: You can load a resource (like images, xml files, css) only if it's used in the widget, and you'll no longer have to care about where to put it and how to package it. Also, the webpack loader will inline small files for better network performance. 13 | * *Automatic dependency management*: Webpack can be very easily integrated with the `npm` repositories, so this brings automatic dependency management. You'll no longer have to manually download libraries from the web, struggle to add all the dependencies and so on. Instead, `npm`, which functions similarly to maven central, handles all of this. 14 | * *Easily develop and test the widget outside of ThingWorx*: By doing the initial development and testing in a simple html page, it reduces the waiting times of publishing widget, doing reloads, etc... 15 | * *Allows using of different loaders*: You can develop your code in multiple languages, and use transpilers to convert you code javascript code that works in older javascript version. One of this languages, is typescript. 16 | 17 | ### Typescript 18 | 19 | * Typescript is a superscript of javascript with types. 20 | * Optional static typing (the key here is optional). 21 | * Type Inference, which gives some of the benefits of types, without actually using them. 22 | * Access to ES6 and ES7 features, before they become supported by major browsers. 23 | * The ability to compile down to a version of JavaScript that runs on all browsers. 24 | * Great tooling support with auto-completion.. 25 | 26 | ## Using the widget template 27 | 28 | ### Required software 29 | 30 | The following software is required: 31 | 32 | * [NodeJS](https://nodejs.org/en/) needs to be installed and added to the `PATH`. You should use the LTS version. 33 | 34 | The following software is recommended: 35 | 36 | * [Visual Studio Code](https://code.visualstudio.com/): An integrated developer environment with great typescript support. You can also use any IDE of your liking, it just that most of the testing was done using VSCode. 37 | 38 | ### Proposed folder structure 39 | 40 | ``` 41 | demoWebpackTypescriptWidget 42 | │ README.md // this file 43 | │ package.json // here you specify project name, homepage and dependencies. This is the only file you should edit to start a new project 44 | │ tsconfig.json // configuration for the typescript compiler 45 | │ webpack.config.js // configuration for webpack. Can be updated through the use of webpack-merge 46 | │ index.html // when testing the widget outside of ThingWorx, the index file used. 47 | │ .env.sample // sample file of how to declare the target ThingWorx server for automatic widget upload. Rename this file to .env 48 | │ .eslintrc.js // eslint configuration for automatic file formatting 49 | │ .releaserc.json // semantic-release sample configuration for publishing to GitHub Releases 50 | │ .releaserc-gitlab.json // semantic-release sample configuration for publishing to GitLab Releases 51 | └───webpack // Internal webpack configuration and plugins 52 | └───Entities // ThingWorx XML entities that are part of the widget. This can be Things, StyleDefinitions, etc. They can be exported using the SourceControl export functionality in ThingWorx. 53 | └───src // main folder where your development will take place 54 | │ └───browser // If the widget is developed from an external library, or you want to be able to test it outside of thingworx 55 | │ │ │ index.ts // Typescript file containing the necessary code to run the widget outside of thingworx, inside a browser 56 | │ └───ide // Folder containing the code that is used in the Mashup Builder 57 | │ │ │ index.ts // Contains the widget definition (properties, events, etc) used in the Mashup Builder 58 | │ └───runtime // Folder containing the code that is used during Mashup Runtime. 59 | │ │ │ index.ts // Main file with the widget runtime logic, (eg. what happens when the a property changes) 60 | │ └───common // Code that is shared across runtime, browser and ide 61 | │ │ │ file1.ts // typescript file with internal logic 62 | │ │ │ file2.js // javascript file in ES2015 with module 63 | │ │ │ ... 64 | │ └───styles // folder for css styles that you can import into your app using import statements. This can also be within each scope (browser, ide, runtime) 65 | │ └───images // folder for image resources you are statically including using import statements 66 | │ └───static // folder for resources that are copied over to the development extension. Think of folder of images that you reference only dynamically 67 | └───build // temporary folder used during compilation 68 | └───zip // location of the built extension 69 | ``` 70 | 71 | ### Developing a new widget 72 | 73 | In order to start developing a new widget using this template you need to do the following: 74 | 75 | 1. Clone this repository 76 | ``` 77 | git clone https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget 78 | ``` 79 | Alternatively, you can also use the link https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/ to directly generate a new GitHub repository using this template. 80 | 2. Open `package.json` and configure the `name`, `description`, and other fields you find relevant 81 | 3. Run `yarn install`. This will install the development dependencies for this project. 82 | 4. Start working on your widget. 83 | 84 | ### Adding dependencies 85 | 86 | Dependencies can be added from [npm](https://www.npmjs.com/), using the `yarn add DEPENDENCY_NAME` command, or by adding them directly to `package.json`, under `dependencies`. After adding them to `package.json`, you should run `yarn install`. 87 | If you are using a javascript library that also has typescript mappings you can install those using `yarn add @types/DEPENDENCY_NAME`. 88 | 89 | ### Building and publishing 90 | 91 | The following commands allow you to build and compile your widget: 92 | 93 | * `yarn run build`: builds the production version of the widget. Creates a new extension zip file under the `zip` folder. The production version is optimized for sharing and using in production environments. 94 | * `yarn run upload`: creates a build, and uploads the extension zip to the ThingWorx server configured in `.env`. The build is created for development, with source-maps enabled. 95 | * `yarn run buildDev`: builds the development version of the widget. Creates a new extension zip file under the `zip` folder.The build is created for development, with source-maps enabled. 96 | 97 | ## Example of widgets that use this starter kit 98 | 99 | * [SVGViewer](https://github.com/ptc-iot-sharing/SvgViewerWidgetTWX): Allows viewing, interacting and manipulating SVG files in ThingWorx. Contains examples of using external libraries. 100 | * [Calendar](https://github.com/ptc-iot-sharing/CalendarWidgetTWX): A calendar widget for ThingWorx built using the fullcalendar library. Contains examples of using external libraries as well as referencing global external libraries without including them in the built package. 101 | * [BarcodeScanner](https://github.com/ptc-iot-sharing/ThingworxBarcodeScannerWidget): A client side widget for scanning barcodes. 102 | ## Sematic release 103 | 104 | The widget uses [semantic-release](https://semantic-release.gitbook.io/) and GitLab CI/CD pipelines for automatic version management and package publishing. This automates the whole widget release workflow including: determining the next version number, generating the release notes, updating the _CHANGELOG.MD_ and publishing a release. Please read through the *semantic-release* official documentation to better understand how it works. 105 | 106 | Because we are using *semantic-release* the commit messages must follow a specific format called [Angular commit conventions or Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). It is mandatory that this is followed. To help with this, the project is also setup with [commitizen](https://commitizen.github.io/cz-cli/) as a dev-dependency. So, you can use `git cz` instead of `git commit` to create a new commit. 107 | 108 | The repository should have one protected branch: 109 | 110 | * **master**: This is where the main development takes place. Each push to master is automatically built by the CI/CD pipeline and artifacts are generated. 111 | 112 | By default, this repository comes with two samples CI/CD configurations for both GitHub and GitLab. By default, the `.releaserc.json` triggers a GitHub release. If you want to use GitLab, rename `.releaserc-gitlab.json` to `.releaserc.json`. 113 | 114 | ## Support 115 | 116 | Feel free to open an issue if you encounter any problem while using this starter kit. 117 | 118 | Consulting and custom development can also be supported by contacting me at placatus@iqnox.com. 119 | 120 | 121 | # Disclaimer 122 | By downloading this software, the user acknowledges that it is unsupported, not reviewed for security purposes, and that the user assumes all risk for running it. 123 | 124 | Users accept all risk whatsoever regarding the security of the code they download. 125 | 126 | This software is not an official PTC product and is not officially supported by PTC. 127 | 128 | PTC is not responsible for any maintenance for this software. 129 | 130 | PTC will not accept technical support cases logged related to this Software. 131 | 132 | This source code is offered freely and AS IS without any warranty. 133 | 134 | The author of this code cannot be held accountable for the well-functioning of it. 135 | 136 | The author shared the code that worked at a specific moment in time using specific versions of PTC products at that time, without the intention to make the code compliant with past, current or future versions of those PTC products. 137 | 138 | The author has not committed to maintain this code and he may not be bound to maintain or fix it. 139 | 140 | 141 | # License 142 | I accept the MIT License (https://opensource.org/licenses/MIT) and agree that any software downloaded/utilized will be in compliance with that Agreement. However, despite anything to the contrary in the License Agreement, I agree as follows: 143 | 144 | I acknowledge that I am not entitled to support assistance with respect to the software, and PTC will have no obligation to maintain the software or provide bug fixes or security patches or new releases. 145 | 146 | The software is provided “As Is” and with no warranty, indemnitees or guarantees whatsoever, and PTC will have no liability whatsoever with respect to the software, including with respect to any intellectual property infringement claims or security incidents or data loss. 147 | 148 | -------------------------------------------------------------------------------- /webpack/transformers/descriptionTransformer.js: -------------------------------------------------------------------------------- 1 | const ts = require('typescript'); 2 | 3 | /** 4 | * The name of the decorator that identifies a widget class. 5 | */ 6 | const WIDGET_CLASS_DECORATOR = 'TWWidgetDefinition'; 7 | 8 | /** 9 | * The name of the decorator that identifies a property. 10 | */ 11 | const WIDGET_PROPERRTY_DECORATOR = 'property'; 12 | 13 | /** 14 | * The name of the decorator that identifies an event. 15 | */ 16 | const WIDGET_EVENT_DECORATOR = 'event'; 17 | 18 | /** 19 | * The name of the decorator that identifies a service. 20 | */ 21 | const WIDGET_SERVICE_DECORATOR = 'service'; 22 | 23 | /** 24 | * The name of the decorator that supplioes descriptions. 25 | */ 26 | const DESCRIPTION_DECORATOR = 'description'; 27 | 28 | /** 29 | * A typescript transformer that automatically generates description decorators from JSDoc tags. 30 | * This transformer must be used in the `before` phase. 31 | * 32 | * When used, a description decorator will be generated for a property or method that: 33 | * - has either the `@property`, `@event` or `@service` decorator applied to it 34 | * - is declared in a class that has the `@TWWidgetDefinition` decorator applied to it 35 | * 36 | * It will also generate a description decorator for any class that has the `@TWWidgetDefinition` decorator applied to it. 37 | * 38 | * If a description decorator is already specified for an element, the transformer will skip creating an additional 39 | * description decorator for that element. 40 | * 41 | * The transformer will take the text of the first JSDoc tag that refers to each matching element and 42 | * supply it as the argument for the description decorator. 43 | */ 44 | class DescriptionTransformer { 45 | /** 46 | * 47 | * @param {ts.TransformationContext} context The transformation context. 48 | */ 49 | constructor(context) { 50 | this.context = context; 51 | } 52 | 53 | /** 54 | * @type {ts.TransformationContext} The transformation context. 55 | */ 56 | context; 57 | 58 | /** 59 | * Set to `true` if the file processed by this transformer is an IDE file. 60 | */ 61 | isIDEFile = false; 62 | 63 | /** 64 | * Checks whether the given node has a decorator or decorator factory with the given name. 65 | * @param {string} name The name of the decorator to find. 66 | * @param {ts.Node} node The node in which to search. 67 | * @return {boolean} `true` if the decorator was found, `false` otherwise. 68 | */ 69 | hasDecoratorNamed(name, node) { 70 | if (!node.decorators) return false; 71 | 72 | // Getting the decorator name depends on whether the decorator is applied directly or via a 73 | // decorator factory. 74 | for (const decorator of node.decorators) { 75 | // In a decorator factory, the decorator itself is the result of invoking 76 | // the decorator factory function so it doesn't technically have a name; in this case the name 77 | // of the decorator factory function is considered to be the decorator name. 78 | if (decorator.expression.kind == ts.SyntaxKind.CallExpression) { 79 | /** @type {ts.CallExpression} */ const callExpression = decorator.expression; 80 | if ( 81 | callExpression.expression.kind == ts.SyntaxKind.Identifier && 82 | callExpression.expression.text == name 83 | ) { 84 | return true; 85 | } 86 | } else if (decorator.expression.kind == ts.SyntaxKind.Identifier) { 87 | /** @type {ts.Identifier} */ const identifierExpression = decorator.expression; 88 | if (identifierExpression.text == name) { 89 | return true; 90 | } 91 | } 92 | } 93 | return false; 94 | } 95 | 96 | /** 97 | * Visits the given node. This method will be invoked for all nodes in the file. 98 | * @param {ts.Node} node The node to visit. 99 | * @return {ts.Node} The visited node, or a new node that will replace it. 100 | */ 101 | visit(node) { 102 | // The description decorator only makes sense for IDE files 103 | // An IDE file is identified from its imports - it must import its Thingworx specific 104 | // decorators from the widgetIDESupport package 105 | if (node.kind == ts.SyntaxKind.ImportDeclaration) { 106 | /** @type {ts.ImportDeclaration} */ const importNode = node; 107 | 108 | /** @type {ts.StringLiteral} */ const module = importNode.moduleSpecifier; 109 | if (module.text == 'typescriptwebpacksupport/widgetIDESupport') { 110 | this.isIDEFile = true; 111 | } 112 | } 113 | 114 | // There are three kinds of nodes that are relevant to this transformer that will be handled here. 115 | 116 | // The first kind is a class declaration node 117 | if (node.kind == ts.SyntaxKind.ClassDeclaration && this.isIDEFile) { 118 | // Classes must have a `@TWWidgetDefinition` decorator and must not have a `@description` decorator in order to be considered 119 | if ( 120 | this.hasDecoratorNamed(WIDGET_CLASS_DECORATOR, node) && 121 | !this.hasDecoratorNamed(DESCRIPTION_DECORATOR, node) 122 | ) { 123 | // First visit the class members 124 | const replacementNode = ts.visitEachChild( 125 | node, 126 | (node) => this.visit(node), 127 | this.context, 128 | ); 129 | 130 | // Then return a replacement 131 | const classNode = this.addDescriptionDecoratorToNode(replacementNode, node); 132 | 133 | return classNode; 134 | } 135 | } 136 | // The second kind is a property declaration node 137 | else if (node.kind == ts.SyntaxKind.PropertyDeclaration && this.isIDEFile) { 138 | // Members must be part of a class that has the `@TWWidgetDefinition` decorator 139 | // and must not have the `@description` decorator themselves 140 | if ( 141 | node.parent.kind == ts.SyntaxKind.ClassDeclaration && 142 | (this.hasDecoratorNamed(WIDGET_PROPERRTY_DECORATOR, node) || 143 | this.hasDecoratorNamed(WIDGET_EVENT_DECORATOR, node) || 144 | this.hasDecoratorNamed(WIDGET_SERVICE_DECORATOR, node)) 145 | ) { 146 | if (!this.hasDecoratorNamed(DESCRIPTION_DECORATOR, node)) { 147 | return this.addDescriptionDecoratorToNode(node); 148 | } 149 | } 150 | } 151 | // The final kind is a method declaration node 152 | else if (node.kind == ts.SyntaxKind.MethodDeclaration && this.isIDEFile) { 153 | // Members must be part of a class that has the `@TWWidgetDefinition` decorator 154 | // and must not have the `@description` decorator themselves 155 | if ( 156 | node.parent.kind == ts.SyntaxKind.ClassDeclaration && 157 | this.hasDecoratorNamed(WIDGET_SERVICE_DECORATOR, node) 158 | ) { 159 | if (!this.hasDecoratorNamed(DESCRIPTION_DECORATOR, node)) { 160 | return this.addDescriptionDecoratorToNode(node); 161 | } 162 | } 163 | } 164 | 165 | return ts.visitEachChild(node, (node) => this.visit(node), this.context); 166 | } 167 | 168 | /** 169 | * Adds the description decorator to the given node. 170 | * @param {ts.Node} node The node to add the description decorator to. 171 | * @param {ts.Node} originalNode If the target is node is transformed, and JSDoc tags cannot be obtained from it, 172 | * this should be set to the original untransformed node from which the documentation 173 | * can be obtained. 174 | * @returns {ts.Node} A transformed node containing the added description decorator. 175 | */ 176 | addDescriptionDecoratorToNode(node, originalNode) { 177 | // The description is the JSDoc associated to the node, if there is one 178 | const documentation = ts.getJSDocCommentsAndTags(originalNode || node); 179 | if (!documentation.length) return node; 180 | 181 | let description = ''; 182 | 183 | // Get the first documentation node and use it as the description 184 | if (documentation.length) { 185 | for (const documentationNode of documentation) { 186 | if (documentationNode.kind == ts.SyntaxKind.JSDocComment) { 187 | const comment = documentationNode.comment || ''; 188 | if (typeof comment != 'string') { 189 | description = comment.reduce((acc, val) => acc + val.text, ''); 190 | } else { 191 | description = comment; 192 | } 193 | break; 194 | } 195 | } 196 | } 197 | 198 | // Return if the description is empty 199 | if (!description) return node; 200 | 201 | // The description decorator is a decorator factory, so a call expression has to be created for it 202 | const descriptionCall = this.context.factory.createCallExpression( 203 | this.context.factory.createIdentifier(DESCRIPTION_DECORATOR), 204 | undefined, 205 | [this.context.factory.createStringLiteral(description, false)], 206 | ); 207 | 208 | const decorator = this.context.factory.createDecorator(descriptionCall); 209 | 210 | switch (node.kind) { 211 | case ts.SyntaxKind.ClassDeclaration: 212 | /** @type {ts.ClassDeclaration} */ const classNode = node; 213 | return this.context.factory.updateClassDeclaration( 214 | classNode, 215 | [decorator].concat(classNode.decorators || []), 216 | classNode.modifiers, 217 | classNode.name, 218 | classNode.typeParameters, 219 | classNode.heritageClauses, 220 | classNode.members, 221 | ); 222 | case ts.SyntaxKind.PropertyDeclaration: 223 | /** @type {ts.PropertyDeclaration} */ const propNode = node; 224 | return this.context.factory.updatePropertyDeclaration( 225 | propNode, 226 | [decorator].concat(propNode.decorators || []), 227 | propNode.modifiers, 228 | propNode.name, 229 | propNode.questionToken || propNode.exclamationToken, 230 | propNode.type, 231 | propNode.initializer, 232 | ); 233 | case ts.SyntaxKind.MethodDeclaration: 234 | /** @type {ts.MethodDeclaration} */ const methodNode = node; 235 | return this.context.factory.updateMethodDeclaration( 236 | methodNode, 237 | [decorator].concat(methodNode.decorators || []), 238 | methodNode.modifiers, 239 | methodNode.asteriskToken, 240 | methodNode.name, 241 | methodNode.questionToken, 242 | methodNode.typeParameters, 243 | methodNode.parameters, 244 | methodNode.type, 245 | methodNode.body, 246 | ); 247 | default: 248 | return node; 249 | } 250 | } 251 | } 252 | 253 | /** 254 | * Returns a description transformer function. 255 | * @return A transformer function. 256 | */ 257 | function DescriptionTransformerFactory() { 258 | // Note that this function is currently useless, but can be used in the future to specify construction arguments 259 | return function DescriptionTransformerFunction( 260 | /** @type {ts.TransformationContext} */ context, 261 | ) { 262 | const transformer = new DescriptionTransformer(context); 263 | 264 | return (/** @type {ts.Node} */ node) => 265 | ts.visitNode(node, (node) => transformer.visit(node)); 266 | }; 267 | } 268 | 269 | exports.DescriptionTransformer = DescriptionTransformer; 270 | exports.DescriptionTransformerFactory = DescriptionTransformerFactory; 271 | -------------------------------------------------------------------------------- /webpack/TWClient.ts: -------------------------------------------------------------------------------- 1 | // This file is based on https://github.com/BogdanMihaiciuc/ThingCLI/blob/master/src/Utilities/TWClient.ts 2 | // No changes should be done here, rather they should be done upstream 3 | import * as fs from 'fs'; 4 | import * as Path from 'path'; 5 | 6 | /** 7 | * The options that may be passed to a thingworx request. 8 | */ 9 | interface TWClientRequestOptions { 10 | /** 11 | * The endpoint to invoke 12 | */ 13 | url: string; 14 | 15 | /** 16 | * An optional set of HTTP headers to include in addition to the 17 | * default thingworx headers. 18 | */ 19 | headers?: Record; 20 | 21 | /** 22 | * An optional text or JSON body to send. 23 | */ 24 | body?: string | Record; 25 | 26 | /** 27 | * An optional multipart body to send. 28 | */ 29 | formData?: FormData; 30 | } 31 | 32 | /** 33 | * The interface for an object that contains the response returned from 34 | * a TWClient request. 35 | */ 36 | interface TWClientResponse { 37 | /** 38 | * The response's body. 39 | */ 40 | body: string; 41 | 42 | /** 43 | * The response headers. 44 | */ 45 | headers: Headers; 46 | 47 | /** 48 | * The status code. 49 | */ 50 | statusCode?: number; 51 | 52 | /** 53 | * The status message. 54 | */ 55 | statusMessage?: string; 56 | } 57 | 58 | /** 59 | * A class that is responsible for performing requests to a thingworx server. 60 | */ 61 | export class TWClient { 62 | /** 63 | * The cached package.json contents. 64 | */ 65 | private static _cachedPackageJSON?: any; 66 | 67 | /** 68 | * The contents of the project's package.json file. 69 | */ 70 | private static get _packageJSON() { 71 | if (this._cachedPackageJSON) return this._cachedPackageJSON; 72 | this._cachedPackageJSON = require(`${process.cwd()}/package.json`) as TWPackageJSON; 73 | return this._cachedPackageJSON; 74 | } 75 | 76 | /** 77 | * The cached connection details. 78 | */ 79 | private static _cachedConnectionDetails?: TWPackageJSONConnectionDetails; 80 | 81 | /** 82 | * The cached header to use for Authentication. 83 | * Automatically set when the _cachedConnectionDetails are accessed 84 | */ 85 | private static _authenticationHeaders?: Record; 86 | 87 | /** 88 | * The connection details to be used. 89 | */ 90 | private static get _connectionDetails(): TWPackageJSONConnectionDetails { 91 | // Return the cached connection details if they exist. 92 | if (this._cachedConnectionDetails) { 93 | return this._cachedConnectionDetails; 94 | } 95 | 96 | // Otherwise try to get them from the environment variables, falling back to loading 97 | // them from package.json if they are not defined in the environment. 98 | if (!process.env.THINGWORX_SERVER) { 99 | console.error( 100 | 'The thingworx server is not defined in your environment, defaulting to loading from package.json', 101 | ); 102 | this._cachedConnectionDetails = { 103 | thingworxServer: this._packageJSON.thingworxServer, 104 | thingworxUser: this._packageJSON.thingworxUser, 105 | thingworxPassword: this._packageJSON.thingworxPassword, 106 | thingworxAppKey: this._packageJSON.thingworxAppKey, 107 | }; 108 | } else { 109 | this._cachedConnectionDetails = { 110 | thingworxServer: process.env.THINGWORX_SERVER, 111 | thingworxUser: process.env.THINGWORX_USER, 112 | thingworxPassword: process.env.THINGWORX_PASSWORD, 113 | thingworxAppKey: process.env.THINGWORX_APPKEY, 114 | }; 115 | } 116 | 117 | // Try to authorize using an app key if provided, which is the preferred method 118 | if (this._connectionDetails.thingworxAppKey) { 119 | this._authenticationHeaders = { appKey: this._connectionDetails.thingworxAppKey }; 120 | } 121 | // Otherwise use the username and password combo 122 | else if ( 123 | this._connectionDetails.thingworxUser && 124 | this._connectionDetails.thingworxPassword 125 | ) { 126 | const basicAuth = Buffer.from( 127 | this._connectionDetails.thingworxUser + 128 | ':' + 129 | this._connectionDetails.thingworxPassword, 130 | ).toString('base64'); 131 | this._authenticationHeaders = { Authorization: 'Basic ' + basicAuth }; 132 | } else { 133 | throw new Error( 134 | 'Unable to authorize a request to thingworx because an app key or username/password combo was not provided.', 135 | ); 136 | } 137 | 138 | return this._cachedConnectionDetails; 139 | } 140 | 141 | /** 142 | * Returns the thingworx server. 143 | */ 144 | static get server(): string | undefined { 145 | return this._connectionDetails.thingworxServer; 146 | } 147 | 148 | /** 149 | * Performs a request, returning a promise that resolves with its response. 150 | * @param options The requests's options. 151 | * @returns A promise that resolves with the response when 152 | * the request finishes. 153 | */ 154 | private static async _performRequest( 155 | options: TWClientRequestOptions, 156 | method: 'get' | 'post' = 'post', 157 | ): Promise { 158 | const { thingworxServer: host } = this._connectionDetails; 159 | 160 | // Automatically prepend the base thingworx url 161 | options.url = `${host}/Thingworx/${options.url}`; 162 | 163 | // Automatically add the thingworx specific headers to options 164 | const headers = Object.assign( 165 | {}, 166 | options.headers || {}, 167 | { 168 | 'X-XSRF-TOKEN': 'TWX-XSRF-TOKEN-VALUE', 169 | 'X-THINGWORX-SESSION': 'true', 170 | Accept: 'application/json', 171 | }, 172 | this._authenticationHeaders, 173 | ); 174 | 175 | const fetchOptions: RequestInit = { method, headers }; 176 | 177 | if (options.body) { 178 | // If the body is specified as an object, stringify it 179 | if (typeof options.body == 'object') { 180 | fetchOptions.body = JSON.stringify(options.body); 181 | } else { 182 | fetchOptions.body = options.body; 183 | } 184 | } else if (options.formData) { 185 | fetchOptions.body = options.formData; 186 | } 187 | 188 | const response = await fetch(options.url, fetchOptions); 189 | 190 | return { 191 | body: await response.text(), 192 | headers: response.headers, 193 | statusCode: response.status, 194 | statusMessage: response.statusText, 195 | }; 196 | } 197 | 198 | /** 199 | * Deletes the specified extension from the thingworx server. 200 | * @param name The name of the extension to remove. 201 | * @returns A promise that resolves with the server response when the 202 | * operation finishes. 203 | */ 204 | static async removeExtension(name: string): Promise { 205 | return await this._performRequest({ 206 | url: `Subsystems/PlatformSubsystem/Services/DeleteExtensionPackage`, 207 | headers: { 208 | 'Content-Type': 'application/json', 209 | }, 210 | body: { packageName: name }, 211 | }); 212 | } 213 | 214 | /** 215 | * Imports the specified extension package to the thingworx server. 216 | * @param data A form data object containing the extension to import. 217 | * @returns A promise that resolves with the server response when 218 | * the operation finishes. 219 | */ 220 | static async importExtension(formData: FormData): Promise { 221 | return await this._performRequest({ 222 | url: `ExtensionPackageUploader?purpose=import`, 223 | formData: formData, 224 | }); 225 | } 226 | 227 | /** 228 | * Sends a POST request to the specified endpoint, with an empty body. 229 | * @param endpoint The endpoint. 230 | * @returns A promise that resolves with the server response when 231 | * the operation finishes. 232 | */ 233 | static async invokeEndpoint(endpoint: string): Promise { 234 | return await this._performRequest({ 235 | url: endpoint, 236 | headers: { 237 | 'Content-Type': 'application/json', 238 | }, 239 | }); 240 | } 241 | 242 | /** 243 | * Retrieves the metadata of the specified entity. 244 | * @param name The name of the entity. 245 | * @param kind The kind of entity. 246 | * @returns A promise that resolves with the server response when 247 | * the operation finishes. 248 | */ 249 | static async getEntity(name: string, kind: string): Promise { 250 | const url = `${kind}/${name}${kind == 'Resources' ? '/Metadata' : ''}`; 251 | return await this._performRequest({ url }, 'get'); 252 | } 253 | 254 | /** 255 | * Retrieves a list containing the entities that the specified entity depends on. 256 | * @param name The name of the entity. 257 | * @param kind The kind of entity. 258 | * @returns A promise that resolves with the server response when 259 | * the operation finishes. 260 | */ 261 | static async getEntityDependencies(name: string, kind: string): Promise { 262 | const url = `${kind}/${name}/Services/GetOutgoingDependencies`; 263 | return await this._performRequest({ 264 | url, 265 | headers: { 266 | 'Content-Type': 'application/json', 267 | }, 268 | }); 269 | } 270 | 271 | /** 272 | * Retrieves a list containing the entities that are part of the specified project. 273 | * @param name The name of the project. 274 | * @returns A promise that resolves with the server response when 275 | * the operation finishes. 276 | */ 277 | static async getProjectEntities(name: string): Promise { 278 | const url = `Resources/SearchFunctions/Services/SpotlightSearch`; 279 | return await this._performRequest({ 280 | url, 281 | headers: { 282 | 'Content-Type': 'application/json', 283 | }, 284 | body: JSON.stringify({ 285 | searchExpression: '**', 286 | withPermissions: false, 287 | sortBy: 'name', 288 | isAscending: true, 289 | searchDescriptions: true, 290 | aspects: { 291 | isSystemObject: false, 292 | }, 293 | projectName: name, 294 | searchText: '', 295 | }), 296 | }); 297 | } 298 | 299 | /** 300 | * Retrieves the typings file for the specified extension package. 301 | * @param name The name of the extension package. 302 | * @returns A promise that resolves with the server response when 303 | * the operation finishes. 304 | */ 305 | static async getExtensionTypes(name: string): Promise { 306 | const url = `Common/extensions/${name}/ui/@types/index.d.ts`; 307 | return await this._performRequest({ url }, 'get'); 308 | } 309 | 310 | /** 311 | * Retrieves the package details of the specified extension package. 312 | * @param name The name of the extension package. 313 | * @returns A promise that resolves with the server response when 314 | * the operation finishes. 315 | */ 316 | static async getExtensionPackageDetails(name: string): Promise { 317 | return await this._performRequest({ 318 | url: 'Subsystems/PlatformSubsystem/Services/GetExtensionPackageDetails', 319 | headers: { 320 | 'Content-Type': 'application/json', 321 | }, 322 | body: JSON.stringify({ packageName: name }), 323 | }); 324 | } 325 | 326 | /** 327 | * Executes the source control import of a path on the file repository into ThingWorx. 328 | * @param fileRepository Name of the ThingWorx FileRepository thing from where the import happens 329 | * @param path Path in the `fileRepository` where the entities are. 330 | * @param projectName Defaults to `'project'`. The name of the project being imported. 331 | * This is only used for error reporting if the import fails. 332 | * @returns A promise that resolves with the server response when 333 | * the operation finishes. 334 | */ 335 | static async sourceControlImport( 336 | fileRepository: string, 337 | path: string, 338 | projectName?: string, 339 | ): Promise { 340 | const url = `Resources/SourceControlFunctions/Services/ImportSourceControlledEntities`; 341 | 342 | try { 343 | const response = await this._performRequest({ 344 | url, 345 | headers: { 346 | 'Content-Type': 'application/json', 347 | }, 348 | body: { 349 | repositoryName: fileRepository, 350 | path: path, 351 | includeDependents: false, 352 | overwritePropertyValues: true, 353 | useDefaultDataProvider: false, 354 | withSubsystems: false, 355 | }, 356 | }); 357 | if (response.statusCode != 200) { 358 | throw new Error( 359 | `Got status code ${response.statusCode} (${response.statusMessage}). Body: ${response.body}`, 360 | ); 361 | } 362 | return response; 363 | } catch (err) { 364 | throw new Error( 365 | `Error executing source control import for project '${ 366 | projectName || 'project' 367 | }' because: ${err}`, 368 | ); 369 | } 370 | } 371 | 372 | /** 373 | * Uploads a local file into a ThingWorx file repository. 374 | * @param filePath Local path to the folder the file is in. 375 | * @param fileName Name of the file to be uploaded. 376 | * @param fileRepository Name of the TWX file repository the file should be uploaded to. 377 | * @param targetPath Remote path in the TWX file repository where the file should be stored. 378 | * @returns A promise that resolves with the server response when 379 | * the operation finishes. 380 | */ 381 | static async uploadFile( 382 | filePath: string, 383 | fileName: string, 384 | fileRepository: string, 385 | targetPath: string, 386 | ): Promise { 387 | try { 388 | // load the file from the build folder 389 | let formData = new FormData(); 390 | formData.append('upload-repository', fileRepository); 391 | formData.append('upload-path', targetPath); 392 | formData.append( 393 | 'upload-files', 394 | new Blob([fs.readFileSync(Path.join(filePath, fileName))]), 395 | fileName, 396 | ); 397 | formData.append('upload-submit', 'Upload'); 398 | 399 | // POST request to the Thingworx FileRepositoryUploader endpoint 400 | const response = await this._performRequest({ 401 | url: 'FileRepositoryUploader', 402 | formData, 403 | }); 404 | 405 | if (response.statusCode != 200) { 406 | throw new Error( 407 | `Got status code ${response.statusCode} (${response.statusMessage}). Body: ${response.body}`, 408 | ); 409 | } 410 | return response; 411 | } catch (err) { 412 | throw new Error(`Error uploading file '${filePath}' into repository because: ${err}`); 413 | } 414 | } 415 | 416 | /** 417 | * Performs a unzip operation on a remote file in a ThingWorx file repository 418 | * @param fileRepository Name of the TWX FileRepository thing 419 | * @param filePath Remote path to where the zip file is 420 | * @param targetFolder Remote path to where the file should be extracted 421 | * @returns A promise that resolves with the server response when 422 | * the operation finishes. 423 | */ 424 | static async unzipAndExtractRemote( 425 | fileRepository: string, 426 | filePath: string, 427 | targetFolder: string, 428 | ): Promise { 429 | const url = `Things/${fileRepository}/Services/ExtractZipArchive`; 430 | 431 | try { 432 | const response = await this._performRequest({ 433 | url, 434 | headers: { 435 | 'Content-Type': 'application/json', 436 | }, 437 | body: { 438 | path: targetFolder, 439 | zipFileName: filePath, 440 | }, 441 | }); 442 | if (response.statusCode != 200) { 443 | throw new Error( 444 | `Got status code ${response.statusCode} (${response.statusMessage}). Body: ${response.body}`, 445 | ); 446 | } 447 | return response; 448 | } catch (err) { 449 | throw new Error(`Error executing remote file unzip because: ${err}`); 450 | } 451 | } 452 | 453 | /** 454 | * Deletes the specified remote folder in a ThingWorx file repository. 455 | * @param fileRepository Name of the FileRepository from which the folder should be deleted. 456 | * @param targetFolder Remote path to the folder to be deleted. 457 | * @returns A promise that resolves with the server response when 458 | * the operation finishes. 459 | */ 460 | static async deleteRemoteDirectory( 461 | fileRepository: string, 462 | targetFolder: string, 463 | ): Promise { 464 | const url = `Things/${fileRepository}/Services/DeleteFolder`; 465 | try { 466 | const response = await this._performRequest({ 467 | url, 468 | headers: { 469 | 'Content-Type': 'application/json', 470 | }, 471 | body: { 472 | path: targetFolder, 473 | }, 474 | }); 475 | if (response.statusCode != 200) { 476 | throw new Error( 477 | `Got status code ${response.statusCode} (${response.statusMessage}). Body: ${response.body}`, 478 | ); 479 | } 480 | return response; 481 | } catch (err) { 482 | throw new Error(`Error executing remote folder delete because: ${err}`); 483 | } 484 | } 485 | 486 | /** 487 | * Execute a source control export of the specified project. 488 | * @param project The name of the Thingworx project to export. 489 | * @param fileRepository Name of the FileRepository where the export will be saved. 490 | * @param path Remote path where the files should be exported to. 491 | * @param name Name of the folder where the files should be stored. 492 | * @returns A promise that resolves with the URL where the zip containing the exports is found 493 | * when the operation completes. 494 | */ 495 | static async sourceControlExport( 496 | project: string, 497 | fileRepository: string, 498 | path: string, 499 | name: string, 500 | ): Promise { 501 | const { thingworxServer: host } = this._connectionDetails; 502 | 503 | try { 504 | // Do a ExportToSourceControl to export the project 505 | const exportResponse = await this._performRequest({ 506 | url: 'Resources/SourceControlFunctions/Services/ExportSourceControlledEntities', 507 | headers: { 508 | 'Content-Type': 'application/json', 509 | }, 510 | body: { 511 | projectName: project, 512 | repositoryName: fileRepository, 513 | path: path, 514 | name: name, 515 | exportMatchingModelTags: true, 516 | includeDependents: false, 517 | }, 518 | }); 519 | 520 | if (exportResponse.statusCode != 200) { 521 | throw new Error( 522 | `Got status code ${exportResponse.statusCode} (${exportResponse.statusMessage}). Body: ${exportResponse.body}`, 523 | ); 524 | } 525 | 526 | // Create a zip from the folder that was exported 527 | await this._performRequest({ 528 | url: `Things/${fileRepository}/Services/CreateZipArchive`, 529 | headers: { 530 | 'Content-Type': 'application/json', 531 | }, 532 | body: { 533 | newFileName: project + '.zip', 534 | path: path, 535 | files: path + '/' + project + '/', 536 | }, 537 | }); 538 | 539 | return `${host}/Thingworx/FileRepositories/${fileRepository}/${path}/${project}.zip`; 540 | } catch (err) { 541 | throw new Error( 542 | `Error executing source control export for project '${project}' because: ${err}`, 543 | ); 544 | } 545 | } 546 | 547 | /** 548 | * Downloads a remote file into the specified path. 549 | * @param fileUrl Path to the file to download. 550 | * @param targetPath Local path to where the file should be saved. 551 | * @returns A promise that resolves when the file has been written to the specified path. 552 | */ 553 | static async downloadFile(fileUrl: string, targetPath: string): Promise { 554 | const response = await fetch(fileUrl, { headers: this._authenticationHeaders }); 555 | fs.writeFileSync(targetPath, Buffer.from(await (await response.blob()).arrayBuffer())); 556 | } 557 | } 558 | 559 | /** 560 | * A subset of the package.json file for a thingworx vscode project that contains 561 | * the thingworx connection details. 562 | */ 563 | interface TWPackageJSONConnectionDetails { 564 | /** 565 | * The URL to the thingworx server. 566 | */ 567 | thingworxServer?: string; 568 | 569 | /** 570 | * The username to use when connecting to the thingworx server. 571 | */ 572 | thingworxUser?: string; 573 | 574 | /** 575 | * The password to use when connecting to the thingworx server. 576 | */ 577 | thingworxPassword?: string; 578 | 579 | /** 580 | * When specified, has priority over `thingworxUser` and `thingworxPassword`. 581 | * The app key to use when connecting to the thingworx server. 582 | */ 583 | thingworxAppKey?: string; 584 | } 585 | 586 | /** 587 | * The interface for a package.json file with the thingworx vscode project specific 588 | * entries. 589 | */ 590 | interface TWPackageJSON extends TWPackageJSONConnectionDetails {} 591 | --------------------------------------------------------------------------------