diff --git a/.eslintrc.json b/.eslintrc.json index f9b22b7..0596d3e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,24 +1,265 @@ { - "root": true, - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module" - }, - "plugins": [ - "@typescript-eslint" - ], - "rules": { - "@typescript-eslint/naming-convention": "warn", - "@typescript-eslint/semi": "warn", - "curly": "warn", - "eqeqeq": "warn", - "no-throw-literal": "warn", - "semi": "off" - }, - "ignorePatterns": [ - "out", - "dist", - "**/*.d.ts" + "env": { + "browser": false, + "es2021": true + }, + "extends": [ + "prettier", + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "airbnb-typescript/base", + "airbnb-base", + "plugin:sonarjs/recommended", + "plugin:jsdoc/recommended-typescript" + ], + "overrides": [ + { + "files": [ + "**/*.spec.ts", + "**/*.mock.ts" + ], + "env": { + "jest": true + }, + "rules": { + "import/first": "off", + "max-lines-per-function": "off", + "import/no-extraneous-dependencies": "off", + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/dot-notation": "off", + "sonarjs/no-duplicate-string": "off", + "@typescript-eslint/no-non-null-assertion": "off" + } + } + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module", + "project": [ + "./tsconfig.json" ] -} + }, + "plugins": [ + "@typescript-eslint", + "sonarjs", + "prettier", + "jsdoc" + ], + "rules": { + "quotes": [ + "error", + "double" + ], + "semi": [ + "warn", + "always" + ], + "import/extensions": [ + "error", + "never" + ], + "no-await-in-loop": "error", + "no-constant-binary-expression": "error", + "no-constructor-return": "error", + "no-duplicate-imports": "error", + "no-self-compare": "error", + "no-template-curly-in-string": "error", + "no-unmodified-loop-condition": "warn", + "no-unreachable-loop": "warn", + "no-unused-private-class-members": "warn", + "no-use-before-define": "off", + "@typescript-eslint/no-use-before-define": "off", + "camelcase": "off", + "dot-notation": "off", + "func-style": [ + "error", + "declaration" + ], + "max-depth": [ + "warn", + 4 + ], + "max-lines": [ + "warn", + { + "max": 500, + "skipBlankLines": true, + "skipComments": true + } + ], + "max-lines-per-function": [ + "warn", + { + "max": 40, + "skipBlankLines": true, + "skipComments": true + } + ], + "no-confusing-arrow": "warn", + "no-console": "off", + "no-else-return": [ + "error", + { + "allowElseIf": true + } + ], + "no-lone-blocks": "error", + "no-lonely-if": "error", + "no-magic-numbers": [ + "warn", + { + "ignore": [ + 0, + 1 + ] + } + ], + "no-new": "warn", + "no-return-await": "error", + "no-sequences": "error", + "no-unused-expressions": "error", + "no-useless-computed-key": "warn", + "no-useless-concat": "error", + "no-useless-constructor": "off", + "@typescript-eslint/no-useless-constructor": "error", + "no-useless-escape": "error", + "no-useless-call": "error", + "no-useless-rename": "error", + "no-useless-return": "error", + "no-var": "error", + "no-with": "error", + "prefer-arrow-callback": "error", + "prefer-const": "error", + "prefer-destructuring": "warn", + "prefer-template": "error", + "require-await": "error", + "spaced-comment": "error", + "yoda": "error", + "comma-spacing": [ + "error", + { + "before": false, + "after": true + } + ], + "no-extra-parens": "off", + "no-whitespace-before-property": "error", + "no-trailing-spaces": "error", + "object-curly-spacing": [ + "error", + "always" + ], + "object-curly-newline": "off", + "padded-blocks": [ + "error", + "never" + ], + "space-before-blocks": [ + "error", + "always" + ], + "space-before-function-paren": "off", + "@typescript-eslint/await-thenable": "error", + "@typescript-eslint/naming-convention": [ + "error", + { + "selector": "default", + "format": [ + "camelCase" + ] + }, + { + "selector": "variable", + "format": [ + "camelCase", + "UPPER_CASE" + ] + }, + { + "selector": "parameter", + "format": [ + "camelCase" + ], + "leadingUnderscore": "allow" + }, + { + "selector": "memberLike", + "modifiers": [ + "private" + ], + "format": [ + "camelCase" + ], + "leadingUnderscore": "allow" + }, + { + "selector": "memberLike", + "format": [ + "camelCase", + "snake_case" + ], + "leadingUnderscore": "allow" + }, + { + "selector": "typeLike", + "format": [ + "PascalCase" + ] + }, + { + "selector": "variable", + "modifiers": [ + "destructured" + ], + "format": null + }, + { + "selector": "interface", + "format": [ + "PascalCase" + ], + "custom": { + "regex": "^I[A-Z]", + "match": false + } + } + ], + "@typescript-eslint/quotes": [ + "error", + "double" + ], + "no-underscore-dangle": "off", + "max-len": [ + "error", + { + "code": 80, + "ignorePattern": "(^import .*$|^export .*$)", + "ignoreStrings": true, + "ignoreComments": true, + "ignoreTemplateLiterals": true + } + ], + "operator-linebreak": "off", + "indent": "off", + "@typescript-eslint/indent": "off", + "no-empty-function": "off", + "implicit-arrow-linebreak": "off", + "function-paren-newline": "off", + "no-plusplus": [ + "error", + { + "allowForLoopAfterthoughts": true + } + ], + "import/no-unresolved": "off", + "import/order": "off", + "class-methods-use-this": "off", + "brace-style": "off", + "@typescript-eslint/brace-style": "off", + "jsdoc/require-jsdoc": "off", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": "warn" + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 897cb9f..1ce764a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ node_modules package-lock.json -dist \ No newline at end of file +dist +out +coverage +*.vsix diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..3c99943 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "endOfLine": "lf", + "printWidth": 80, + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "all", + "useTabs": false, + "parser": "typescript" +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 08100db..a1ff845 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,23 @@ # openproject README -This is the README for your extension "openproject". After writing up a brief description, we recommend including the following sections. +Extension for [OpenProject](https://www.openproject.org/) - project management system. ## Features -Describe specific features of your extension including screenshots of your extension in action. Image paths are relative to this README file. - -For example if there is an image subfolder under your extension project workspace: - -\!\[feature X\]\(images/feature-x.png\) - -> Tip: Many popular extensions utilize animations. This is an excellent way to show off your extension! We recommend short, focused animations that are easy to follow. +- Authorization. +- Getting list of your work packages. ![picture](https://github.com/bitswar/VSCodeOpenProject/pictures/work_packages.png) ## Requirements -If you have any requirements or dependencies, add a section describing those and how to install and configure them. +- Your OpenProject url +- Your OpenProject token ## Extension Settings -Include if your extension adds any VS Code settings through the `contributes.configuration` extension point. - -For example: - This extension contributes the following settings: -* `myExtension.enable`: Enable/disable this extension. -* `myExtension.thing`: Set to `blah` to do something. - -## Known Issues - -Calling out known issues can help limit users opening duplicate issues against your extension. +* `openproject.base_url`: Your OpenProject url. +* `openproject.token`: Your OpenProject access token. ## Release Notes @@ -37,35 +25,6 @@ Users appreciate release notes as you update your extension. ### 1.0.0 -Initial release of ... - -### 1.0.1 - -Fixed issue #. - -### 1.1.0 - -Added features X, Y, and Z. - ---- - -## Following extension guidelines - -Ensure that you've read through the extensions guidelines and follow the best practices for creating your extension. - -* [Extension Guidelines](https://code.visualstudio.com/api/references/extension-guidelines) - -## Working with Markdown - -You can author your README using Visual Studio Code. Here are some useful editor keyboard shortcuts: - -* Split the editor (`Cmd+\` on macOS or `Ctrl+\` on Windows and Linux). -* Toggle preview (`Shift+Cmd+V` on macOS or `Shift+Ctrl+V` on Windows and Linux). -* Press `Ctrl+Space` (Windows, Linux, macOS) to see a list of Markdown snippets. - -## For more information - -* [Visual Studio Code's Markdown Support](http://code.visualstudio.com/docs/languages/markdown) -* [Markdown Syntax Reference](https://help.github.com/articles/markdown-basics/) +Initial release of OpenProject VSCode extension **Enjoy!** diff --git a/package.json b/package.json index 4add615..d349ced 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "openproject", + "publisher": "bitswar", "displayName": "OpenProject", "description": "OpenProject extension for VSCode", "version": "0.0.1", @@ -9,13 +10,64 @@ "categories": [ "Other" ], + "repository": "https://github.com/bitswar/VSCodeOpenProject", "activationEvents": [], "main": "./dist/extension.js", "contributes": { "commands": [ { - "command": "openproject.helloWorld", - "title": "Hello World" + "command": "openproject.auth", + "title": "Authorize", + "shortTitle": "Auth" + }, + { + "command": "openproject.refresh", + "title": "Refresh work packages", + "shortTitle": "Refresh" + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "openproject", + "icon": "resources/icon.png", + "title": "OpenProject" + } + ] + }, + "views": { + "openproject": [ + { + "id": "openproject-workspaces", + "name": "Workspaces" + } + ] + }, + "viewsWelcome": [ + { + "id": "openproject-login", + "name": "Auth", + "view": "openproject-workspaces", + "type": "webview", + "contents": "Hello! To use OpenProject features you need to authorize:\n[Auth](command:openproject.auth)", + "when": "!openproject.authed" + } + ], + "configuration": [ + { + "title": "OpenProject", + "properties": { + "openproject.token": { + "type": "string", + "default": null, + "description": "OpenProject API token" + }, + "openproject.base_url": { + "type": "string", + "default": "https://board.dipal-local.ru", + "description": "OpenProject base_url" + } + } } ] }, @@ -25,24 +77,57 @@ "package": "webpack --mode production --devtool hidden-source-map", "compile-tests": "tsc -p . --outDir out", "watch-tests": "tsc -p . -w --outDir out", - "pretest": "npm run compile-tests && npm run compile && npm run lint", "lint": "eslint src --ext ts", - "test": "node ./out/test/runTest.js" + "lint:fix": "eslint src --ext ts --fix", + "test": "jest" }, "devDependencies": { - "@types/vscode": "^1.78.0", + "@faker-js/faker": "^8.0.1", "@types/glob": "^8.1.0", + "@types/jest": "^29.5.1", "@types/mocha": "^10.0.1", "@types/node": "16.x", - "@typescript-eslint/eslint-plugin": "^5.59.1", - "@typescript-eslint/parser": "^5.59.1", - "eslint": "^8.39.0", + "@types/vscode": "^1.78.0", + "@typescript-eslint/eslint-plugin": "^5.59.7", + "@typescript-eslint/parser": "^5.59.7", + "@vscode/test-electron": "^2.3.0", + "copy-webpack-plugin": "^11.0.0", + "eslint": "^8.41.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-airbnb-typescript": "^17.0.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-airbnb": "^0.0.1-security", + "eslint-plugin-jsdoc": "^44.2.5", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-sonarjs": "^0.19.0", "glob": "^8.1.0", + "jest": "^29.5.0", "mocha": "^10.2.0", - "typescript": "^5.0.4", + "ts-jest": "^29.1.0", "ts-loader": "^9.4.2", + "typescript": "^5.0.4", "webpack": "^5.81.0", - "webpack-cli": "^5.0.2", - "@vscode/test-electron": "^2.3.0" + "webpack-cli": "^5.0.2" + }, + "dependencies": { + "axios": "^1.4.0", + "client-oauth2": "^4.3.3", + "op-client": "^1.4.2" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage" } } diff --git a/pictures/work_packages.png b/pictures/work_packages.png new file mode 100644 index 0000000..15f72d7 Binary files /dev/null and b/pictures/work_packages.png differ diff --git a/resources/closed.png b/resources/closed.png new file mode 100644 index 0000000..a3a5303 Binary files /dev/null and b/resources/closed.png differ diff --git a/resources/confirmed.png b/resources/confirmed.png new file mode 100644 index 0000000..587271b Binary files /dev/null and b/resources/confirmed.png differ diff --git a/resources/developed.png b/resources/developed.png new file mode 100644 index 0000000..92622c0 Binary files /dev/null and b/resources/developed.png differ diff --git a/resources/developing.png b/resources/developing.png new file mode 100644 index 0000000..9e2c7f1 Binary files /dev/null and b/resources/developing.png differ diff --git a/resources/failed.png b/resources/failed.png new file mode 100644 index 0000000..9385295 Binary files /dev/null and b/resources/failed.png differ diff --git a/resources/hold.png b/resources/hold.png new file mode 100644 index 0000000..4187bdd Binary files /dev/null and b/resources/hold.png differ diff --git a/resources/icon.png b/resources/icon.png new file mode 100644 index 0000000..22e855e Binary files /dev/null and b/resources/icon.png differ diff --git a/resources/in_specification.png b/resources/in_specification.png new file mode 100644 index 0000000..8329632 Binary files /dev/null and b/resources/in_specification.png differ diff --git a/resources/new.png b/resources/new.png new file mode 100644 index 0000000..8870415 Binary files /dev/null and b/resources/new.png differ diff --git a/resources/rejected.png b/resources/rejected.png new file mode 100644 index 0000000..b9160f6 Binary files /dev/null and b/resources/rejected.png differ diff --git a/resources/specified.png b/resources/specified.png new file mode 100644 index 0000000..0419bf1 Binary files /dev/null and b/resources/specified.png differ diff --git a/resources/tested.png b/resources/tested.png new file mode 100644 index 0000000..433cd9e Binary files /dev/null and b/resources/tested.png differ diff --git a/resources/testing.png b/resources/testing.png new file mode 100644 index 0000000..3f2cc65 Binary files /dev/null and b/resources/testing.png differ diff --git a/src/__mocks__/vscode.js b/src/__mocks__/vscode.js new file mode 100644 index 0000000..e406dc3 --- /dev/null +++ b/src/__mocks__/vscode.js @@ -0,0 +1,77 @@ +/* eslint-disable max-classes-per-file */ +/* eslint-disable no-undef */ +/* eslint-disable @typescript-eslint/naming-convention */ + +class Disposable {} + +class EventEmitter {} + +const languages = { + createDiagnosticCollection: jest.fn(), +}; + +const StatusBarAlignment = {}; + +const window = { + createStatusBarItem: jest.fn(() => ({ + show: jest.fn(), + })), + showErrorMessage: jest.fn(), + showWarningMessage: jest.fn(), + showInformationMessage: jest.fn(), + createTextEditorDecorationType: jest.fn(), + createTreeView: jest.fn(), +}; + +const workspace = { + getConfiguration: jest.fn(), + workspaceFolders: [], + onDidSaveTextDocument: jest.fn(), +}; + +const OverviewRulerLane = { + Left: null, +}; + +const Uri = { + file: (f) => f, + parse: jest.fn(), +}; +const Range = jest.fn(); +const Diagnostic = jest.fn(); +const DiagnosticSeverity = { Error: 0, Warning: 1, Information: 2, Hint: 3 }; + +const debug = { + onDidTerminateDebugSession: jest.fn(), + startDebugging: jest.fn(), +}; + +const commands = { + executeCommand: jest.fn(), + registerCommand: jest.fn(), +}; + +const TreeItemCollapsibleState = { + None: 0, + Collapsed: 1, + Expanded: 2, +}; + +const vscode = { + TreeItemCollapsibleState, + EventEmitter, + Disposable, + languages, + StatusBarAlignment, + window, + workspace, + OverviewRulerLane, + Uri, + Range, + Diagnostic, + DiagnosticSeverity, + debug, + commands, +}; + +module.exports = vscode; diff --git a/src/commands/authorizeClient.command.spec.ts b/src/commands/authorizeClient.command.spec.ts new file mode 100644 index 0000000..613d609 --- /dev/null +++ b/src/commands/authorizeClient.command.spec.ts @@ -0,0 +1,110 @@ +// jest.mock("../__mocks__/vscode", () => vscode); +jest.mock("../openProject.client"); +jest.mock("../views/openProject.treeDataProvider"); +const vscode = require("../__mocks__/vscode"); + +import { faker } from "@faker-js/faker"; +import { User } from "op-client"; +import OpenProjectClient from "../openProject.client"; +import VSCodeConfigMock from "../test/config.mock"; +import OpenProjectTreeDataProvider from "../views/openProject.treeDataProvider"; +import authorizeClient from "./authorizeClient.command"; + +describe("Authorize client command test suit", () => { + const client = new OpenProjectClient(); + const config = new VSCodeConfigMock({ + base_url: faker.internet.url(), + token: faker.string.sample(), + }); + const user = new User(1); + const treeDataProvider = { refreshWPs: jest.fn() }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(OpenProjectClient, "getInstance").mockReturnValue(client); + jest.spyOn(vscode.workspace, "getConfiguration").mockReturnValue(config); + jest.spyOn(client, "init").mockResolvedValue(user); + + jest + .spyOn(OpenProjectTreeDataProvider, "getInstance") + .mockReturnValue(treeDataProvider as any); + + user.firstName = faker.person.firstName(); + user.lastName = faker.person.lastName(); + }); + + describe("Init call", () => { + it("should call init with correct data", async () => { + await authorizeClient(); + expect(client.init).toHaveBeenLastCalledWith( + config.get("base_url"), + config.get("token"), + ); + }); + it("should call init empty string", async () => { + const emptyConfig = new VSCodeConfigMock({}); + jest + .spyOn(vscode.workspace, "getConfiguration") + .mockReturnValue(emptyConfig); + + await authorizeClient(); + + expect(client.init).toHaveBeenLastCalledWith("", ""); + }); + }); + + describe("On success", () => { + it("should set 'openproject.authed' to true on success", async () => { + jest.spyOn(vscode.commands, "executeCommand"); + + await authorizeClient(); + + expect(vscode.commands.executeCommand).toHaveBeenLastCalledWith( + "setContext", + "openproject.authed", + true, + ); + }); + + it("should show message 'Hello' on success", async () => { + jest.spyOn(vscode.window, "showInformationMessage"); + + await authorizeClient(); + + expect(vscode.window.showInformationMessage).toHaveBeenLastCalledWith( + `Hello, ${user.firstName} ${user.lastName}!`, + ); + }); + + it("should call 'refresh WPs' on treeDataProvider", async () => { + await authorizeClient(); + + expect(treeDataProvider.refreshWPs).toHaveBeenCalled(); + }); + }); + describe("On fail", () => { + it("should show error message", async () => { + jest.spyOn(vscode.window, "showErrorMessage"); + jest.spyOn(client, "init").mockResolvedValue(undefined); + + await authorizeClient(); + + expect(vscode.window.showErrorMessage).toHaveBeenLastCalledWith( + "Failed connecting to OpenProject", + ); + }); + + it("should call nothing else", async () => { + jest.spyOn(client, "init").mockResolvedValue(undefined); + jest.spyOn(OpenProjectTreeDataProvider, "getInstance"); + jest.spyOn(vscode.commands, "executeCommand"); + jest.spyOn(vscode.window, "showInformationMessage"); + + await authorizeClient(); + + expect(OpenProjectTreeDataProvider.getInstance).not.toHaveBeenCalled(); + expect(vscode.commands.executeCommand).not.toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/commands/authorizeClient.command.ts b/src/commands/authorizeClient.command.ts new file mode 100644 index 0000000..c777c89 --- /dev/null +++ b/src/commands/authorizeClient.command.ts @@ -0,0 +1,21 @@ +import OpenProjectClient from "../openProject.client"; +import * as vscode from "vscode"; +import OpenProjectTreeDataProvider from "../views/openProject.treeDataProvider"; + +export default async function authorizeClient() { + const config = vscode.workspace.getConfiguration("openproject"); + const client = OpenProjectClient.getInstance(); + const user = await client.init( + config.get("base_url") ?? "", + config.get("token") ?? "", + ); + if (!user) { + vscode.window.showErrorMessage("Failed connecting to OpenProject"); + return; + } + vscode.commands.executeCommand("setContext", "openproject.authed", true); + vscode.window.showInformationMessage( + `Hello, ${user.firstName} ${user.lastName}!`, + ); + OpenProjectTreeDataProvider.getInstance().refreshWPs(); +} diff --git a/src/commands/refreshWPs.command.spec.ts b/src/commands/refreshWPs.command.spec.ts new file mode 100644 index 0000000..061a411 --- /dev/null +++ b/src/commands/refreshWPs.command.spec.ts @@ -0,0 +1,20 @@ +jest.mock("../views/openProject.treeDataProvider"); + +import OpenProjectTreeDataProvider from "../views/openProject.treeDataProvider"; +import refreshWPs from "./refreshWPs.command"; + +describe("refresh WPs command test suit", () => { + it("should call refreshWPs func", () => { + const treeDataProvider = { refreshWPs: jest.fn() }; + + jest + .spyOn(OpenProjectTreeDataProvider, "getInstance") + .mockReturnValue(treeDataProvider as any); + + jest.spyOn(treeDataProvider, "refreshWPs"); + + refreshWPs(); + + expect(treeDataProvider.refreshWPs).toHaveBeenCalled(); + }); +}); diff --git a/src/commands/refreshWPs.command.ts b/src/commands/refreshWPs.command.ts new file mode 100644 index 0000000..d076de6 --- /dev/null +++ b/src/commands/refreshWPs.command.ts @@ -0,0 +1,5 @@ +import OpenProjectTreeDataProvider from "../views/openProject.treeDataProvider"; + +export default function refreshWPs() { + OpenProjectTreeDataProvider.getInstance().refreshWPs(); +} diff --git a/src/extension.spec.ts b/src/extension.spec.ts new file mode 100644 index 0000000..0e473d7 --- /dev/null +++ b/src/extension.spec.ts @@ -0,0 +1,52 @@ +const vscode = require("./__mocks__/vscode"); + +jest.mock("./views/openProject.treeDataProvider"); +jest.mock("./commands/authorizeClient.command"); +jest.mock("./commands/refreshWPs.command"); + +import OpenProjectTreeDataProvider from "./views/openProject.treeDataProvider"; +import authorizeClient from "./commands/authorizeClient.command"; +import refreshWPs from "./commands/refreshWPs.command"; +import { activate, deactivate } from "./extension"; + +describe("activate", () => { + let context: any; + + beforeEach(() => { + context = { + subscriptions: [], + }; + }); + + test("registers expected commands", () => { + activate(context); + expect(vscode.commands.registerCommand).toHaveBeenCalledWith( + "openproject.auth", + authorizeClient, + ); + expect(vscode.commands.registerCommand).toHaveBeenCalledWith( + "openproject.refresh", + refreshWPs, + ); + }); + + test("creates expected tree view", () => { + activate(context); + expect(vscode.window.createTreeView).toHaveBeenCalledWith( + "openproject-workspaces", + expect.any(Object), + ); + expect(OpenProjectTreeDataProvider.getInstance).toHaveBeenCalled(); + }); + + test("adds commands and subscriptions to context", () => { + activate(context); + expect(context.subscriptions).toHaveLength(2); + }); +}); + +describe("deactivate", () => { + it("nothing should happen", () => { + deactivate(); + }); +}); diff --git a/src/extension.ts b/src/extension.ts index 1a90a29..bc39f6f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,12 +1,22 @@ -import * as vscode from 'vscode'; +import * as vscode from "vscode"; +import OpenProjectTreeDataProvider from "./views/openProject.treeDataProvider"; +import authorizeClient from "./commands/authorizeClient.command"; +import refreshWPs from "./commands/refreshWPs.command"; export function activate(context: vscode.ExtensionContext) { - console.log('Congratulations, your extension "openproject" is now active!'); - - let disposable = vscode.commands.registerCommand('openproject.helloWorld', () => { - vscode.window.showInformationMessage('Hello World from OpenProject!'); - }); - - context.subscriptions.push(disposable); + authorizeClient(); + const authCommand = vscode.commands.registerCommand( + "openproject.auth", + authorizeClient, + ); + const refreshWPsCommand = vscode.commands.registerCommand( + "openproject.refresh", + refreshWPs, + ); + vscode.window.createTreeView("openproject-workspaces", { + treeDataProvider: OpenProjectTreeDataProvider.getInstance(), + }); + context.subscriptions.push(authCommand, refreshWPsCommand); } + export function deactivate() {} diff --git a/src/openProject.client.spec.ts b/src/openProject.client.spec.ts new file mode 100644 index 0000000..7e98421 --- /dev/null +++ b/src/openProject.client.spec.ts @@ -0,0 +1,108 @@ +import { faker } from "@faker-js/faker"; +import { User, WP } from "op-client"; +import OpenProjectClient, { + addTokenToUrl, + createLogger, +} from "./openProject.client"; + +jest.mock("op-client"); + +describe("OpenProject Client tests", () => { + let client: OpenProjectClient; + + beforeEach(() => { + jest.clearAllMocks(); + client = OpenProjectClient.getInstance(); + client["entityManager"] = { + fetch: jest.fn(), + get: jest.fn(), + getMany: jest.fn(), + } as any; + }); + + describe("Initialization", () => { + it("should be initialized correctly", async () => { + const token = faker.string.alphanumeric(); + const baseUrl = faker.internet.url(); + const user = new User(1); + + jest.spyOn(client, "getUser").mockResolvedValueOnce(user); + + expect(await client.init(baseUrl, token)).toEqual(user); + }); + }); + + describe("GetUser", () => { + it("should call entity manager's fetch", async () => { + jest.spyOn(client["entityManager"]!, "fetch").mockResolvedValueOnce(1); + + await client.getUser(); + + expect(client["entityManager"]!.fetch).toHaveBeenCalledWith( + "api/v3/users/me", + ); + }); + it("should return user", async () => { + const user = new User(1); + + jest.spyOn(client["entityManager"]!, "fetch").mockResolvedValueOnce(user); + + expect(await client.getUser()).toEqual(user); + }); + it("should return undefined if entity manager is null", async () => { + client["entityManager"] = undefined; + + expect(await client.getUser()).toBeUndefined(); + }); + it("should return undefined if error returned", async () => { + jest.spyOn(client["entityManager"]!, "fetch").mockRejectedValueOnce(null); + + expect(await client.getUser()).toBeUndefined(); + }); + }); + + describe("getWPs", () => { + it("should call getMany", async () => { + jest.spyOn(client["entityManager"]!, "getMany").mockResolvedValueOnce([]); + + await client.getWPs(); + + expect(client["entityManager"]!.getMany).toHaveBeenLastCalledWith(WP, { + pageSize: 100, + all: true, + filters: [], + }); + }); + it("should return wps", async () => { + const wps = faker.helpers.uniqueArray(() => new WP(1), 5); + + jest + .spyOn(client["entityManager"]!, "getMany") + .mockResolvedValueOnce(wps); + + expect(await client.getWPs()).toEqual(wps); + }); + it("should return undefined if entity manager is null", async () => { + client["entityManager"] = undefined; + + expect(await client.getWPs()).toBeUndefined(); + }); + }); +}); + +describe("addTokenToUrl", () => { + it("should return correct url", () => { + const token = faker.string.alphanumeric(10); + const url = "http://google.com"; + + const result = `http://apikey:${token}@google.com/`; + + expect(addTokenToUrl(url, token)).toBe(result); + }); +}); + +describe("createLogger", () => { + it("should return console", () => { + expect(createLogger()).toEqual(console); + }); +}); diff --git a/src/openProject.client.ts b/src/openProject.client.ts new file mode 100644 index 0000000..6b66b9d --- /dev/null +++ b/src/openProject.client.ts @@ -0,0 +1,65 @@ +import { EntityManager, User, WP } from "op-client"; +import { URL } from "url"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/naming-convention, import/no-extraneous-dependencies +const ClientOAuth2 = require("client-oauth2"); + +export default class OpenProjectClient { + private static _instance: OpenProjectClient; + + public static getInstance(): OpenProjectClient { + if (!this._instance) { + this._instance = new OpenProjectClient(); + } + return this._instance; + } + + private entityManager?: EntityManager; + + public init( + baseUrl: string, + token: string, + ): Promise | undefined { + this.entityManager = new EntityManager({ + baseUrl: addTokenToUrl(baseUrl, token), + createLogger, + token: new ClientOAuth2({}).createToken(token, {}), + }); + return this.getUser(); + } + + public getUser(): Promise | undefined { + return this.entityManager + ?.fetch("api/v3/users/me") + .then((response) => new User(response)) + .catch((err) => { + console.error(err); + return undefined; + }); + } + + public getWPs(): Promise | undefined { + return this.entityManager?.getMany(WP, { + pageSize: 100, + all: true, + filters: [], + }); + } +} + +/** + * makes http://apikey:*token*@google.com out of http://google.com + * @param url url to which we want to add token + * @param token api token + * @returns url with token + */ +export function addTokenToUrl(url: string, token: string): string { + const parsedUrl = new URL(url); + parsedUrl.username = "apikey"; + parsedUrl.password = token; + return parsedUrl.toString(); +} + +export function createLogger() { + return console; +} diff --git a/src/test/config.mock.ts b/src/test/config.mock.ts new file mode 100644 index 0000000..7d39476 --- /dev/null +++ b/src/test/config.mock.ts @@ -0,0 +1,9 @@ +class VSCodeConfigMock { + constructor(private readonly _data: { [key: string]: any }) {} + + get(key: string) { + return this._data[key]; + } +} + +export default VSCodeConfigMock; diff --git a/src/utils/getIconPathByStatus.util.spec.ts b/src/utils/getIconPathByStatus.util.spec.ts new file mode 100644 index 0000000..3404a11 --- /dev/null +++ b/src/utils/getIconPathByStatus.util.spec.ts @@ -0,0 +1,63 @@ +import { faker } from "@faker-js/faker"; +import getIconPathByStatus from "./getIconPathByStatus.util"; + +describe("getIconPathByStatus test suit", () => { + describe("return correct path suit", () => { + it("should return correct path of 'Confirmed' icon", () => { + expect(getIconPathByStatus("Confirmed")).toEqual( + "resources/confirmed.png", + ); + }); + it("should return correct path of 'In specification' icon", () => { + expect(getIconPathByStatus("In specification")).toEqual( + "resources/in_specification.png", + ); + }); + it("should return correct path of 'Specified' icon", () => { + expect(getIconPathByStatus("Specified")).toEqual( + "resources/specified.png", + ); + }); + it("should return correct path of 'In progress' icon", () => { + expect(getIconPathByStatus("In progress")).toEqual( + "resources/developing.png", + ); + }); + it("should return correct path of 'Developed' icon", () => { + expect(getIconPathByStatus("Developed")).toEqual( + "resources/developed.png", + ); + }); + it("should return correct path of 'In testing' icon", () => { + expect(getIconPathByStatus("In testing")).toEqual( + "resources/testing.png", + ); + }); + it("should return correct path of 'Tested' icon", () => { + expect(getIconPathByStatus("Tested")).toEqual("resources/tested.png"); + }); + it("should return correct path of 'Test failed' icon", () => { + expect(getIconPathByStatus("Test failed")).toEqual( + "resources/failed.png", + ); + }); + it("should return correct path of 'On hold' icon", () => { + expect(getIconPathByStatus("On hold")).toEqual("resources/hold.png"); + }); + it("should return correct path of 'Closed' icon", () => { + expect(getIconPathByStatus("Closed")).toEqual("resources/closed.png"); + }); + it("should return correct path of 'Rejected' icon", () => { + expect(getIconPathByStatus("Rejected")).toEqual("resources/rejected.png"); + }); + it("should return undefined for new", () => { + expect(getIconPathByStatus("New")).toEqual(undefined); + }); + }); + + describe("Others should get undefined", () => { + it("should return undefined", () => { + expect(getIconPathByStatus(faker.string.alpha())).toEqual(undefined); + }); + }); +}); diff --git a/src/utils/getIconPathByStatus.util.ts b/src/utils/getIconPathByStatus.util.ts new file mode 100644 index 0000000..a96872c --- /dev/null +++ b/src/utils/getIconPathByStatus.util.ts @@ -0,0 +1,30 @@ +export default function getIconPathByStatus( + status?: string, +): string | undefined { + switch (status) { + case "Confirmed": + return "resources/confirmed.png"; + case "In specification": + return "resources/in_specification.png"; + case "Specified": + return "resources/specified.png"; + case "In progress": + return "resources/developing.png"; + case "Developed": + return "resources/developed.png"; + case "In testing": + return "resources/testing.png"; + case "Tested": + return "resources/tested.png"; + case "Test failed": + return "resources/failed.png"; + case "On hold": + return "resources/hold.png"; + case "Closed": + return "resources/closed.png"; + case "Rejected": + return "resources/rejected.png"; + default: + return undefined; + } +} diff --git a/src/views/openProject.treeDataProvider.spec.ts b/src/views/openProject.treeDataProvider.spec.ts new file mode 100644 index 0000000..37c0d5d --- /dev/null +++ b/src/views/openProject.treeDataProvider.spec.ts @@ -0,0 +1,372 @@ +import { TreeItemCollapsibleState } from "vscode"; +import OpenProjectTreeDataProvider from "./openProject.treeDataProvider"; + +describe("OpenProjectTreeDataProvider", () => { + let instance: OpenProjectTreeDataProvider; + + beforeEach(() => { + instance = OpenProjectTreeDataProvider.getInstance(); + jest.spyOn(instance["_client"], "getWPs").mockResolvedValue([]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("refreshWPs", () => { + it("should update the workPackages array with new work packages", async () => { + instance["_onDidChangeTreeData"] = { fire: jest.fn() } as any; + jest.spyOn(instance["_client"], "getWPs").mockResolvedValue([ + { + id: 1, + subject: "Test Work Package", + status: { + self: { + title: "New", + }, + }, + children: [], + parent: null, + ancestor: null, + } as any, + ]); + + await instance.refreshWPs(); + + expect(instance["workPackages"]).toEqual([ + { + id: 1, + subject: "Test Work Package", + status: { + self: { + title: "New", + }, + }, + children: [], + parent: null, + ancestor: null, + }, + ]); + }); + it("should fire _onDidChangeTreeData", async () => { + instance["_onDidChangeTreeData"] = { fire: jest.fn() } as any; + jest.spyOn(instance["_client"], "getWPs").mockResolvedValue([ + { + id: 1, + subject: "Test Work Package", + status: { + self: { + title: "New", + }, + }, + children: [], + parent: null, + ancestor: null, + } as any, + ]); + + await instance.refreshWPs(); + + expect(instance["_onDidChangeTreeData"].fire).toHaveBeenCalled(); + }); + }); + + describe("getTreeItem", () => { + it("should return a tree item with label, collapsible state and icon path", async () => { + const wp = { + id: 1, + subject: "Test Work Package", + status: { + self: { + title: "New", + }, + }, + children: [], + parent: null, + ancestor: null, + }; + const treeItem = await instance.getTreeItem(wp as any); + expect(treeItem.label).toEqual("#1 Test Work Package"); + expect(treeItem.collapsibleState).toEqual(TreeItemCollapsibleState.None); + expect(treeItem.iconPath).toBeUndefined(); + }); + it("should return a tree item with label, collapsible state and icon path", async () => { + const wp = { + id: 1, + subject: "Test Work Package", + status: { + self: { + title: "New", + }, + }, + children: null, + parent: null, + ancestor: null, + }; + const treeItem = await instance.getTreeItem(wp as any); + expect(treeItem.label).toEqual("#1 Test Work Package"); + expect(treeItem.collapsibleState).toEqual(TreeItemCollapsibleState.None); + expect(treeItem.iconPath).toBeUndefined(); + }); + it("should return a tree item with label, collapsible state = collapsed and icon path", async () => { + const wp = { + id: 1, + subject: "Test Work Package", + status: { + self: { + title: "New", + }, + }, + children: [{}], + parent: null, + ancestor: null, + }; + const treeItem = await instance.getTreeItem(wp as any); + expect(treeItem.label).toEqual("#1 Test Work Package"); + expect(treeItem.collapsibleState).toEqual( + TreeItemCollapsibleState.Collapsed, + ); + expect(treeItem.iconPath).toBeUndefined(); + }); + it("should return a tree item with label, collapsible state and some icon path", async () => { + const wp = { + id: 1, + subject: "Test Work Package", + status: { + self: { + title: "In progress", + }, + }, + children: [{}], + parent: null, + ancestor: null, + }; + const treeItem = await instance.getTreeItem(wp as any); + expect(treeItem.label).toEqual("#1 Test Work Package"); + expect(treeItem.collapsibleState).toEqual( + TreeItemCollapsibleState.Collapsed, + ); + expect(treeItem.iconPath).not.toBeUndefined(); + }); + }); + + describe("getChildren", () => { + it("should return the top-level work packages", () => { + instance["workPackages"] = [ + { + id: 1, + subject: "Test Work Package 1", + status: { + self: { + title: "New", + }, + }, + children: [], + parent: null, + ancestor: null, + }, + { + id: 2, + subject: "Test Work Package 2", + status: { + self: { + title: "New", + }, + }, + children: [], + parent: null, + ancestor: null, + }, + ] as any; + const topLevelWPs = instance.getChildren(); + expect(topLevelWPs).toEqual([ + { + id: 1, + subject: "Test Work Package 1", + status: { + self: { + title: "New", + }, + }, + children: [], + parent: null, + ancestor: null, + }, + { + id: 2, + subject: "Test Work Package 2", + status: { + self: { + title: "New", + }, + }, + children: [], + parent: null, + ancestor: null, + }, + ]); + }); + + it("should return the children of a work package", () => { + const parentWP = { + id: 1, + subject: "Parent Work Package", + status: { + self: { + title: "New", + }, + }, + children: [ + { + id: 2, + subject: "Child Work Package", + status: { + self: { + title: "New", + }, + }, + children: [], + parent: { + id: 1, + subject: "Parent Work Package", + }, + ancestor: null, + }, + ], + parent: null, + ancestor: null, + }; + instance["workPackages"] = [parentWP, ...parentWP.children] as any; + + expect(instance.getChildren(parentWP as any)).toEqual([ + { + id: 2, + subject: "Child Work Package", + status: { + self: { + title: "New", + }, + }, + children: [], + parent: { + id: 1, + subject: "Parent Work Package", + }, + ancestor: null, + }, + ]); + }); + }); + + describe("resolveTreeItem", () => { + it("should return item", () => { + const item = {}; + expect(instance.resolveTreeItem(item)).toEqual(item); + }); + }); + + describe("getParent", () => { + it("should return the ancestor of a work package", () => { + const ancestorWP = { + id: 1, + subject: "Ancestor Work Package", + status: { + self: { + title: "New", + }, + }, + children: [ + { + id: 2, + subject: "Parent Work Package", + status: { + self: { + title: "New", + }, + }, + children: [ + { + id: 3, + subject: "Child Work Package", + status: { + self: { + title: "New", + }, + }, + children: [], + parent: { + id: 2, + subject: "Parent Work Package", + }, + ancestor: { + id: 1, + subject: "Ancestor Work Package", + }, + }, + ], + parent: null, + ancestor: { + id: 1, + subject: "Ancestor Work Package", + }, + }, + ], + parent: null, + ancestor: null, + }; + instance["workPackages"] = [ancestorWP, ...ancestorWP.children] as any; + const parentWP = instance.getParent({ + id: 3, + subject: "Child Work Package", + status: { + self: { + title: "New", + }, + }, + children: [], + parent: { + id: 2, + subject: "Parent Work Package", + }, + ancestor: { + id: 1, + subject: "Ancestor Work Package", + }, + } as any); + expect(parentWP).toEqual({ + id: 2, + subject: "Parent Work Package", + status: { + self: { + title: "New", + }, + }, + children: [ + { + id: 3, + subject: "Child Work Package", + status: { + self: { + title: "New", + }, + }, + children: [], + parent: { + id: 2, + subject: "Parent Work Package", + }, + ancestor: { + id: 1, + subject: "Ancestor Work Package", + }, + }, + ], + parent: null, + ancestor: { + id: 1, + subject: "Ancestor Work Package", + }, + }); + }); + }); +}); diff --git a/src/views/openProject.treeDataProvider.ts b/src/views/openProject.treeDataProvider.ts new file mode 100644 index 0000000..e5fbe43 --- /dev/null +++ b/src/views/openProject.treeDataProvider.ts @@ -0,0 +1,73 @@ +import { WP } from "op-client"; +import * as vscode from "vscode"; +import { Event, ProviderResult, TreeDataProvider, TreeItem } from "vscode"; +import OpenProjectClient from "../openProject.client"; +import getIconPathByStatus from "../utils/getIconPathByStatus.util"; +import path from "path"; + +export default class OpenProjectTreeDataProvider + implements TreeDataProvider +{ + private static _instance: OpenProjectTreeDataProvider; + + public static getInstance(): OpenProjectTreeDataProvider { + if (!this._instance) { + this._instance = new OpenProjectTreeDataProvider(); + } + return this._instance; + } + + private _client: OpenProjectClient; + + private workPackages: WP[] = []; + + private _onDidChangeTreeData: vscode.EventEmitter< + void | WP | WP[] | null | undefined + > = new vscode.EventEmitter(); + + private constructor() { + this._client = OpenProjectClient.getInstance(); + this.refreshWPs(); + } + + onDidChangeTreeData?: Event = + this._onDidChangeTreeData.event; + + getTreeItem(element: WP): TreeItem | Promise { + const iconPath = getIconPathByStatus(element.status.self.title); + return { + label: `#${element.id} ${element.subject}`, + collapsibleState: + element.children?.length > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None, + iconPath: iconPath + ? vscode.Uri.file(path.join(__dirname, iconPath)) + : undefined, + }; + } + + getChildren(element?: WP | undefined): ProviderResult { + if (!element) { + return this.workPackages.filter((wp) => !wp.parent); + } + return this.workPackages.filter((wp) => wp.parent?.id === element.id); + } + + getParent(element: WP): ProviderResult { + return this.workPackages.find((wp) => wp.id === element.parent.id); + } + + resolveTreeItem(item: TreeItem): ProviderResult { + return item; + } + + refreshWPs(): Promise | undefined { + return this._client.getWPs()?.then((wps) => { + if (wps.length) { + this.workPackages = wps; + this._onDidChangeTreeData.fire(); + } + }); + } +} diff --git a/tsconfig.json b/tsconfig.json index 965a7b4..0205cec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,14 @@ { - "compilerOptions": { - "module": "commonjs", - "target": "ES2020", - "lib": [ - "ES2020" - ], - "sourceMap": true, - "rootDir": "src", - "strict": true /* enable all strict type-checking options */ - /* Additional Checks */ - // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ - // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ - // "noUnusedParameters": true, /* Report errors on unused parameters. */ - } -} + "compilerOptions": { + "module": "commonjs", + "target": "ES2017", + "sourceMap": true, + "rootDir": "src", + "esModuleInterop": true, + "strict": true, + "types": [ + "jest", + "node" + ] + } +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 736707a..89b1e73 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,44 +1,66 @@ -'use strict'; +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable jsdoc/valid-types */ +/* eslint-disable spaced-comment */ +/* eslint-disable jsdoc/check-tag-names */ -const path = require('path'); +const path = require("path"); +const CopyPlugin = require("copy-webpack-plugin"); -/** @typedef {import('webpack').Configuration} WebpackConfig **/ +/** @typedef {import('webpack').Configuration} WebpackConfig */ /** @type WebpackConfig */ const extensionConfig = { - target: 'node', - mode: 'none', + target: "node", + mode: "none", - entry: './src/extension.ts', + entry: "./src/extension.ts", output: { - - path: path.resolve(__dirname, 'dist'), - filename: 'extension.js', - libraryTarget: 'commonjs2' + path: path.resolve(__dirname, "dist"), + filename: "extension.js", + libraryTarget: "commonjs2", }, externals: { - vscode: 'commonjs vscode' + vscode: "commonjs vscode", }, resolve: { - - extensions: ['.ts', '.js'] + extensions: [".ts", ".js"], }, module: { rules: [ { test: /\.ts$/, - exclude: /node_modules/, + exclude: /(node_modules|*\.spec\.ts)/, use: [ { - loader: 'ts-loader' - } - ] - } - ] + loader: "ts-loader", + }, + ], + }, + { + test: /resources\/*/, + use: [ + { + loader: "file-loader", + }, + ], + }, + ], }, - devtool: 'nosources-source-map', + devtool: "nosources-source-map", infrastructureLogging: { level: "log", }, + plugins: [ + new CopyPlugin({ + patterns: [ + { + from: ".", + to: "resources", + context: "resources", + }, + ], + }), + ], }; -module.exports = [ extensionConfig ]; \ No newline at end of file +module.exports = [extensionConfig];