From 499fa4aec5f689c57a9f3d8af91b7aa4edee4040 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Sat, 10 Jun 2023 19:04:52 +0300 Subject: [PATCH 01/38] refresh button added to title --- package.json | 21 +++++++++++++++++---- resources/refresh.png | Bin 0 -> 390 bytes resources/refresh_light.png | Bin 0 -> 638 bytes 3 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 resources/refresh.png create mode 100644 resources/refresh_light.png diff --git a/package.json b/package.json index 5e8b739..f0e7f46 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,22 @@ { "command": "openproject.refresh", "title": "Refresh work packages", - "shortTitle": "Refresh" + "shortTitle": "Refresh", + "icon": { + "dark": "resources/refresh_light.png", + "light": "resources/refresh.png" + } } ], + "menus": { + "view/title": [ + { + "command": "openproject.refresh", + "group": "navigation", + "when": "view == openproject-workspaces" + } + ] + }, "viewsContainers": { "activitybar": [ { @@ -89,10 +102,10 @@ "@types/mocha": "^10.0.1", "@types/node": "16.x", "@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", + "@typescript-eslint/eslint-plugin": "^5.59.7", + "@typescript-eslint/parser": "^5.59.7", "eslint": "^8.41.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^17.0.0", @@ -131,4 +144,4 @@ ], "coverageDirectory": "../coverage" } -} +} \ No newline at end of file diff --git a/resources/refresh.png b/resources/refresh.png new file mode 100644 index 0000000000000000000000000000000000000000..f5918c6c7833e4dd7a30ae876d1e6cff002a816b GIT binary patch literal 390 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#X!0Ygc<#+)i{6*$r9Iy zlHmNblJdl&REF~Ma=pyF?Be9af>gcyqV(DCY@`?%7==Au978O6=UzUm=Nu@)`hcIe zX<@s9py@|rEoa%85|s?K()F$tq92V7r*>R9;gJ;J$tmn5+FZOY;QKwb*pnxBme1cC z_U&eR?!UzyvR5t~3I4M#$w>7-yYmfq>3GT4!qE{O) z1*(D;9gWdGPeUR-g=h4o?k`c~f3VPbLCgWGUxgYb+T6Kol5GC%f6TIO@vRs>OJUYK z4}S=oe4_L&>0X}D8<7moZMum(%1zH#S9DvtH=XTlFV*2Po?z}He`tx?U$3Jq+X`Ec hSKRl`@!0sw{NAsq-qZ3)y}&SJ@O1TaS?83{1OOBdmtz0` literal 0 HcmV?d00001 diff --git a/resources/refresh_light.png b/resources/refresh_light.png new file mode 100644 index 0000000000000000000000000000000000000000..858ad9f5ed2f8d1bc41e97d367f3fa32ac3cdfd0 GIT binary patch literal 638 zcmV-^0)hRBP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0063uBQgL0010qN zS#tmY0AK(B0AK*{YeLTe000McNliru=LQN810h@rbh7{e0pm$TK~y-)os%(08(|oR zzjz@zjC3f{v9pF~vgkQQyO}8+gn*NiSxUj7i;zL2Q-jfq32_nE%-tl$4hp4U3HDNH zgHS;smYA4QOz!=1_jR}>tx2GH;UAxemzVD+`X@*RgDB`a`wau-b{qNod-fX!PSa`g z!{j9Oi`ZT-eq^&~SQa4C^ZmAMO5ImvR1~&iHPVOpllo+5r@iUK!jh+ z&e9NZ(^{oM-5a!j zM5H{c)!e_1j|Vn*xx7q7Bzmz(okS$^*R|=jnuwzGgOLw~P-?Z%uq;dk0%UGRqum1b z#N&P8WH87Z*VljuKJV@l5j_&%h8Y)`3}1%BVvmi9jE-_l({P$jqhgwX2oi^fBC6^P z64NxSSF2o~pT|KeHSjb2>~v7jb+MhjddAF6OyK>_4nIFX$9}zzQme&JOC@BtwtAiV YA8}!mvl*szl>h($07*qoM6N<$f?~}Nr2qf` literal 0 HcmV?d00001 From de976fd7c460c7853a5bf4aadf4cc7890c0d476c Mon Sep 17 00:00:00 2001 From: goodhumored Date: Sun, 11 Jun 2023 03:32:04 +0300 Subject: [PATCH 02/38] eslint rules updated --- .eslintrc.json | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 0596d3e..c12d394 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -16,7 +16,8 @@ { "files": [ "**/*.spec.ts", - "**/*.mock.ts" + "**/*.mock.ts", + "**/__mocks__/**/*.ts" ], "env": { "jest": true @@ -24,7 +25,6 @@ "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", @@ -48,14 +48,12 @@ "jsdoc" ], "rules": { + "import/no-extraneous-dependencies": "off", "quotes": [ "error", "double" ], - "semi": [ - "warn", - "always" - ], + "semi": "off", "import/extensions": [ "error", "never" @@ -260,6 +258,7 @@ "@typescript-eslint/brace-style": "off", "jsdoc/require-jsdoc": "off", "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": "warn" + "@typescript-eslint/no-unused-vars": "warn", + "import/no-cycle": "off" } } \ No newline at end of file From 087e1ecc2ed784e837ce608a4db82b23a8cf43ed Mon Sep 17 00:00:00 2001 From: goodhumored Date: Sun, 11 Jun 2023 03:35:26 +0300 Subject: [PATCH 03/38] singleton to DI; folder structure --- package.json | 8 +- src/DI/container.ts | 39 +++++ src/DI/tokens.ts | 9 ++ src/__mocks__/{vscode.js => vscode.ts} | 41 +++--- .../__mocks__/authorizeClient.command.ts | 9 ++ .../authorizeClient.command.spec.ts | 59 ++++---- .../authorize/authorizeClient.command.ts | 35 +++++ .../authorizeClientCommand.interface.ts | 3 + .../refresh/__mocks__/refreshWPs.command.ts | 7 + .../refresh/refreshWPs.command.spec.ts | 23 +++ .../commands/refresh/refreshWPs.command.ts | 16 +++ .../refresh/refreshWPsCommand.interface.ts | 3 + .../__mocks__/openProject.treeDataProvider.ts | 17 +++ .../openProject.treeDataProvider.spec.ts | 73 +++++----- .../views/openProject.treeDataProvider.ts | 45 +++--- .../openProjectTreeDataProvider.interface.ts | 7 + src/commands/authorizeClient.command.ts | 21 --- src/commands/refreshWPs.command.spec.ts | 20 --- src/commands/refreshWPs.command.ts | 5 - src/extension.spec.ts | 95 ++++++++++--- src/extension.ts | 44 ++++-- src/infrastructure/logger/__mocks__/logger.ts | 9 ++ src/infrastructure/logger/logger.interface.ts | 4 + src/infrastructure/logger/logger.spec.ts | 31 ++++ src/infrastructure/logger/logger.ts | 13 ++ .../__mocks__/openProject.client.ts | 11 ++ .../clientNotInitialized.exception.ts | 5 + .../openProject/openProject.client.spec.ts | 133 ++++++++++++++++++ .../openProject/openProject.client.ts | 70 +++++++++ .../openProjectClient.interface.ts | 7 + .../unexpectedClientError.exception.ts | 5 + .../openProject/userNotFound.exception.ts | 5 + src/openProject.client.spec.ts | 108 -------------- src/openProject.client.ts | 65 --------- src/utils/addCredsToUrl.util.spec.ts | 14 ++ src/utils/addCredsToUrl.util.ts | 17 +++ src/utils/getIconPathByStatus.util.spec.ts | 4 +- tsconfig.json | 7 +- 38 files changed, 713 insertions(+), 374 deletions(-) create mode 100644 src/DI/container.ts create mode 100644 src/DI/tokens.ts rename src/__mocks__/{vscode.js => vscode.ts} (78%) create mode 100644 src/application/commands/authorize/__mocks__/authorizeClient.command.ts rename src/{commands => application/commands/authorize}/authorizeClient.command.spec.ts (63%) create mode 100644 src/application/commands/authorize/authorizeClient.command.ts create mode 100644 src/application/commands/authorize/authorizeClientCommand.interface.ts create mode 100644 src/application/commands/refresh/__mocks__/refreshWPs.command.ts create mode 100644 src/application/commands/refresh/refreshWPs.command.spec.ts create mode 100644 src/application/commands/refresh/refreshWPs.command.ts create mode 100644 src/application/commands/refresh/refreshWPsCommand.interface.ts create mode 100644 src/application/views/__mocks__/openProject.treeDataProvider.ts rename src/{ => application}/views/openProject.treeDataProvider.spec.ts (82%) rename src/{ => application}/views/openProject.treeDataProvider.ts (59%) create mode 100644 src/application/views/openProjectTreeDataProvider.interface.ts delete mode 100644 src/commands/authorizeClient.command.ts delete mode 100644 src/commands/refreshWPs.command.spec.ts delete mode 100644 src/commands/refreshWPs.command.ts create mode 100644 src/infrastructure/logger/__mocks__/logger.ts create mode 100644 src/infrastructure/logger/logger.interface.ts create mode 100644 src/infrastructure/logger/logger.spec.ts create mode 100644 src/infrastructure/logger/logger.ts create mode 100644 src/infrastructure/openProject/__mocks__/openProject.client.ts create mode 100644 src/infrastructure/openProject/clientNotInitialized.exception.ts create mode 100644 src/infrastructure/openProject/openProject.client.spec.ts create mode 100644 src/infrastructure/openProject/openProject.client.ts create mode 100644 src/infrastructure/openProject/openProjectClient.interface.ts create mode 100644 src/infrastructure/openProject/unexpectedClientError.exception.ts create mode 100644 src/infrastructure/openProject/userNotFound.exception.ts delete mode 100644 src/openProject.client.spec.ts delete mode 100644 src/openProject.client.ts create mode 100644 src/utils/addCredsToUrl.util.spec.ts create mode 100644 src/utils/addCredsToUrl.util.ts diff --git a/package.json b/package.json index f0e7f46..f81c148 100644 --- a/package.json +++ b/package.json @@ -102,10 +102,10 @@ "@types/mocha": "^10.0.1", "@types/node": "16.x", "@types/vscode": "^1.78.0", - "@vscode/test-electron": "^2.3.0", - "copy-webpack-plugin": "^11.0.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", @@ -126,7 +126,9 @@ "dependencies": { "axios": "^1.4.0", "client-oauth2": "^4.3.3", - "op-client": "^1.4.2" + "inversify": "^6.0.1", + "op-client": "^1.4.2", + "reflect-metadata": "^0.1.13" }, "jest": { "moduleFileExtensions": [ diff --git a/src/DI/container.ts b/src/DI/container.ts new file mode 100644 index 0000000..e10f016 --- /dev/null +++ b/src/DI/container.ts @@ -0,0 +1,39 @@ +import { Container } from "inversify"; +import "reflect-metadata"; +import AuthorizeClientCommandImpl from "../application/commands/authorize/authorizeClient.command"; +import AuthorizeClientCommand from "../application/commands/authorize/authorizeClientCommand.interface"; +import RefreshWPsCommandImpl from "../application/commands/refresh/refreshWPs.command"; +import RefreshWPsCommand from "../application/commands/refresh/refreshWPsCommand.interface"; +import OpenProjectTreeDataProviderImpl from "../application/views/openProject.treeDataProvider"; +import OpenProjectTreeDataProvider from "../application/views/openProjectTreeDataProvider.interface"; +import ConsoleLogger from "../infrastructure/logger/logger"; +import Logger from "../infrastructure/logger/logger.interface"; +import OpenProjectClientImpl from "../infrastructure/openProject/openProject.client"; +import OpenProjectClient from "../infrastructure/openProject/openProjectClient.interface"; +import TOKENS from "./tokens"; + +const container = new Container(); + +container + .bind(TOKENS.authorizeCommand) + .to(AuthorizeClientCommandImpl) + .inSingletonScope(); + +container + .bind(TOKENS.opClient) + .to(OpenProjectClientImpl) + .inSingletonScope(); + +container + .bind(TOKENS.opTreeView) + .to(OpenProjectTreeDataProviderImpl) + .inSingletonScope(); + +container + .bind(TOKENS.refreshWPsCommand) + .to(RefreshWPsCommandImpl) + .inSingletonScope(); + +container.bind(TOKENS.logger).to(ConsoleLogger).inSingletonScope(); + +export default container; diff --git a/src/DI/tokens.ts b/src/DI/tokens.ts new file mode 100644 index 0000000..66b6b7f --- /dev/null +++ b/src/DI/tokens.ts @@ -0,0 +1,9 @@ +const TOKENS = { + opTreeView: Symbol.for("OpenProjectTreeDataProvider"), + opClient: Symbol.for("OpenProjectClient"), + refreshWPsCommand: Symbol.for("RefreshWPsCommand"), + authorizeCommand: Symbol.for("AuthorizeClientCommand"), + logger: Symbol.for("Logger"), +}; + +export default TOKENS; diff --git a/src/__mocks__/vscode.js b/src/__mocks__/vscode.ts similarity index 78% rename from src/__mocks__/vscode.js rename to src/__mocks__/vscode.ts index e406dc3..90c3479 100644 --- a/src/__mocks__/vscode.js +++ b/src/__mocks__/vscode.ts @@ -2,9 +2,13 @@ /* eslint-disable no-undef */ /* eslint-disable @typescript-eslint/naming-convention */ +import type { window as vscodeWindow } from "vscode"; + class Disposable {} -class EventEmitter {} +class EventEmitter { + fire = jest.fn(); +} const languages = { createDiagnosticCollection: jest.fn(), @@ -12,7 +16,7 @@ const languages = { const StatusBarAlignment = {}; -const window = { +const windowMocked: typeof vscodeWindow = { createStatusBarItem: jest.fn(() => ({ show: jest.fn(), })), @@ -21,7 +25,10 @@ const window = { showInformationMessage: jest.fn(), createTextEditorDecorationType: jest.fn(), createTreeView: jest.fn(), -}; + createInputBox: jest.fn(), + showInputBox: jest.fn(), + showQuickPick: jest.fn(), +} as any; const workspace = { getConfiguration: jest.fn(), @@ -34,10 +41,9 @@ const OverviewRulerLane = { }; const Uri = { - file: (f) => f, + file: (f: any) => f, parse: jest.fn(), }; -const Range = jest.fn(); const Diagnostic = jest.fn(); const DiagnosticSeverity = { Error: 0, Warning: 1, Information: 2, Hint: 3 }; @@ -45,6 +51,7 @@ const debug = { onDidTerminateDebugSession: jest.fn(), startDebugging: jest.fn(), }; +const RangeMocked = jest.fn(); const commands = { executeCommand: jest.fn(), @@ -57,21 +64,19 @@ const TreeItemCollapsibleState = { Expanded: 2, }; -const vscode = { - TreeItemCollapsibleState, - EventEmitter, +export { + Diagnostic, + DiagnosticSeverity, Disposable, - languages, - StatusBarAlignment, - window, - workspace, + EventEmitter, OverviewRulerLane, + RangeMocked as Range, + StatusBarAlignment, + TreeItemCollapsibleState, Uri, - Range, - Diagnostic, - DiagnosticSeverity, - debug, commands, + debug, + languages, + windowMocked as window, + workspace, }; - -module.exports = vscode; diff --git a/src/application/commands/authorize/__mocks__/authorizeClient.command.ts b/src/application/commands/authorize/__mocks__/authorizeClient.command.ts new file mode 100644 index 0000000..5d25e14 --- /dev/null +++ b/src/application/commands/authorize/__mocks__/authorizeClient.command.ts @@ -0,0 +1,9 @@ +import { injectable } from "inversify"; +import AuthorizeClientCommand from "../authorizeClientCommand.interface"; + +@injectable() +export default class AuthorizeClientCommandImpl + implements AuthorizeClientCommand +{ + authorizeClient = jest.fn(); +} diff --git a/src/commands/authorizeClient.command.spec.ts b/src/application/commands/authorize/authorizeClient.command.spec.ts similarity index 63% rename from src/commands/authorizeClient.command.spec.ts rename to src/application/commands/authorize/authorizeClient.command.spec.ts index 613d609..8dc0216 100644 --- a/src/commands/authorizeClient.command.spec.ts +++ b/src/application/commands/authorize/authorizeClient.command.spec.ts @@ -1,41 +1,42 @@ -// jest.mock("../__mocks__/vscode", () => vscode); -jest.mock("../openProject.client"); -jest.mock("../views/openProject.treeDataProvider"); -const vscode = require("../__mocks__/vscode"); +jest.mock("../../../infrastructure/openProject/openProject.client"); +jest.mock("../../views/openProject.treeDataProvider"); 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(), - }); +import container from "../../../DI/container"; +import TOKENS from "../../../DI/tokens"; +import * as vscode from "../../../__mocks__/vscode"; +import OpenProjectClient from "../../../infrastructure/openProject/openProject.client"; +import VSCodeConfigMock from "../../../test/config.mock"; +import OpenProjectTreeDataProvider from "../../views/openProject.treeDataProvider"; +import AuthorizeClientCommandImpl from "./authorizeClient.command"; + +describe("Authorize client command test suite", () => { + let command: AuthorizeClientCommandImpl; + let client: OpenProjectClient; + let treeDataProvider: OpenProjectTreeDataProvider; + let config: VSCodeConfigMock; const user = new User(1); - const treeDataProvider = { refreshWPs: jest.fn() }; beforeEach(() => { jest.clearAllMocks(); - jest.spyOn(OpenProjectClient, "getInstance").mockReturnValue(client); + command = container.get(TOKENS.authorizeCommand); + client = container.get(TOKENS.opClient); + treeDataProvider = container.get(TOKENS.opTreeView); + config = new VSCodeConfigMock({ + base_url: faker.internet.url(), + token: faker.string.sample(), + }); 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(); + await command.authorizeClient(); expect(client.init).toHaveBeenLastCalledWith( config.get("base_url"), config.get("token"), @@ -47,7 +48,7 @@ describe("Authorize client command test suit", () => { .spyOn(vscode.workspace, "getConfiguration") .mockReturnValue(emptyConfig); - await authorizeClient(); + await command.authorizeClient(); expect(client.init).toHaveBeenLastCalledWith("", ""); }); @@ -57,7 +58,7 @@ describe("Authorize client command test suit", () => { it("should set 'openproject.authed' to true on success", async () => { jest.spyOn(vscode.commands, "executeCommand"); - await authorizeClient(); + await command.authorizeClient(); expect(vscode.commands.executeCommand).toHaveBeenLastCalledWith( "setContext", @@ -69,7 +70,7 @@ describe("Authorize client command test suit", () => { it("should show message 'Hello' on success", async () => { jest.spyOn(vscode.window, "showInformationMessage"); - await authorizeClient(); + await command.authorizeClient(); expect(vscode.window.showInformationMessage).toHaveBeenLastCalledWith( `Hello, ${user.firstName} ${user.lastName}!`, @@ -77,7 +78,7 @@ describe("Authorize client command test suit", () => { }); it("should call 'refresh WPs' on treeDataProvider", async () => { - await authorizeClient(); + await command.authorizeClient(); expect(treeDataProvider.refreshWPs).toHaveBeenCalled(); }); @@ -87,7 +88,7 @@ describe("Authorize client command test suit", () => { jest.spyOn(vscode.window, "showErrorMessage"); jest.spyOn(client, "init").mockResolvedValue(undefined); - await authorizeClient(); + await command.authorizeClient(); expect(vscode.window.showErrorMessage).toHaveBeenLastCalledWith( "Failed connecting to OpenProject", @@ -96,13 +97,11 @@ describe("Authorize client command test suit", () => { 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(); + await command.authorizeClient(); - expect(OpenProjectTreeDataProvider.getInstance).not.toHaveBeenCalled(); expect(vscode.commands.executeCommand).not.toHaveBeenCalled(); expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); }); diff --git a/src/application/commands/authorize/authorizeClient.command.ts b/src/application/commands/authorize/authorizeClient.command.ts new file mode 100644 index 0000000..a2b7fd3 --- /dev/null +++ b/src/application/commands/authorize/authorizeClient.command.ts @@ -0,0 +1,35 @@ +import { inject, injectable } from "inversify"; +import * as vscode from "vscode"; +import TOKENS from "../../../DI/tokens"; +import OpenProjectClient from "../../../infrastructure/openProject/openProjectClient.interface"; +import OpenProjectTreeDataProvider from "../../views/openProjectTreeDataProvider.interface"; +import AuthorizeClientCommand from "./authorizeClientCommand.interface"; + +@injectable() +export default class AuthorizeClientCommandImpl + implements AuthorizeClientCommand +{ + constructor( + @inject(TOKENS.opTreeView) + private readonly _treeDataProvider: OpenProjectTreeDataProvider, + @inject(TOKENS.opClient) + private readonly _client: OpenProjectClient, + ) {} + + async authorizeClient() { + const config = vscode.workspace.getConfiguration("openproject"); + const user = await this._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}!`, + ); + this._treeDataProvider.refreshWPs(); + } +} diff --git a/src/application/commands/authorize/authorizeClientCommand.interface.ts b/src/application/commands/authorize/authorizeClientCommand.interface.ts new file mode 100644 index 0000000..cb70392 --- /dev/null +++ b/src/application/commands/authorize/authorizeClientCommand.interface.ts @@ -0,0 +1,3 @@ +export default interface AuthorizeClientCommand { + authorizeClient(): Promise; +} diff --git a/src/application/commands/refresh/__mocks__/refreshWPs.command.ts b/src/application/commands/refresh/__mocks__/refreshWPs.command.ts new file mode 100644 index 0000000..a980b24 --- /dev/null +++ b/src/application/commands/refresh/__mocks__/refreshWPs.command.ts @@ -0,0 +1,7 @@ +import { injectable } from "inversify"; +import RefreshWPsCommand from "../refreshWPsCommand.interface"; + +@injectable() +export default class RefreshWPsCommandImpl implements RefreshWPsCommand { + refreshWPs = jest.fn(); +} diff --git a/src/application/commands/refresh/refreshWPs.command.spec.ts b/src/application/commands/refresh/refreshWPs.command.spec.ts new file mode 100644 index 0000000..73df85e --- /dev/null +++ b/src/application/commands/refresh/refreshWPs.command.spec.ts @@ -0,0 +1,23 @@ +jest.mock("../../views/openProject.treeDataProvider"); + +import container from "../../../DI/container"; +import TOKENS from "../../../DI/tokens"; +import OpenProjectTreeDataProviderImpl from "../../views/openProject.treeDataProvider"; +import RefreshWPsCommand from "./refreshWPsCommand.interface"; + +describe("refresh WPs command test suite", () => { + let treeDataProvider: OpenProjectTreeDataProviderImpl; + let command: RefreshWPsCommand; + + beforeEach(() => { + treeDataProvider = container.get(TOKENS.opTreeView); + command = container.get(TOKENS.refreshWPsCommand); + }); + it("should call refreshWPs func", () => { + jest.spyOn(treeDataProvider, "refreshWPs"); + + command.refreshWPs(); + + expect(treeDataProvider.refreshWPs).toHaveBeenCalled(); + }); +}); diff --git a/src/application/commands/refresh/refreshWPs.command.ts b/src/application/commands/refresh/refreshWPs.command.ts new file mode 100644 index 0000000..264456e --- /dev/null +++ b/src/application/commands/refresh/refreshWPs.command.ts @@ -0,0 +1,16 @@ +import { inject, injectable } from "inversify"; +import TOKENS from "../../../DI/tokens"; +import OpenProjectTreeDataProvider from "../../views/openProjectTreeDataProvider.interface"; +import RefreshWPsCommand from "./refreshWPsCommand.interface"; + +@injectable() +export default class RefreshWPsCommandImpl implements RefreshWPsCommand { + constructor( + @inject(TOKENS.opTreeView) + private readonly _treeDataProvider: OpenProjectTreeDataProvider, + ) {} + + refreshWPs() { + this._treeDataProvider.refreshWPs(); + } +} diff --git a/src/application/commands/refresh/refreshWPsCommand.interface.ts b/src/application/commands/refresh/refreshWPsCommand.interface.ts new file mode 100644 index 0000000..5c12cbc --- /dev/null +++ b/src/application/commands/refresh/refreshWPsCommand.interface.ts @@ -0,0 +1,3 @@ +export default interface RefreshWPsCommand { + refreshWPs(): void; +} diff --git a/src/application/views/__mocks__/openProject.treeDataProvider.ts b/src/application/views/__mocks__/openProject.treeDataProvider.ts new file mode 100644 index 0000000..4569d07 --- /dev/null +++ b/src/application/views/__mocks__/openProject.treeDataProvider.ts @@ -0,0 +1,17 @@ +import { injectable } from "inversify"; +import OpenProjectTreeDataProvider from "../openProjectTreeDataProvider.interface"; + +@injectable() +export default class OpenProjectTreeDataProviderImpl + implements OpenProjectTreeDataProvider +{ + refreshWPs = jest.fn(); + + getTreeItem = jest.fn(); + + getChildren = jest.fn(); + + getParent = jest.fn(); + + resolveTreeItem = jest.fn(); +} diff --git a/src/views/openProject.treeDataProvider.spec.ts b/src/application/views/openProject.treeDataProvider.spec.ts similarity index 82% rename from src/views/openProject.treeDataProvider.spec.ts rename to src/application/views/openProject.treeDataProvider.spec.ts index 37c0d5d..60a294e 100644 --- a/src/views/openProject.treeDataProvider.spec.ts +++ b/src/application/views/openProject.treeDataProvider.spec.ts @@ -1,12 +1,23 @@ +jest.mock("../../infrastructure/openProject/openProject.client"); +import { WP } from "op-client"; import { TreeItemCollapsibleState } from "vscode"; -import OpenProjectTreeDataProvider from "./openProject.treeDataProvider"; +import container from "../../DI/container"; +import TOKENS from "../../DI/tokens"; +import OpenProjectClient from "../../infrastructure/openProject/openProjectClient.interface"; +import OpenProjectTreeDataProviderImpl from "./openProject.treeDataProvider"; describe("OpenProjectTreeDataProvider", () => { - let instance: OpenProjectTreeDataProvider; + let treeView: OpenProjectTreeDataProviderImpl; + let client: OpenProjectClient; + + beforeAll(() => { + client = container.get(TOKENS.opClient); + jest.spyOn(client, "getWPs").mockResolvedValue([]); + treeView = container.get(TOKENS.opTreeView); + }); beforeEach(() => { - instance = OpenProjectTreeDataProvider.getInstance(); - jest.spyOn(instance["_client"], "getWPs").mockResolvedValue([]); + jest.spyOn(client, "getWPs").mockResolvedValue([]); }); afterEach(() => { @@ -15,8 +26,7 @@ describe("OpenProjectTreeDataProvider", () => { 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([ + const wps: WP[] = [ { id: 1, subject: "Test Work Package", @@ -28,29 +38,16 @@ describe("OpenProjectTreeDataProvider", () => { children: [], parent: null, ancestor: null, - } as any, - ]); + }, + ] as any; + jest.spyOn(client, "getWPs").mockResolvedValue(wps); - await instance.refreshWPs(); + await treeView.refreshWPs(); - expect(instance["workPackages"]).toEqual([ - { - id: 1, - subject: "Test Work Package", - status: { - self: { - title: "New", - }, - }, - children: [], - parent: null, - ancestor: null, - }, - ]); + expect(treeView["workPackages"]).toEqual(wps); }); it("should fire _onDidChangeTreeData", async () => { - instance["_onDidChangeTreeData"] = { fire: jest.fn() } as any; - jest.spyOn(instance["_client"], "getWPs").mockResolvedValue([ + jest.spyOn(client, "getWPs").mockResolvedValue([ { id: 1, subject: "Test Work Package", @@ -65,9 +62,9 @@ describe("OpenProjectTreeDataProvider", () => { } as any, ]); - await instance.refreshWPs(); + await treeView.refreshWPs(); - expect(instance["_onDidChangeTreeData"].fire).toHaveBeenCalled(); + expect(treeView["_onDidChangeTreeData"].fire).toHaveBeenCalled(); }); }); @@ -85,7 +82,7 @@ describe("OpenProjectTreeDataProvider", () => { parent: null, ancestor: null, }; - const treeItem = await instance.getTreeItem(wp as any); + const treeItem = await treeView.getTreeItem(wp as any); expect(treeItem.label).toEqual("#1 Test Work Package"); expect(treeItem.collapsibleState).toEqual(TreeItemCollapsibleState.None); expect(treeItem.iconPath).toBeUndefined(); @@ -103,7 +100,7 @@ describe("OpenProjectTreeDataProvider", () => { parent: null, ancestor: null, }; - const treeItem = await instance.getTreeItem(wp as any); + const treeItem = await treeView.getTreeItem(wp as any); expect(treeItem.label).toEqual("#1 Test Work Package"); expect(treeItem.collapsibleState).toEqual(TreeItemCollapsibleState.None); expect(treeItem.iconPath).toBeUndefined(); @@ -121,7 +118,7 @@ describe("OpenProjectTreeDataProvider", () => { parent: null, ancestor: null, }; - const treeItem = await instance.getTreeItem(wp as any); + const treeItem = await treeView.getTreeItem(wp as any); expect(treeItem.label).toEqual("#1 Test Work Package"); expect(treeItem.collapsibleState).toEqual( TreeItemCollapsibleState.Collapsed, @@ -141,7 +138,7 @@ describe("OpenProjectTreeDataProvider", () => { parent: null, ancestor: null, }; - const treeItem = await instance.getTreeItem(wp as any); + const treeItem = await treeView.getTreeItem(wp as any); expect(treeItem.label).toEqual("#1 Test Work Package"); expect(treeItem.collapsibleState).toEqual( TreeItemCollapsibleState.Collapsed, @@ -152,7 +149,7 @@ describe("OpenProjectTreeDataProvider", () => { describe("getChildren", () => { it("should return the top-level work packages", () => { - instance["workPackages"] = [ + treeView["workPackages"] = [ { id: 1, subject: "Test Work Package 1", @@ -178,7 +175,7 @@ describe("OpenProjectTreeDataProvider", () => { ancestor: null, }, ] as any; - const topLevelWPs = instance.getChildren(); + const topLevelWPs = treeView.getChildren(); expect(topLevelWPs).toEqual([ { id: 1, @@ -236,9 +233,9 @@ describe("OpenProjectTreeDataProvider", () => { parent: null, ancestor: null, }; - instance["workPackages"] = [parentWP, ...parentWP.children] as any; + treeView["workPackages"] = [parentWP, ...parentWP.children] as any; - expect(instance.getChildren(parentWP as any)).toEqual([ + expect(treeView.getChildren(parentWP as any)).toEqual([ { id: 2, subject: "Child Work Package", @@ -261,7 +258,7 @@ describe("OpenProjectTreeDataProvider", () => { describe("resolveTreeItem", () => { it("should return item", () => { const item = {}; - expect(instance.resolveTreeItem(item)).toEqual(item); + expect(treeView.resolveTreeItem(item)).toEqual(item); }); }); @@ -314,8 +311,8 @@ describe("OpenProjectTreeDataProvider", () => { parent: null, ancestor: null, }; - instance["workPackages"] = [ancestorWP, ...ancestorWP.children] as any; - const parentWP = instance.getParent({ + treeView["workPackages"] = [ancestorWP, ...ancestorWP.children] as any; + const parentWP = treeView.getParent({ id: 3, subject: "Child Work Package", status: { diff --git a/src/views/openProject.treeDataProvider.ts b/src/application/views/openProject.treeDataProvider.ts similarity index 59% rename from src/views/openProject.treeDataProvider.ts rename to src/application/views/openProject.treeDataProvider.ts index e5fbe43..6c0f791 100644 --- a/src/views/openProject.treeDataProvider.ts +++ b/src/application/views/openProject.treeDataProvider.ts @@ -1,35 +1,30 @@ +import { inject, injectable } from "inversify"; 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"; +import * as vscode from "vscode"; +import { Event, ProviderResult, TreeItem } from "vscode"; +import TOKENS from "../../DI/tokens"; +import OpenProjectClient from "../../infrastructure/openProject/openProject.client"; +import getIconPathByStatus from "../../utils/getIconPathByStatus.util"; +import OpenProjectTreeDataProvider from "./openProjectTreeDataProvider.interface"; -export default class OpenProjectTreeDataProvider - implements TreeDataProvider +@injectable() +export default class OpenProjectTreeDataProviderImpl + implements OpenProjectTreeDataProvider { - private static _instance: OpenProjectTreeDataProvider; - - public static getInstance(): OpenProjectTreeDataProvider { - if (!this._instance) { - this._instance = new OpenProjectTreeDataProvider(); - } - return this._instance; + constructor( + @inject(TOKENS.opClient) + private readonly _client: OpenProjectClient, + ) { + this.refreshWPs(); } - 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; @@ -47,11 +42,11 @@ export default class OpenProjectTreeDataProvider }; } - getChildren(element?: WP | undefined): ProviderResult { - if (!element) { + getChildren(parentElement?: WP | undefined): ProviderResult { + if (!parentElement) { return this.workPackages.filter((wp) => !wp.parent); } - return this.workPackages.filter((wp) => wp.parent?.id === element.id); + return this.workPackages.filter((wp) => wp.parent?.id === parentElement.id); } getParent(element: WP): ProviderResult { @@ -62,8 +57,8 @@ export default class OpenProjectTreeDataProvider return item; } - refreshWPs(): Promise | undefined { - return this._client.getWPs()?.then((wps) => { + refreshWPs(): Promise { + return this._client.getWPs().then((wps) => { if (wps.length) { this.workPackages = wps; this._onDidChangeTreeData.fire(); diff --git a/src/application/views/openProjectTreeDataProvider.interface.ts b/src/application/views/openProjectTreeDataProvider.interface.ts new file mode 100644 index 0000000..76cd6f6 --- /dev/null +++ b/src/application/views/openProjectTreeDataProvider.interface.ts @@ -0,0 +1,7 @@ +import { WP } from "op-client"; +import { TreeDataProvider } from "vscode"; + +export default interface OpenProjectTreeDataProvider + extends TreeDataProvider { + refreshWPs: () => Promise; +} diff --git a/src/commands/authorizeClient.command.ts b/src/commands/authorizeClient.command.ts deleted file mode 100644 index c777c89..0000000 --- a/src/commands/authorizeClient.command.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 061a411..0000000 --- a/src/commands/refreshWPs.command.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index d076de6..0000000 --- a/src/commands/refreshWPs.command.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 index 0e473d7..d79d5d6 100644 --- a/src/extension.spec.ts +++ b/src/extension.spec.ts @@ -1,47 +1,96 @@ -const vscode = require("./__mocks__/vscode"); +jest.mock("./application/commands/authorize/authorizeClient.command"); +jest.mock("./application/commands/refresh/refreshWPs.command"); +jest.mock("./application/views/openProject.treeDataProvider"); -jest.mock("./views/openProject.treeDataProvider"); -jest.mock("./commands/authorizeClient.command"); -jest.mock("./commands/refreshWPs.command"); +const vscode = require("./__mocks__/vscode"); -import OpenProjectTreeDataProvider from "./views/openProject.treeDataProvider"; -import authorizeClient from "./commands/authorizeClient.command"; -import refreshWPs from "./commands/refreshWPs.command"; +import container from "./DI/container"; +import TOKENS from "./DI/tokens"; +import AuthorizeClientCommand from "./application/commands/authorize/authorizeClientCommand.interface"; +import RefreshWPsCommand from "./application/commands/refresh/refreshWPsCommand.interface"; +import OpenProjectTreeDataProvider from "./application/views/openProjectTreeDataProvider.interface"; import { activate, deactivate } from "./extension"; describe("activate", () => { let context: any; + let authCommand: AuthorizeClientCommand; + let refreshCommand: RefreshWPsCommand; + let treeView: OpenProjectTreeDataProvider; + + beforeAll(() => { + authCommand = container.get( + TOKENS.authorizeCommand, + ); + refreshCommand = container.get(TOKENS.refreshWPsCommand); + treeView = container.get(TOKENS.opTreeView); + }); + 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, - ); + describe("registers all commands", () => { + it("registers auth command", () => { + activate(context); + expect(vscode.commands.registerCommand.mock.calls).toEqual( + expect.arrayContaining([ + ["openproject.auth", authCommand.authorizeClient], + ]), + ); + }); + it("registers refresh command", () => { + activate(context); + expect(vscode.commands.registerCommand.mock.calls).toEqual( + expect.arrayContaining([ + ["openproject.refresh", refreshCommand.refreshWPs], + ]), + ); + }); }); - test("creates expected tree view", () => { + it("creates expected tree view", () => { activate(context); expect(vscode.window.createTreeView).toHaveBeenCalledWith( "openproject-workspaces", - expect.any(Object), + { treeDataProvider: treeView }, ); - expect(OpenProjectTreeDataProvider.getInstance).toHaveBeenCalled(); }); - test("adds commands and subscriptions to context", () => { - activate(context); - expect(context.subscriptions).toHaveLength(2); + describe("subscriptions", () => { + it("adds 3 subscriptions to context", () => { + activate(context); + expect(context.subscriptions).toHaveLength(3); + }); + it("adds authCommand to subscriptions to context", () => { + jest + .spyOn(vscode.commands, "registerCommand") + .mockImplementation((name) => name); + activate(context); + expect(context.subscriptions).toEqual( + expect.arrayContaining(["openproject.auth"]), + ); + }); + it("adds refreshCommand to subscriptions to context", () => { + jest + .spyOn(vscode.commands, "registerCommand") + .mockImplementation((name) => name); + activate(context); + expect(context.subscriptions).toEqual( + expect.arrayContaining(["openproject.refresh"]), + ); + }); + it("adds treeView to subscriptions to context", () => { + jest + .spyOn(vscode.window, "createTreeView") + .mockImplementation((name) => name); + activate(context); + expect(context.subscriptions).toEqual( + expect.arrayContaining(["openproject-workspaces"]), + ); + }); }); }); diff --git a/src/extension.ts b/src/extension.ts index bc39f6f..60d61e0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,22 +1,38 @@ import * as vscode from "vscode"; -import OpenProjectTreeDataProvider from "./views/openProject.treeDataProvider"; -import authorizeClient from "./commands/authorizeClient.command"; -import refreshWPs from "./commands/refreshWPs.command"; +import container from "./DI/container"; +import TOKENS from "./DI/tokens"; +import AuthorizeClientCommand from "./application/commands/authorize/authorizeClientCommand.interface"; +import RefreshWPsCommand from "./application/commands/refresh/refreshWPsCommand.interface"; +import OpenProjectTreeDataProvider from "./application/views/openProject.treeDataProvider"; export function activate(context: vscode.ExtensionContext) { - authorizeClient(); - const authCommand = vscode.commands.registerCommand( - "openproject.auth", - authorizeClient, + const authCommand = container.get( + TOKENS.authorizeCommand, ); - const refreshWPsCommand = vscode.commands.registerCommand( - "openproject.refresh", - refreshWPs, + const refreshCommand = container.get( + TOKENS.refreshWPsCommand, ); - vscode.window.createTreeView("openproject-workspaces", { - treeDataProvider: OpenProjectTreeDataProvider.getInstance(), - }); - context.subscriptions.push(authCommand, refreshWPsCommand); + const treeView = container.get( + TOKENS.opTreeView, + ); + + const components = [ + vscode.commands.registerCommand( + "openproject.auth", + authCommand.authorizeClient, + ), + vscode.commands.registerCommand( + "openproject.refresh", + refreshCommand.refreshWPs, + ), + vscode.window.createTreeView("openproject-workspaces", { + treeDataProvider: treeView, + }), + ]; + + context.subscriptions.push(...components); + + authCommand.authorizeClient(); } export function deactivate() {} diff --git a/src/infrastructure/logger/__mocks__/logger.ts b/src/infrastructure/logger/__mocks__/logger.ts new file mode 100644 index 0000000..7dc1481 --- /dev/null +++ b/src/infrastructure/logger/__mocks__/logger.ts @@ -0,0 +1,9 @@ +import { injectable } from "inversify"; +import Logger from "../logger"; + +@injectable() +export default class ConsoleLogger implements Logger { + log = jest.fn(); + + error = jest.fn(); +} diff --git a/src/infrastructure/logger/logger.interface.ts b/src/infrastructure/logger/logger.interface.ts new file mode 100644 index 0000000..b49f9e7 --- /dev/null +++ b/src/infrastructure/logger/logger.interface.ts @@ -0,0 +1,4 @@ +export default interface Logger { + log(...messages: unknown[]): void; + error(...messages: unknown[]): void; +} diff --git a/src/infrastructure/logger/logger.spec.ts b/src/infrastructure/logger/logger.spec.ts new file mode 100644 index 0000000..0a9a016 --- /dev/null +++ b/src/infrastructure/logger/logger.spec.ts @@ -0,0 +1,31 @@ +import container from "../../DI/container"; +import TOKENS from "../../DI/tokens"; +import Logger from "./logger"; + +describe("ConsoleLogger tests suite", () => { + let logger: Logger; + + beforeAll(() => { + logger = container.get(TOKENS.logger); + }); + + it("should call console.log", () => { + const message = "Hello World!"; + + jest.spyOn(console, "log"); + + logger.log(message); + + expect(console.log).toHaveBeenLastCalledWith(message); + }); + + it("should call console.error", () => { + const message = "Error happened!"; + + jest.spyOn(console, "error"); + + logger.error(message); + + expect(console.error).toHaveBeenLastCalledWith(message); + }); +}); diff --git a/src/infrastructure/logger/logger.ts b/src/infrastructure/logger/logger.ts new file mode 100644 index 0000000..7ba329f --- /dev/null +++ b/src/infrastructure/logger/logger.ts @@ -0,0 +1,13 @@ +import { injectable } from "inversify"; +import Logger from "./logger.interface"; + +@injectable() +export default class ConsoleLogger implements Logger { + log(...messages: unknown[]): void { + console.log(...messages); + } + + error(...messages: unknown[]): void { + console.error(...messages); + } +} diff --git a/src/infrastructure/openProject/__mocks__/openProject.client.ts b/src/infrastructure/openProject/__mocks__/openProject.client.ts new file mode 100644 index 0000000..2d49da6 --- /dev/null +++ b/src/infrastructure/openProject/__mocks__/openProject.client.ts @@ -0,0 +1,11 @@ +import { injectable } from "inversify"; +import OpenProjectClient from "../openProjectClient.interface"; + +@injectable() +export default class OpenProjectClientImpl implements OpenProjectClient { + init = jest.fn(); + + getUser = jest.fn(); + + getWPs = jest.fn(); +} diff --git a/src/infrastructure/openProject/clientNotInitialized.exception.ts b/src/infrastructure/openProject/clientNotInitialized.exception.ts new file mode 100644 index 0000000..ca365e9 --- /dev/null +++ b/src/infrastructure/openProject/clientNotInitialized.exception.ts @@ -0,0 +1,5 @@ +export default class ClientNotInitializedException extends Error { + constructor() { + super("OpenProjectClient is used before initialization."); + } +} diff --git a/src/infrastructure/openProject/openProject.client.spec.ts b/src/infrastructure/openProject/openProject.client.spec.ts new file mode 100644 index 0000000..4116569 --- /dev/null +++ b/src/infrastructure/openProject/openProject.client.spec.ts @@ -0,0 +1,133 @@ +jest.mock("../logger/logger"); + +import { faker } from "@faker-js/faker"; +import { User, WP } from "op-client"; +import "reflect-metadata"; +import container from "../../DI/container"; +import TOKENS from "../../DI/tokens"; +import ClientNotInitializedException from "./clientNotInitialized.exception"; +import UnexceptedClientException from "./unexpectedClientError.exception"; +import UserNotFound from "./userNotFound.exception"; +import OpenProjectClientImpl from "./openProject.client"; +import Logger from "../logger/logger.interface"; + +jest.mock("op-client"); + +describe("OpenProject Client tests", () => { + let client: OpenProjectClientImpl; + let logger: Logger; + + beforeEach(() => { + jest.clearAllMocks(); + client = container.get(TOKENS.opClient); + logger = container.get(TOKENS.logger); + + 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 throw ClientNotInitializedException if entity manager is null", () => { + client["_entityManager"] = undefined; + + expect(() => client.getUser()).toThrowError( + ClientNotInitializedException, + ); + }); + it("should throw UserNotFound if no user found", async () => { + jest + .spyOn(client["_entityManager"]!, "fetch") + .mockResolvedValueOnce(null); + + await expect(client.getUser()).rejects.toThrowError(UserNotFound); + }); + it("should throw UnexceptedClientException if error returned", async () => { + jest + .spyOn(client["_entityManager"]!, "fetch") + .mockRejectedValueOnce(null); + + await expect(client.getUser()).rejects.toThrowError( + UnexceptedClientException, + ); + }); + }); + + 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 throw ClientNotInitializedException if entity manager is null", () => { + client["_entityManager"] = undefined; + + expect(() => client.getWPs()).toThrowError(ClientNotInitializedException); + }); + }); + + 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(client["addTokenToUrl"](url, token)).toBe(result); + }); + }); + describe("getLogger", () => { + it("should return logger", () => { + expect(client["getLogger"]()).toEqual(logger); + }); + }); +}); diff --git a/src/infrastructure/openProject/openProject.client.ts b/src/infrastructure/openProject/openProject.client.ts new file mode 100644 index 0000000..801b589 --- /dev/null +++ b/src/infrastructure/openProject/openProject.client.ts @@ -0,0 +1,70 @@ +import ClientOAuth2 from "client-oauth2"; +import { inject, injectable } from "inversify"; +import { EntityManager, User, WP } from "op-client"; +import TOKENS from "../../DI/tokens"; +import addCredsToUrl from "../../utils/addCredsToUrl.util"; +import Logger from "../logger/logger"; +import ClientNotInitializedException from "./clientNotInitialized.exception"; +import OpenProjectClient from "./openProjectClient.interface"; +import UnexceptedClientException from "./unexpectedClientError.exception"; +import UserNotFound from "./userNotFound.exception"; + +@injectable() +export default class OpenProjectClientImpl implements OpenProjectClient { + constructor(@inject(TOKENS.logger) private readonly _logger: Logger) {} + + private _entityManager?: EntityManager | undefined; + + private get entityManager(): EntityManager { + if (!this._entityManager) throw new ClientNotInitializedException(); + return this._entityManager; + } + + private set entityManager(value: EntityManager) { + this._entityManager = value; + } + + public init(baseUrl: string, token: string): Promise { + this.entityManager = new EntityManager({ + baseUrl: this.addTokenToUrl(baseUrl, token), + createLogger: this.getLogger, + token: new ClientOAuth2({}).createToken(token, {}), + }); + return this.getUser(); + } + + public getUser(): Promise { + return this.entityManager + .fetch("api/v3/users/me") + .then((response) => { + if (!response) throw new UserNotFound(); + return new User(response); + }) + .catch((err) => { + if ( + err instanceof UserNotFound || + err instanceof ClientNotInitializedException + ) { + throw err; + } + this._logger.error(err); + throw new UnexceptedClientException(); + }); + } + + public getWPs(): Promise { + return this.entityManager.getMany(WP, { + pageSize: 100, + all: true, + filters: [], + }); + } + + private addTokenToUrl(baseUrl: string, token: string) { + return addCredsToUrl(baseUrl, "apikey", token); + } + + private getLogger(): Logger { + return this._logger; + } +} diff --git a/src/infrastructure/openProject/openProjectClient.interface.ts b/src/infrastructure/openProject/openProjectClient.interface.ts new file mode 100644 index 0000000..d83cde6 --- /dev/null +++ b/src/infrastructure/openProject/openProjectClient.interface.ts @@ -0,0 +1,7 @@ +import { User, WP } from "op-client"; + +export default interface OpenProjectClient { + init(baseUrl: string, token: string): Promise; + getUser(): Promise; + getWPs(): Promise; +} diff --git a/src/infrastructure/openProject/unexpectedClientError.exception.ts b/src/infrastructure/openProject/unexpectedClientError.exception.ts new file mode 100644 index 0000000..e3d7b5d --- /dev/null +++ b/src/infrastructure/openProject/unexpectedClientError.exception.ts @@ -0,0 +1,5 @@ +export default class UnexceptedClientException extends Error { + constructor() { + super("Unexcepted OpenProjectClient error happened."); + } +} diff --git a/src/infrastructure/openProject/userNotFound.exception.ts b/src/infrastructure/openProject/userNotFound.exception.ts new file mode 100644 index 0000000..b9398f1 --- /dev/null +++ b/src/infrastructure/openProject/userNotFound.exception.ts @@ -0,0 +1,5 @@ +export default class UserNotFound extends Error { + constructor() { + super("OpenProject user was not found."); + } +} diff --git a/src/openProject.client.spec.ts b/src/openProject.client.spec.ts deleted file mode 100644 index 7e98421..0000000 --- a/src/openProject.client.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index 6b66b9d..0000000 --- a/src/openProject.client.ts +++ /dev/null @@ -1,65 +0,0 @@ -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/utils/addCredsToUrl.util.spec.ts b/src/utils/addCredsToUrl.util.spec.ts new file mode 100644 index 0000000..cc8d773 --- /dev/null +++ b/src/utils/addCredsToUrl.util.spec.ts @@ -0,0 +1,14 @@ +import { faker } from "@faker-js/faker"; +import addCredsToUrl from "./addCredsToUrl.util"; + +describe("addTokenToUrl", () => { + it("should return correct url", () => { + const username = faker.string.alphanumeric(10); + const password = faker.string.alphanumeric(10); + const url = "http://google.com"; + + const result = `http://${username}:${password}@google.com/`; + + expect(addCredsToUrl(url, username, password)).toBe(result); + }); +}); diff --git a/src/utils/addCredsToUrl.util.ts b/src/utils/addCredsToUrl.util.ts new file mode 100644 index 0000000..cc9722c --- /dev/null +++ b/src/utils/addCredsToUrl.util.ts @@ -0,0 +1,17 @@ +/** + * makes http://username:password@google.com out of http://google.com + * @param url url to which we want to add token + * @param username username to add + * @param password password to add + * @returns url with creds + */ +export default function addCredsToUrl( + url: string, + username: string, + password: string, +): string { + const parsedUrl = new URL(url); + parsedUrl.username = username; + parsedUrl.password = password; + return parsedUrl.toString(); +} diff --git a/src/utils/getIconPathByStatus.util.spec.ts b/src/utils/getIconPathByStatus.util.spec.ts index 3404a11..abf9f88 100644 --- a/src/utils/getIconPathByStatus.util.spec.ts +++ b/src/utils/getIconPathByStatus.util.spec.ts @@ -1,8 +1,8 @@ import { faker } from "@faker-js/faker"; import getIconPathByStatus from "./getIconPathByStatus.util"; -describe("getIconPathByStatus test suit", () => { - describe("return correct path suit", () => { +describe("getIconPathByStatus test suite", () => { + describe("return correct path suite", () => { it("should return correct path of 'Confirmed' icon", () => { expect(getIconPathByStatus("Confirmed")).toEqual( "resources/confirmed.png", diff --git a/tsconfig.json b/tsconfig.json index 0205cec..a7a3045 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,10 +5,13 @@ "sourceMap": true, "rootDir": "src", "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, "strict": true, "types": [ "jest", - "node" + "node", + "reflect-metadata", ] - } + }, } \ No newline at end of file From 13a76a5f609085348ab010c30c9cf4fa7774d648 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Sun, 11 Jun 2023 16:19:55 +0300 Subject: [PATCH 04/38] wpStatus enum --- .../views/openProject.treeDataProvider.ts | 3 ++- .../openProject/wpStatus.enum.ts | 15 +++++++++++ src/utils/getIconPathByStatus.util.ts | 26 ++++++++++--------- 3 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 src/infrastructure/openProject/wpStatus.enum.ts diff --git a/src/application/views/openProject.treeDataProvider.ts b/src/application/views/openProject.treeDataProvider.ts index 6c0f791..d9be991 100644 --- a/src/application/views/openProject.treeDataProvider.ts +++ b/src/application/views/openProject.treeDataProvider.ts @@ -7,6 +7,7 @@ import TOKENS from "../../DI/tokens"; import OpenProjectClient from "../../infrastructure/openProject/openProject.client"; import getIconPathByStatus from "../../utils/getIconPathByStatus.util"; import OpenProjectTreeDataProvider from "./openProjectTreeDataProvider.interface"; +import WPStatus from "../../infrastructure/openProject/wpStatus.enum"; @injectable() export default class OpenProjectTreeDataProviderImpl @@ -29,7 +30,7 @@ export default class OpenProjectTreeDataProviderImpl this._onDidChangeTreeData.event; getTreeItem(element: WP): TreeItem | Promise { - const iconPath = getIconPathByStatus(element.status.self.title); + const iconPath = getIconPathByStatus(element.status.self.title as WPStatus); return { label: `#${element.id} ${element.subject}`, collapsibleState: diff --git a/src/infrastructure/openProject/wpStatus.enum.ts b/src/infrastructure/openProject/wpStatus.enum.ts new file mode 100644 index 0000000..c58c790 --- /dev/null +++ b/src/infrastructure/openProject/wpStatus.enum.ts @@ -0,0 +1,15 @@ +enum WPStatus { + new = "New", + confirmed = "Confirmed", + inSpecification = "In specification", + specified = "Specified", + inProgress = "In progress", + developed = "Developed", + inTesting = "In testing", + tested = "Tested", + testFailed = "Test failed", + onHold = "On hold", + closed = "Closed", + rejected = "Rejected", +} +export default WPStatus; diff --git a/src/utils/getIconPathByStatus.util.ts b/src/utils/getIconPathByStatus.util.ts index a96872c..8be4488 100644 --- a/src/utils/getIconPathByStatus.util.ts +++ b/src/utils/getIconPathByStatus.util.ts @@ -1,28 +1,30 @@ +import WPStatus from "../infrastructure/openProject/wpStatus.enum"; + export default function getIconPathByStatus( - status?: string, + status?: WPStatus, ): string | undefined { switch (status) { - case "Confirmed": + case WPStatus.confirmed: return "resources/confirmed.png"; - case "In specification": + case WPStatus.inSpecification: return "resources/in_specification.png"; - case "Specified": + case WPStatus.specified: return "resources/specified.png"; - case "In progress": + case WPStatus.inProgress: return "resources/developing.png"; - case "Developed": + case WPStatus.developed: return "resources/developed.png"; - case "In testing": + case WPStatus.inTesting: return "resources/testing.png"; - case "Tested": + case WPStatus.tested: return "resources/tested.png"; - case "Test failed": + case WPStatus.testFailed: return "resources/failed.png"; - case "On hold": + case WPStatus.onHold: return "resources/hold.png"; - case "Closed": + case WPStatus.closed: return "resources/closed.png"; - case "Rejected": + case WPStatus.rejected: return "resources/rejected.png"; default: return undefined; From 120e1f61b1e76cca1199d3e04212d8c9145f6e3b Mon Sep 17 00:00:00 2001 From: goodhumored Date: Sun, 11 Jun 2023 16:20:09 +0300 Subject: [PATCH 05/38] eslint rules updated --- .eslintrc.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index c12d394..f4e5b5c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -109,8 +109,12 @@ "warn", { "ignore": [ + -1, 0, - 1 + 1, + 2, + 5, + 10 ] } ], @@ -259,6 +263,7 @@ "jsdoc/require-jsdoc": "off", "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "warn", - "import/no-cycle": "off" + "import/no-cycle": "off", + "no-shadow": "off" } } \ No newline at end of file From 398ce5758f72f7d637d0547eea577a6dce3900a9 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Sun, 11 Jun 2023 23:07:51 +0300 Subject: [PATCH 06/38] webpack config fixed --- webpack.config.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/webpack.config.js b/webpack.config.js index 89b1e73..70c4144 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -29,8 +29,7 @@ const extensionConfig = { module: { rules: [ { - test: /\.ts$/, - exclude: /(node_modules|*\.spec\.ts)/, + test: /^(?!.*\.spec\.ts$).*\.ts$/, use: [ { loader: "ts-loader", From 46def7338ae45f488e00dbfc1b6ced7cc34403ce Mon Sep 17 00:00:00 2001 From: goodhumored Date: Sun, 11 Jun 2023 23:08:08 +0300 Subject: [PATCH 07/38] vscode mock extended --- src/__mocks__/vscode.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/__mocks__/vscode.ts b/src/__mocks__/vscode.ts index 90c3479..f89e93f 100644 --- a/src/__mocks__/vscode.ts +++ b/src/__mocks__/vscode.ts @@ -8,6 +8,8 @@ class Disposable {} class EventEmitter { fire = jest.fn(); + + event = jest.fn(); } const languages = { From 724a699afaea2a7ca2d81bad2ad69ce5d30219e3 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Sun, 11 Jun 2023 23:09:12 +0300 Subject: [PATCH 08/38] authorizeClientCommand refactored --- .../authorize/authorizeClient.command.spec.ts | 83 +++++++------------ .../authorize/authorizeClient.command.ts | 47 +++++++---- .../authorizeClientCommand.interface.ts | 2 +- 3 files changed, 62 insertions(+), 70 deletions(-) diff --git a/src/application/commands/authorize/authorizeClient.command.spec.ts b/src/application/commands/authorize/authorizeClient.command.spec.ts index 8dc0216..3984de5 100644 --- a/src/application/commands/authorize/authorizeClient.command.spec.ts +++ b/src/application/commands/authorize/authorizeClient.command.spec.ts @@ -1,5 +1,4 @@ jest.mock("../../../infrastructure/openProject/openProject.client"); -jest.mock("../../views/openProject.treeDataProvider"); import { faker } from "@faker-js/faker"; import { User } from "op-client"; @@ -7,103 +6,85 @@ import container from "../../../DI/container"; import TOKENS from "../../../DI/tokens"; import * as vscode from "../../../__mocks__/vscode"; import OpenProjectClient from "../../../infrastructure/openProject/openProject.client"; +import UserNotFound from "../../../infrastructure/openProject/userNotFound.exception"; import VSCodeConfigMock from "../../../test/config.mock"; -import OpenProjectTreeDataProvider from "../../views/openProject.treeDataProvider"; import AuthorizeClientCommandImpl from "./authorizeClient.command"; describe("Authorize client command test suite", () => { let command: AuthorizeClientCommandImpl; let client: OpenProjectClient; - let treeDataProvider: OpenProjectTreeDataProvider; - let config: VSCodeConfigMock; - const user = new User(1); beforeEach(() => { jest.clearAllMocks(); command = container.get(TOKENS.authorizeCommand); client = container.get(TOKENS.opClient); - treeDataProvider = container.get(TOKENS.opTreeView); - config = new VSCodeConfigMock({ - base_url: faker.internet.url(), - token: faker.string.sample(), - }); - jest.spyOn(vscode.workspace, "getConfiguration").mockReturnValue(config); - jest.spyOn(client, "init").mockResolvedValue(user); - - user.firstName = faker.person.firstName(); - user.lastName = faker.person.lastName(); }); - describe("Init call", () => { - it("should call init with correct data", async () => { - await command.authorizeClient(); + describe("authorizeClient", () => { + it("should call init with correct data", () => { + const config = new VSCodeConfigMock({ + base_url: faker.internet.url(), + token: faker.string.sample(), + }); + jest.spyOn(vscode.workspace, "getConfiguration").mockReturnValue(config); + jest.spyOn(client, "init"); + + command.authorizeClient(); + expect(client.init).toHaveBeenLastCalledWith( config.get("base_url"), config.get("token"), ); }); - it("should call init empty string", async () => { + it("should call init empty string", () => { const emptyConfig = new VSCodeConfigMock({}); jest .spyOn(vscode.workspace, "getConfiguration") .mockReturnValue(emptyConfig); - await command.authorizeClient(); + command.authorizeClient(); expect(client.init).toHaveBeenLastCalledWith("", ""); }); }); - describe("On success", () => { - it("should set 'openproject.authed' to true on success", async () => { - jest.spyOn(vscode.commands, "executeCommand"); - - await command.authorizeClient(); - - expect(vscode.commands.executeCommand).toHaveBeenLastCalledWith( - "setContext", - "openproject.authed", - true, - ); - }); - + describe("showMessage", () => { it("should show message 'Hello' on success", async () => { + const user = new User(1); + user.firstName = faker.person.firstName(); + user.lastName = faker.person.lastName(); + jest.spyOn(vscode.window, "showInformationMessage"); + jest.spyOn(client, "getUser").mockResolvedValue(user); - await command.authorizeClient(); + await command.showMessage(); expect(vscode.window.showInformationMessage).toHaveBeenLastCalledWith( `Hello, ${user.firstName} ${user.lastName}!`, ); }); - - it("should call 'refresh WPs' on treeDataProvider", async () => { - await command.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 command.authorizeClient(); + jest.spyOn(client, "getUser").mockRejectedValue(new UserNotFound()); + await command.showMessage(); expect(vscode.window.showErrorMessage).toHaveBeenLastCalledWith( "Failed connecting to OpenProject", ); }); + }); - it("should call nothing else", async () => { - jest.spyOn(client, "init").mockResolvedValue(undefined); + describe("setAuthedTrue", () => { + it("should set 'openproject.authed' to true", () => { jest.spyOn(vscode.commands, "executeCommand"); - jest.spyOn(vscode.window, "showInformationMessage"); - await command.authorizeClient(); + command.setAuthedTrue(); - expect(vscode.commands.executeCommand).not.toHaveBeenCalled(); - expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + expect(vscode.commands.executeCommand).toHaveBeenLastCalledWith( + "setContext", + "openproject.authed", + true, + ); }); }); }); diff --git a/src/application/commands/authorize/authorizeClient.command.ts b/src/application/commands/authorize/authorizeClient.command.ts index a2b7fd3..c8d0a72 100644 --- a/src/application/commands/authorize/authorizeClient.command.ts +++ b/src/application/commands/authorize/authorizeClient.command.ts @@ -1,8 +1,8 @@ import { inject, injectable } from "inversify"; import * as vscode from "vscode"; import TOKENS from "../../../DI/tokens"; -import OpenProjectClient from "../../../infrastructure/openProject/openProjectClient.interface"; -import OpenProjectTreeDataProvider from "../../views/openProjectTreeDataProvider.interface"; +import Logger from "../../../infrastructure/logger/logger.interface"; +import OpenProjectClient from "../../../infrastructure/openProject/openProject.client.interface"; import AuthorizeClientCommand from "./authorizeClientCommand.interface"; @injectable() @@ -10,26 +10,37 @@ export default class AuthorizeClientCommandImpl implements AuthorizeClientCommand { constructor( - @inject(TOKENS.opTreeView) - private readonly _treeDataProvider: OpenProjectTreeDataProvider, @inject(TOKENS.opClient) private readonly _client: OpenProjectClient, - ) {} + @inject(TOKENS.logger) + private readonly _logger?: Logger, + ) { + this._client.onInit(() => { + this.showMessage(); + this.setAuthedTrue(); + }); + } - async authorizeClient() { + authorizeClient() { const config = vscode.workspace.getConfiguration("openproject"); - const user = await this._client.init( - config.get("base_url") ?? "", - config.get("token") ?? "", - ); - if (!user) { - vscode.window.showErrorMessage("Failed connecting to OpenProject"); - return; - } + this._client.init(config.get("base_url") ?? "", config.get("token") ?? ""); + } + + setAuthedTrue() { vscode.commands.executeCommand("setContext", "openproject.authed", true); - vscode.window.showInformationMessage( - `Hello, ${user.firstName} ${user.lastName}!`, - ); - this._treeDataProvider.refreshWPs(); + } + + showMessage() { + return this._client + .getUser() + .then((user) => { + vscode.window.showInformationMessage( + `Hello, ${user.firstName} ${user.lastName}!`, + ); + }) + .catch((err) => { + this._logger?.error("Failed connecting to OpenProject: ", err); + vscode.window.showErrorMessage("Failed connecting to OpenProject"); + }); } } diff --git a/src/application/commands/authorize/authorizeClientCommand.interface.ts b/src/application/commands/authorize/authorizeClientCommand.interface.ts index cb70392..61d15f1 100644 --- a/src/application/commands/authorize/authorizeClientCommand.interface.ts +++ b/src/application/commands/authorize/authorizeClientCommand.interface.ts @@ -1,3 +1,3 @@ export default interface AuthorizeClientCommand { - authorizeClient(): Promise; + authorizeClient(): void; } From b93e8e3d32307a13870de9cff0e39c9858d2a25d Mon Sep 17 00:00:00 2001 From: goodhumored Date: Sun, 11 Jun 2023 23:14:03 +0300 Subject: [PATCH 09/38] client update - get projects method added - getLogger method removed; createLogger is arrow function - onInit event fire onInit event instead of return getUser after init --- .../__mocks__/openProject.client.ts | 6 +- .../openProject.client.interface.ts | 11 +++ .../openProject/openProject.client.spec.ts | 67 +++++++++++++++---- .../openProject/openProject.client.ts | 31 ++++++--- .../openProjectClient.interface.ts | 7 -- 5 files changed, 91 insertions(+), 31 deletions(-) create mode 100644 src/infrastructure/openProject/openProject.client.interface.ts delete mode 100644 src/infrastructure/openProject/openProjectClient.interface.ts diff --git a/src/infrastructure/openProject/__mocks__/openProject.client.ts b/src/infrastructure/openProject/__mocks__/openProject.client.ts index 2d49da6..6352a1b 100644 --- a/src/infrastructure/openProject/__mocks__/openProject.client.ts +++ b/src/infrastructure/openProject/__mocks__/openProject.client.ts @@ -1,8 +1,12 @@ import { injectable } from "inversify"; -import OpenProjectClient from "../openProjectClient.interface"; +import OpenProjectClient from "../openProject.client.interface"; @injectable() export default class OpenProjectClientImpl implements OpenProjectClient { + getProjects = jest.fn(); + + onInit = jest.fn(); + init = jest.fn(); getUser = jest.fn(); diff --git a/src/infrastructure/openProject/openProject.client.interface.ts b/src/infrastructure/openProject/openProject.client.interface.ts new file mode 100644 index 0000000..2ef7b5f --- /dev/null +++ b/src/infrastructure/openProject/openProject.client.interface.ts @@ -0,0 +1,11 @@ +import { Project, User, WP } from "op-client"; +import * as vscode from "vscode"; + +export default interface OpenProjectClient { + init(baseUrl: string, token: string): void; + getUser(): Promise; + getWPs(): Promise; + getProjects(): Promise; + + onInit: vscode.Event; +} diff --git a/src/infrastructure/openProject/openProject.client.spec.ts b/src/infrastructure/openProject/openProject.client.spec.ts index 4116569..b304405 100644 --- a/src/infrastructure/openProject/openProject.client.spec.ts +++ b/src/infrastructure/openProject/openProject.client.spec.ts @@ -1,15 +1,16 @@ jest.mock("../logger/logger"); import { faker } from "@faker-js/faker"; -import { User, WP } from "op-client"; +import { Project, User, WP } from "op-client"; import "reflect-metadata"; import container from "../../DI/container"; import TOKENS from "../../DI/tokens"; +import ConsoleLogger from "../logger/logger"; +import Logger from "../logger/logger.interface"; import ClientNotInitializedException from "./clientNotInitialized.exception"; +import OpenProjectClientImpl from "./openProject.client"; import UnexceptedClientException from "./unexpectedClientError.exception"; import UserNotFound from "./userNotFound.exception"; -import OpenProjectClientImpl from "./openProject.client"; -import Logger from "../logger/logger.interface"; jest.mock("op-client"); @@ -30,14 +31,32 @@ describe("OpenProject Client tests", () => { }); describe("Initialization", () => { - it("should be initialized correctly", async () => { + it("should be initialized correctly", () => { const token = faker.string.alphanumeric(); const baseUrl = faker.internet.url(); - const user = new User(1); - jest.spyOn(client, "getUser").mockResolvedValueOnce(user); + expect(client.init(baseUrl, token)).toBeUndefined(); + }); + it("should fire event", () => { + const token = faker.string.alphanumeric(); + const baseUrl = faker.internet.url(); + jest.spyOn(client["_onInit"], "fire"); - expect(await client.init(baseUrl, token)).toEqual(user); + client.init(baseUrl, token); + + expect(client["_onInit"].fire).toHaveBeenCalled(); + }); + it("should pass correct logger factory function", () => { + const token = faker.string.alphanumeric(); + const baseUrl = faker.internet.url(); + + client.init(baseUrl, token); + + expect( + ( + client["_entityManager"]!.constructor as jest.Mock + ).mock.calls[0][0].createLogger(), + ).toBeInstanceOf(ConsoleLogger); }); }); @@ -115,6 +134,35 @@ describe("OpenProject Client tests", () => { }); }); + describe("getProjects", () => { + it("should call getMany", async () => { + jest.spyOn(client["_entityManager"]!, "getMany"); + + await client.getProjects(); + + expect(client["_entityManager"]!.getMany).toHaveBeenLastCalledWith( + Project, + { + pageSize: 100, + all: true, + filters: [], + }, + ); + }); + it("should return projects", async () => { + const projects = faker.helpers.uniqueArray( + () => new Project(faker.number.int()), + 5, + ); + + jest + .spyOn(client["_entityManager"]!, "getMany") + .mockResolvedValueOnce(projects); + + expect(await client.getProjects()).toEqual(projects); + }); + }); + describe("addTokenToUrl", () => { it("should return correct url", () => { const token = faker.string.alphanumeric(10); @@ -125,9 +173,4 @@ describe("OpenProject Client tests", () => { expect(client["addTokenToUrl"](url, token)).toBe(result); }); }); - describe("getLogger", () => { - it("should return logger", () => { - expect(client["getLogger"]()).toEqual(logger); - }); - }); }); diff --git a/src/infrastructure/openProject/openProject.client.ts b/src/infrastructure/openProject/openProject.client.ts index 801b589..54324a7 100644 --- a/src/infrastructure/openProject/openProject.client.ts +++ b/src/infrastructure/openProject/openProject.client.ts @@ -1,20 +1,25 @@ import ClientOAuth2 from "client-oauth2"; import { inject, injectable } from "inversify"; -import { EntityManager, User, WP } from "op-client"; +import { EntityManager, Project, User, WP } from "op-client"; +import * as vscode from "vscode"; import TOKENS from "../../DI/tokens"; import addCredsToUrl from "../../utils/addCredsToUrl.util"; import Logger from "../logger/logger"; import ClientNotInitializedException from "./clientNotInitialized.exception"; -import OpenProjectClient from "./openProjectClient.interface"; +import OpenProjectClient from "./openProject.client.interface"; import UnexceptedClientException from "./unexpectedClientError.exception"; import UserNotFound from "./userNotFound.exception"; @injectable() export default class OpenProjectClientImpl implements OpenProjectClient { - constructor(@inject(TOKENS.logger) private readonly _logger: Logger) {} + private _onInit = new vscode.EventEmitter(); private _entityManager?: EntityManager | undefined; + onInit = this._onInit.event; + + constructor(@inject(TOKENS.logger) private readonly _logger: Logger) {} + private get entityManager(): EntityManager { if (!this._entityManager) throw new ClientNotInitializedException(); return this._entityManager; @@ -24,16 +29,16 @@ export default class OpenProjectClientImpl implements OpenProjectClient { this._entityManager = value; } - public init(baseUrl: string, token: string): Promise { + public init(baseUrl: string, token: string): void { this.entityManager = new EntityManager({ baseUrl: this.addTokenToUrl(baseUrl, token), - createLogger: this.getLogger, + createLogger: () => this._logger, token: new ClientOAuth2({}).createToken(token, {}), }); - return this.getUser(); + this._onInit.fire(); } - public getUser(): Promise { + public getUser(): Promise { return this.entityManager .fetch("api/v3/users/me") .then((response) => { @@ -60,11 +65,15 @@ export default class OpenProjectClientImpl implements OpenProjectClient { }); } - private addTokenToUrl(baseUrl: string, token: string) { - return addCredsToUrl(baseUrl, "apikey", token); + public getProjects(): Promise { + return this.entityManager.getMany(Project, { + pageSize: 100, + all: true, + filters: [], + }); } - private getLogger(): Logger { - return this._logger; + private addTokenToUrl(baseUrl: string, token: string) { + return addCredsToUrl(baseUrl, "apikey", token); } } diff --git a/src/infrastructure/openProject/openProjectClient.interface.ts b/src/infrastructure/openProject/openProjectClient.interface.ts deleted file mode 100644 index d83cde6..0000000 --- a/src/infrastructure/openProject/openProjectClient.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { User, WP } from "op-client"; - -export default interface OpenProjectClient { - init(baseUrl: string, token: string): Promise; - getUser(): Promise; - getWPs(): Promise; -} From 5b723264b9586b6a864d317cf15b1e6665327c10 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Sun, 11 Jun 2023 23:14:48 +0300 Subject: [PATCH 10/38] logger updated --- src/infrastructure/logger/__mocks__/logger.ts | 2 ++ src/infrastructure/logger/logger.interface.ts | 1 + src/infrastructure/logger/logger.spec.ts | 10 ++++++++++ src/infrastructure/logger/logger.ts | 4 ++++ 4 files changed, 17 insertions(+) diff --git a/src/infrastructure/logger/__mocks__/logger.ts b/src/infrastructure/logger/__mocks__/logger.ts index 7dc1481..82fc5e9 100644 --- a/src/infrastructure/logger/__mocks__/logger.ts +++ b/src/infrastructure/logger/__mocks__/logger.ts @@ -3,6 +3,8 @@ import Logger from "../logger"; @injectable() export default class ConsoleLogger implements Logger { + debug = jest.fn(); + log = jest.fn(); error = jest.fn(); diff --git a/src/infrastructure/logger/logger.interface.ts b/src/infrastructure/logger/logger.interface.ts index b49f9e7..fab93ff 100644 --- a/src/infrastructure/logger/logger.interface.ts +++ b/src/infrastructure/logger/logger.interface.ts @@ -1,4 +1,5 @@ export default interface Logger { log(...messages: unknown[]): void; + debug(...messages: unknown[]): void; error(...messages: unknown[]): void; } diff --git a/src/infrastructure/logger/logger.spec.ts b/src/infrastructure/logger/logger.spec.ts index 0a9a016..391ca43 100644 --- a/src/infrastructure/logger/logger.spec.ts +++ b/src/infrastructure/logger/logger.spec.ts @@ -19,6 +19,16 @@ describe("ConsoleLogger tests suite", () => { expect(console.log).toHaveBeenLastCalledWith(message); }); + it("should call console.debug", () => { + const message = "Debug message!"; + + jest.spyOn(console, "debug"); + + logger.debug(message); + + expect(console.debug).toHaveBeenLastCalledWith(message); + }); + it("should call console.error", () => { const message = "Error happened!"; diff --git a/src/infrastructure/logger/logger.ts b/src/infrastructure/logger/logger.ts index 7ba329f..b9a579c 100644 --- a/src/infrastructure/logger/logger.ts +++ b/src/infrastructure/logger/logger.ts @@ -7,6 +7,10 @@ export default class ConsoleLogger implements Logger { console.log(...messages); } + debug(...messages: unknown[]): void { + console.debug(...messages); + } + error(...messages: unknown[]): void { console.error(...messages); } From 6d55d15bba3f5f2bce1054188135c046cf1ea277 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Sun, 11 Jun 2023 23:16:04 +0300 Subject: [PATCH 11/38] project repository; WP repository --- src/DI/container.ts | 22 ++++- src/DI/tokens.ts | 2 + .../project/__mocks__/project.repository.ts | 15 +++ .../project/project.repository.interface.ts | 9 ++ .../project/project.repository.spec.ts | 63 ++++++++++++ .../project/project.repository.ts | 39 ++++++++ .../project/projectNotFount.exception.ts | 5 + .../workPackage/__mocks__/wp.repository.ts | 17 ++++ .../workPackage/wp.repository.interface.ts | 11 +++ .../workPackage/wp.repository.spec.ts | 95 +++++++++++++++++++ .../workPackage/wp.repository.ts | 47 +++++++++ .../workPackage/wpNotFount.exception.ts | 5 + 12 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 src/infrastructure/project/__mocks__/project.repository.ts create mode 100644 src/infrastructure/project/project.repository.interface.ts create mode 100644 src/infrastructure/project/project.repository.spec.ts create mode 100644 src/infrastructure/project/project.repository.ts create mode 100644 src/infrastructure/project/projectNotFount.exception.ts create mode 100644 src/infrastructure/workPackage/__mocks__/wp.repository.ts create mode 100644 src/infrastructure/workPackage/wp.repository.interface.ts create mode 100644 src/infrastructure/workPackage/wp.repository.spec.ts create mode 100644 src/infrastructure/workPackage/wp.repository.ts create mode 100644 src/infrastructure/workPackage/wpNotFount.exception.ts diff --git a/src/DI/container.ts b/src/DI/container.ts index e10f016..2eb0711 100644 --- a/src/DI/container.ts +++ b/src/DI/container.ts @@ -6,10 +6,20 @@ import RefreshWPsCommandImpl from "../application/commands/refresh/refreshWPs.co import RefreshWPsCommand from "../application/commands/refresh/refreshWPsCommand.interface"; import OpenProjectTreeDataProviderImpl from "../application/views/openProject.treeDataProvider"; import OpenProjectTreeDataProvider from "../application/views/openProjectTreeDataProvider.interface"; +import ProjectWPsFilterImpl from "../core/filter/project/project.wpsFilter"; +import ProjectWPsFilter from "../core/filter/project/project.wpsFilter.interface"; +import StatusWPsFilterImpl from "../core/filter/status/status.wpsFilter"; +import StatusWPsFilter from "../core/filter/status/status.wpsFilter.interface"; +import TextWPsFilterImpl from "../core/filter/text/text.wpsFilter"; +import TextWPsFilter from "../core/filter/text/text.wpsFilter.interface"; import ConsoleLogger from "../infrastructure/logger/logger"; import Logger from "../infrastructure/logger/logger.interface"; import OpenProjectClientImpl from "../infrastructure/openProject/openProject.client"; -import OpenProjectClient from "../infrastructure/openProject/openProjectClient.interface"; +import OpenProjectClient from "../infrastructure/openProject/openProject.client.interface"; +import ProjectRepositoryImpl from "../infrastructure/project/project.repository"; +import ProjectRepository from "../infrastructure/project/project.repository.interface"; +import WPRepositoryImpl from "../infrastructure/workPackage/wp.repository"; +import WPRepository from "../infrastructure/workPackage/wp.repository.interface"; import TOKENS from "./tokens"; const container = new Container(); @@ -34,6 +44,16 @@ container .to(RefreshWPsCommandImpl) .inSingletonScope(); +container + .bind(TOKENS.wpRepository) + .to(WPRepositoryImpl) + .inSingletonScope(); + +container + .bind(TOKENS.projectRepository) + .to(ProjectRepositoryImpl) + .inSingletonScope(); + container.bind(TOKENS.logger).to(ConsoleLogger).inSingletonScope(); export default container; diff --git a/src/DI/tokens.ts b/src/DI/tokens.ts index 66b6b7f..a8d5805 100644 --- a/src/DI/tokens.ts +++ b/src/DI/tokens.ts @@ -4,6 +4,8 @@ const TOKENS = { refreshWPsCommand: Symbol.for("RefreshWPsCommand"), authorizeCommand: Symbol.for("AuthorizeClientCommand"), logger: Symbol.for("Logger"), + wpRepository: Symbol.for("WPRepository"), + projectRepository: Symbol.for("ProjectRepository"), }; export default TOKENS; diff --git a/src/infrastructure/project/__mocks__/project.repository.ts b/src/infrastructure/project/__mocks__/project.repository.ts new file mode 100644 index 0000000..aad9e60 --- /dev/null +++ b/src/infrastructure/project/__mocks__/project.repository.ts @@ -0,0 +1,15 @@ +import { injectable } from "inversify"; +import ProjectRepository from "../project.repository.interface"; + +@injectable() +export default class ProjectRepositoryImpl implements ProjectRepository { + findById = jest.fn(); + + findAll = jest.fn(); + + refetch = jest.fn(); + + onProjectsRefetch = jest.fn(); + + penis = "asd"; +} diff --git a/src/infrastructure/project/project.repository.interface.ts b/src/infrastructure/project/project.repository.interface.ts new file mode 100644 index 0000000..6d419c6 --- /dev/null +++ b/src/infrastructure/project/project.repository.interface.ts @@ -0,0 +1,9 @@ +import { Project } from "op-client"; +import { Event } from "vscode"; + +export default interface ProjectRepository { + findById(id: number): Project; + findAll(): Project[]; + refetch(): Promise; + onProjectsRefetch: Event; +} diff --git a/src/infrastructure/project/project.repository.spec.ts b/src/infrastructure/project/project.repository.spec.ts new file mode 100644 index 0000000..645ad4e --- /dev/null +++ b/src/infrastructure/project/project.repository.spec.ts @@ -0,0 +1,63 @@ +jest.mock("../openProject/openProject.client"); + +import { Project } from "op-client"; +import container from "../../DI/container"; +import TOKENS from "../../DI/tokens"; +import OpenProjectClient from "../openProject/openProject.client.interface"; +import ProjectRepository from "./project.repository.interface"; +import ProjectNotFoundException from "./projectNotFount.exception"; + +describe("Project repository test suite", () => { + const client = container.get(TOKENS.opClient); + const repository = container.get(TOKENS.projectRepository); + const project1 = new Project(1); + const project2 = new Project(2); + const project3 = new Project(3); + const project4 = new Project(4); + const project5 = new Project(5); + + beforeAll(async () => { + jest + .spyOn(client, "getProjects") + .mockResolvedValue([project1, project2, project3, project4]); + await repository.refetch(); + }); + + describe("findById", () => { + it("should return project1", () => { + expect(repository.findById(project1.id)).toEqual(project1); + }); + it("should return project2 by id", () => { + expect(repository.findById(project2.id)).toEqual(project2); + }); + it("should return project3 by id", () => { + expect(repository.findById(project3.id)).toEqual(project3); + }); + it("should return project4 by id", () => { + expect(repository.findById(project4.id)).toEqual(project4); + }); + it("should throw ProjectNotFoundException", () => { + expect(() => repository.findById(project5.id)).toThrowError( + ProjectNotFoundException, + ); + }); + }); + + describe("findAll", () => { + it("should return all projects", () => { + expect(repository.findAll()).toEqual([ + project1, + project2, + project3, + project4, + ]); + }); + }); + + describe("refetch", () => { + it("should call client getProjects again", () => { + repository.refetch(); + expect(client.getProjects).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/infrastructure/project/project.repository.ts b/src/infrastructure/project/project.repository.ts new file mode 100644 index 0000000..fdaef29 --- /dev/null +++ b/src/infrastructure/project/project.repository.ts @@ -0,0 +1,39 @@ +import { inject, injectable } from "inversify"; +import { Project } from "op-client"; +import * as vscode from "vscode"; +import { Event } from "vscode"; +import TOKENS from "../../DI/tokens"; +import OpenProjectClient from "../openProject/openProject.client.interface"; +import ProjectRepository from "./project.repository.interface"; +import ProjectNotFoundException from "./projectNotFount.exception"; + +@injectable() +export default class ProjectRepositoryImpl implements ProjectRepository { + private _projects: Project[] = []; + + private _onProjectsRefetch: vscode.EventEmitter = + new vscode.EventEmitter(); + + onProjectsRefetch: Event = this._onProjectsRefetch.event; + + constructor( + @inject(TOKENS.opClient) private readonly _client: OpenProjectClient, + ) {} + + findById(id: number): Project { + const result = this._projects.find((wp) => wp.id === id); + if (!result) throw new ProjectNotFoundException(); + return result; + } + + findAll(): Project[] { + return this._projects; + } + + refetch(): Promise { + return this._client.getProjects().then((projects) => { + this._projects = projects; + this._onProjectsRefetch.fire(); + }); + } +} diff --git a/src/infrastructure/project/projectNotFount.exception.ts b/src/infrastructure/project/projectNotFount.exception.ts new file mode 100644 index 0000000..3e6089f --- /dev/null +++ b/src/infrastructure/project/projectNotFount.exception.ts @@ -0,0 +1,5 @@ +export default class ProjectNotFoundException extends Error { + constructor() { + super("Work package was not found"); + } +} diff --git a/src/infrastructure/workPackage/__mocks__/wp.repository.ts b/src/infrastructure/workPackage/__mocks__/wp.repository.ts new file mode 100644 index 0000000..e3fc1db --- /dev/null +++ b/src/infrastructure/workPackage/__mocks__/wp.repository.ts @@ -0,0 +1,17 @@ +import { injectable } from "inversify"; +import WPRepository from "../wp.repository.interface"; + +@injectable() +export default class WPRepositoryImpl implements WPRepository { + findById = jest.fn(); + + findByParentId = jest.fn(); + + findAll = jest.fn(); + + refetch = jest.fn(); + + onWPsRefetch = jest.fn(); + + findByProjectId = jest.fn(); +} diff --git a/src/infrastructure/workPackage/wp.repository.interface.ts b/src/infrastructure/workPackage/wp.repository.interface.ts new file mode 100644 index 0000000..2b752c2 --- /dev/null +++ b/src/infrastructure/workPackage/wp.repository.interface.ts @@ -0,0 +1,11 @@ +import { WP } from "op-client"; +import { Event } from "vscode"; + +export default interface WPRepository { + findById(id: number): WP; + findByParentId(parentId: number): WP[]; + findByProjectId(projectId: number): WP[]; + findAll(): WP[]; + refetch(): Promise; + onWPsRefetch: Event; +} diff --git a/src/infrastructure/workPackage/wp.repository.spec.ts b/src/infrastructure/workPackage/wp.repository.spec.ts new file mode 100644 index 0000000..d98ad79 --- /dev/null +++ b/src/infrastructure/workPackage/wp.repository.spec.ts @@ -0,0 +1,95 @@ +jest.mock("../openProject/openProject.client"); + +import { Project, WP } from "op-client"; +import container from "../../DI/container"; +import TOKENS from "../../DI/tokens"; +import OpenProjectClient from "../openProject/openProject.client.interface"; +import WPRepositoryImpl from "./wp.repository"; +import WPNotFoundException from "./wpNotFount.exception"; + +describe("WP repository test suite", () => { + const client = container.get(TOKENS.opClient); + const repository = container.get(TOKENS.wpRepository); + const wp1 = new WP(1); + const wp2 = new WP(2); + const wp3 = new WP(3); + const wp4 = new WP(4); + const wp5 = new WP(5); + const project1 = new Project(1); + const project2 = new Project(2); + const project3 = new Project(3); + const project4 = new Project(4); + + beforeAll(async () => { + wp2.parent = wp1; + wp3.parent = wp1; + wp4.parent = wp2; + wp1.project = project1; + wp2.project = project2; + wp3.project = project2; + wp4.project = project3; + + jest.spyOn(client, "getWPs").mockResolvedValue([wp1, wp2, wp3, wp4]); + await repository.refetch(); + }); + + describe("findById", () => { + it("should return wp1", () => { + expect(repository.findById(wp1.id)).toEqual(wp1); + }); + it("should return wp2 by id", () => { + expect(repository.findById(wp2.id)).toEqual(wp2); + }); + it("should return wp3 by id", () => { + expect(repository.findById(wp3.id)).toEqual(wp3); + }); + it("should return wp4 by id", () => { + expect(repository.findById(wp4.id)).toEqual(wp4); + }); + it("should throw WPNotFoundException", () => { + expect(() => repository.findById(wp5.id)).toThrowError( + WPNotFoundException, + ); + }); + }); + + describe("findByParentId", () => { + it("should return children of wp1", () => { + expect(repository.findByParentId(wp1.id)).toEqual([wp2, wp3]); + }); + it("should return children of wp2", () => { + expect(repository.findByParentId(wp2.id)).toEqual([wp4]); + }); + it("should return children of wp4", () => { + expect(repository.findByParentId(wp4.id)).toEqual([]); + }); + }); + + describe("findByProjectId", () => { + it("should return projects of project1", () => { + expect(repository.findByProjectId(project1.id)).toEqual([wp1]); + }); + it("should return projects of project2", () => { + expect(repository.findByProjectId(project2.id)).toEqual([wp2, wp3]); + }); + it("should return projects of project3", () => { + expect(repository.findByProjectId(project3.id)).toEqual([wp4]); + }); + it("should return empty array", () => { + expect(repository.findByProjectId(project4.id)).toEqual([]); + }); + }); + + describe("findAll", () => { + it("should return all wps", () => { + expect(repository.findAll()).toEqual([wp1, wp2, wp3, wp4]); + }); + }); + + describe("refetch", () => { + it("should call client getWPs again", () => { + repository.refetch(); + expect(client.getWPs).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/infrastructure/workPackage/wp.repository.ts b/src/infrastructure/workPackage/wp.repository.ts new file mode 100644 index 0000000..2cfed20 --- /dev/null +++ b/src/infrastructure/workPackage/wp.repository.ts @@ -0,0 +1,47 @@ +import { inject, injectable } from "inversify"; +import { WP } from "op-client"; +import * as vscode from "vscode"; +import { Event } from "vscode"; +import TOKENS from "../../DI/tokens"; +import OpenProjectClient from "../openProject/openProject.client.interface"; +import WPRepository from "./wp.repository.interface"; +import WPNotFoundException from "./wpNotFount.exception"; + +@injectable() +export default class WPRepositoryImpl implements WPRepository { + private _wps: WP[] = []; + + private _onWPsRefetch: vscode.EventEmitter = + new vscode.EventEmitter(); + + onWPsRefetch: Event = this._onWPsRefetch.event; + + constructor( + @inject(TOKENS.opClient) private readonly _client: OpenProjectClient, + ) {} + + findById(id: number): WP { + const result = this._wps.find((wp) => wp.id === id); + if (!result) throw new WPNotFoundException(); + return result; + } + + findByParentId(parentId: number): WP[] { + return this._wps.filter((wp) => wp.parent?.id === parentId); + } + + findByProjectId(projectId: number): WP[] { + return this._wps.filter((wp) => wp.project.id === projectId); + } + + findAll(): WP[] { + return this._wps; + } + + refetch(): Promise { + return this._client.getWPs().then((wps) => { + this._wps = wps; + this._onWPsRefetch.fire(); + }); + } +} diff --git a/src/infrastructure/workPackage/wpNotFount.exception.ts b/src/infrastructure/workPackage/wpNotFount.exception.ts new file mode 100644 index 0000000..1f2342d --- /dev/null +++ b/src/infrastructure/workPackage/wpNotFount.exception.ts @@ -0,0 +1,5 @@ +export default class WPNotFoundException extends Error { + constructor() { + super("Work package was not found"); + } +} From 65258e675c3d024c1e29a3c4145701977921deff Mon Sep 17 00:00:00 2001 From: goodhumored Date: Sun, 11 Jun 2023 23:19:21 +0300 Subject: [PATCH 12/38] tree data provider refactored - wp crud and storage logic moved to repository - projects added to the top level - treeItems construct logic moved to projectTreeItem and WPTreeItem classes - refresh calls refetch on repositories --- .../__mocks__/openProject.treeDataProvider.ts | 4 + .../openProject.treeDataProvider.spec.ts | 413 ++++-------------- .../views/openProject.treeDataProvider.ts | 82 ++-- .../openProjectTreeDataProvider.interface.ts | 6 +- .../views/treeItems/project.treeItem.spec.ts | 41 ++ .../views/treeItems/project.treeItem.ts | 16 + .../views/treeItems/wp.treeItem.spec.ts | 93 ++++ .../views/treeItems/wp.treeItem.ts | 37 ++ 8 files changed, 314 insertions(+), 378 deletions(-) create mode 100644 src/application/views/treeItems/project.treeItem.spec.ts create mode 100644 src/application/views/treeItems/project.treeItem.ts create mode 100644 src/application/views/treeItems/wp.treeItem.spec.ts create mode 100644 src/application/views/treeItems/wp.treeItem.ts diff --git a/src/application/views/__mocks__/openProject.treeDataProvider.ts b/src/application/views/__mocks__/openProject.treeDataProvider.ts index 4569d07..c9f500d 100644 --- a/src/application/views/__mocks__/openProject.treeDataProvider.ts +++ b/src/application/views/__mocks__/openProject.treeDataProvider.ts @@ -5,6 +5,10 @@ import OpenProjectTreeDataProvider from "../openProjectTreeDataProvider.interfac export default class OpenProjectTreeDataProviderImpl implements OpenProjectTreeDataProvider { + refresh = jest.fn(); + + onDidChangeTreeData = jest.fn(); + refreshWPs = jest.fn(); getTreeItem = jest.fn(); diff --git a/src/application/views/openProject.treeDataProvider.spec.ts b/src/application/views/openProject.treeDataProvider.spec.ts index 60a294e..0b002c2 100644 --- a/src/application/views/openProject.treeDataProvider.spec.ts +++ b/src/application/views/openProject.treeDataProvider.spec.ts @@ -1,257 +1,66 @@ -jest.mock("../../infrastructure/openProject/openProject.client"); -import { WP } from "op-client"; -import { TreeItemCollapsibleState } from "vscode"; +jest.mock("../../infrastructure/project/project.repository"); +jest.mock("../../infrastructure/workPackage/wp.repository"); + +import { faker } from "@faker-js/faker"; +import { Project, WP } from "op-client"; import container from "../../DI/container"; import TOKENS from "../../DI/tokens"; -import OpenProjectClient from "../../infrastructure/openProject/openProjectClient.interface"; +import ProjectRepository from "../../infrastructure/project/project.repository.interface"; +import WPRepository from "../../infrastructure/workPackage/wp.repository.interface"; import OpenProjectTreeDataProviderImpl from "./openProject.treeDataProvider"; describe("OpenProjectTreeDataProvider", () => { - let treeView: OpenProjectTreeDataProviderImpl; - let client: OpenProjectClient; - - beforeAll(() => { - client = container.get(TOKENS.opClient); - jest.spyOn(client, "getWPs").mockResolvedValue([]); - treeView = container.get(TOKENS.opTreeView); - }); - - beforeEach(() => { - jest.spyOn(client, "getWPs").mockResolvedValue([]); - }); + const treeView = container.get( + TOKENS.opTreeView, + ); + const wpRepo = container.get(TOKENS.wpRepository); + const projectRepo = container.get( + TOKENS.projectRepository, + ); afterEach(() => { jest.restoreAllMocks(); }); - describe("refreshWPs", () => { - it("should update the workPackages array with new work packages", async () => { - const wps: WP[] = [ - { - id: 1, - subject: "Test Work Package", - status: { - self: { - title: "New", - }, - }, - children: [], - parent: null, - ancestor: null, - }, - ] as any; - jest.spyOn(client, "getWPs").mockResolvedValue(wps); - - await treeView.refreshWPs(); - - expect(treeView["workPackages"]).toEqual(wps); - }); - it("should fire _onDidChangeTreeData", async () => { - jest.spyOn(client, "getWPs").mockResolvedValue([ - { - id: 1, - subject: "Test Work Package", - status: { - self: { - title: "New", - }, - }, - children: [], - parent: null, - ancestor: null, - } as any, - ]); - - await treeView.refreshWPs(); - - expect(treeView["_onDidChangeTreeData"].fire).toHaveBeenCalled(); - }); + describe("getTreeItem", () => { + it.todo("should return wp tree item"); + it.todo("should return project tree item"); }); - 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 treeView.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 treeView.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 treeView.getTreeItem(wp as any); - expect(treeItem.label).toEqual("#1 Test Work Package"); - expect(treeItem.collapsibleState).toEqual( - TreeItemCollapsibleState.Collapsed, + describe("getChildren", () => { + it("should return the project", () => { + const projects = faker.helpers.uniqueArray( + () => new Project(faker.number.int()), + 10, ); - expect(treeItem.iconPath).toBeUndefined(); + + jest.spyOn(projectRepo, "findAll").mockReturnValue(projects); + + const result = treeView.getChildren(); + expect(result).toEqual(projects); }); - 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 treeView.getTreeItem(wp as any); - expect(treeItem.label).toEqual("#1 Test Work Package"); - expect(treeItem.collapsibleState).toEqual( - TreeItemCollapsibleState.Collapsed, + it("should return wps of project", () => { + const wps = faker.helpers.uniqueArray( + () => new WP(faker.number.int()), + 10, ); - expect(treeItem.iconPath).not.toBeUndefined(); - }); - }); - describe("getChildren", () => { - it("should return the top-level work packages", () => { - treeView["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 = treeView.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, - }, - ]); + jest.spyOn(wpRepo, "findByProjectId").mockReturnValue(wps); + + const result = treeView.getChildren(new Project(1)); + expect(result).toEqual(wps); }); 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, - }; - treeView["workPackages"] = [parentWP, ...parentWP.children] as any; + const wps = faker.helpers.uniqueArray( + () => new WP(faker.number.int()), + 10, + ); - expect(treeView.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, - }, - ]); + jest.spyOn(wpRepo, "findByParentId").mockReturnValue(wps); + + const result = treeView.getChildren(new WP(1)); + expect(result).toEqual(wps); }); }); @@ -263,107 +72,41 @@ describe("OpenProjectTreeDataProvider", () => { }); 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, - }; - treeView["workPackages"] = [ancestorWP, ...ancestorWP.children] as any; - const parentWP = treeView.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", - }, - }); + it("should return project", () => { + const wp = new WP(1); + const project = new Project(1); + wp.project = project; + + jest.spyOn(projectRepo, "findById").mockReturnValue(project); + + expect(treeView.getParent(wp)).toEqual(project); + }); + it("should return workPackage", () => { + const wp = new WP(1); + const wp1 = new WP(2); + wp1.parent = wp; + + jest.spyOn(wpRepo, "findById").mockReturnValue(wp); + + expect(treeView.getParent(wp1)).toEqual(wp); + }); + it("should return undefined", () => { + const project = new Project(1); + + expect(treeView.getParent(project)).toBeUndefined(); + }); + }); + + describe("refresh", () => { + it("should call wpRepo refetch", () => { + jest.spyOn(wpRepo, "refetch"); + treeView.refresh(); + expect(wpRepo.refetch).toHaveBeenCalled(); + }); + it("should call projectRepo refetch", () => { + jest.spyOn(projectRepo, "refetch"); + treeView.refresh(); + expect(projectRepo.refetch).toHaveBeenCalled(); }); }); }); diff --git a/src/application/views/openProject.treeDataProvider.ts b/src/application/views/openProject.treeDataProvider.ts index d9be991..b2fb26e 100644 --- a/src/application/views/openProject.treeDataProvider.ts +++ b/src/application/views/openProject.treeDataProvider.ts @@ -1,69 +1,71 @@ import { inject, injectable } from "inversify"; -import { WP } from "op-client"; -import path from "path"; +import { Project, WP } from "op-client"; import * as vscode from "vscode"; import { Event, ProviderResult, TreeItem } from "vscode"; import TOKENS from "../../DI/tokens"; -import OpenProjectClient from "../../infrastructure/openProject/openProject.client"; -import getIconPathByStatus from "../../utils/getIconPathByStatus.util"; +import OpenProjectClient from "../../infrastructure/openProject/openProject.client.interface"; +import ProjectRepository from "../../infrastructure/project/project.repository.interface"; +import WPRepository from "../../infrastructure/workPackage/wp.repository.interface"; import OpenProjectTreeDataProvider from "./openProjectTreeDataProvider.interface"; -import WPStatus from "../../infrastructure/openProject/wpStatus.enum"; +import ProjectTreeItem from "./treeItems/project.treeItem"; +import WPTreeItem from "./treeItems/wp.treeItem"; @injectable() export default class OpenProjectTreeDataProviderImpl implements OpenProjectTreeDataProvider { + private _onDidChangeTreeData: vscode.EventEmitter = + new vscode.EventEmitter(); + + onDidChangeTreeData: Event = this._onDidChangeTreeData.event; + constructor( + @inject(TOKENS.wpRepository) + private readonly _wpRepository: WPRepository, + @inject(TOKENS.projectRepository) + private readonly _projectRepository: ProjectRepository, @inject(TOKENS.opClient) - private readonly _client: OpenProjectClient, + _client: OpenProjectClient, ) { - this.refreshWPs(); + _wpRepository.onWPsRefetch(() => this._onDidChangeTreeData.fire()); + _projectRepository.onProjectsRefetch(() => + this._onDidChangeTreeData.fire(), + ); + _client.onInit(() => this.refresh()); } - private workPackages: WP[] = []; - - private _onDidChangeTreeData: vscode.EventEmitter< - void | WP | WP[] | null | undefined - > = new vscode.EventEmitter(); - - onDidChangeTreeData?: Event = - this._onDidChangeTreeData.event; - - getTreeItem(element: WP): TreeItem | Promise { - const iconPath = getIconPathByStatus(element.status.self.title as WPStatus); - 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, - }; + getTreeItem(element: WP | Project): TreeItem { + if (element instanceof WP) return new WPTreeItem(element); + if (element instanceof Project) return new ProjectTreeItem(element); + return {}; } - getChildren(parentElement?: WP | undefined): ProviderResult { + getChildren( + parentElement?: WP | Project | undefined, + ): ProviderResult { if (!parentElement) { - return this.workPackages.filter((wp) => !wp.parent); + return this._projectRepository.findAll(); + } + if (parentElement instanceof Project) { + return this._wpRepository.findByProjectId(parentElement.id); } - return this.workPackages.filter((wp) => wp.parent?.id === parentElement.id); + return this._wpRepository.findByParentId(parentElement.id); } - getParent(element: WP): ProviderResult { - return this.workPackages.find((wp) => wp.id === element.parent.id); + getParent(element: WP | Project): ProviderResult { + if (element instanceof Project) return undefined; + if (element.parent) return this._wpRepository.findById(element.parent.id); + return this._projectRepository.findById(element.project.id); } resolveTreeItem(item: TreeItem): ProviderResult { return item; } - refreshWPs(): Promise { - return this._client.getWPs().then((wps) => { - if (wps.length) { - this.workPackages = wps; - this._onDidChangeTreeData.fire(); - } - }); + refresh(): Promise { + return Promise.all([ + this._wpRepository.refetch(), + this._projectRepository.refetch(), + ]).then(); } } diff --git a/src/application/views/openProjectTreeDataProvider.interface.ts b/src/application/views/openProjectTreeDataProvider.interface.ts index 76cd6f6..11b2996 100644 --- a/src/application/views/openProjectTreeDataProvider.interface.ts +++ b/src/application/views/openProjectTreeDataProvider.interface.ts @@ -1,7 +1,7 @@ -import { WP } from "op-client"; +import { Project, WP } from "op-client"; import { TreeDataProvider } from "vscode"; export default interface OpenProjectTreeDataProvider - extends TreeDataProvider { - refreshWPs: () => Promise; + extends TreeDataProvider { + refresh(): Promise; } diff --git a/src/application/views/treeItems/project.treeItem.spec.ts b/src/application/views/treeItems/project.treeItem.spec.ts new file mode 100644 index 0000000..7cfb30c --- /dev/null +++ b/src/application/views/treeItems/project.treeItem.spec.ts @@ -0,0 +1,41 @@ +import { faker } from "@faker-js/faker"; +import { Project } from "op-client"; +import { TreeItemCollapsibleState } from "../../../__mocks__/vscode"; +import ProjectTreeItem from "./project.treeItem"; + +describe("Project tree item test suit", () => { + describe("should construct correctly", () => { + it("should return a tree item with label, collapsible state and icon path", () => { + const text = faker.lorem.text(); + const project: Project = { + id: 1, + body: { + name: "Test Project", + description: { + raw: text, + }, + }, + } as Project; + const treeItem = new ProjectTreeItem(project as any); + expect(treeItem.label).toEqual("Test Project"); + expect(treeItem.description).toEqual(text); + expect(treeItem.collapsibleState).toEqual( + TreeItemCollapsibleState.Expanded, + ); + }); + it("should return a tree item with an empty description", () => { + const project: Project = { + id: 1, + body: { + name: "Test Project", + }, + } as Project; + const treeItem = new ProjectTreeItem(project as any); + expect(treeItem.label).toEqual("Test Project"); + expect(treeItem.description).toBeUndefined(); + expect(treeItem.collapsibleState).toEqual( + TreeItemCollapsibleState.Expanded, + ); + }); + }); +}); diff --git a/src/application/views/treeItems/project.treeItem.ts b/src/application/views/treeItems/project.treeItem.ts new file mode 100644 index 0000000..effbf1b --- /dev/null +++ b/src/application/views/treeItems/project.treeItem.ts @@ -0,0 +1,16 @@ +import { Project } from "op-client"; +import { TreeItem, TreeItemCollapsibleState, TreeItemLabel } from "vscode"; + +export default class ProjectTreeItem implements TreeItem { + collapsibleState?: TreeItemCollapsibleState | undefined; + + description?: string; + + label?: string | TreeItemLabel | undefined; + + constructor(project: Project) { + this.label = project.body.name; + this.collapsibleState = TreeItemCollapsibleState.Expanded; + this.description = project.body.description?.raw; + } +} diff --git a/src/application/views/treeItems/wp.treeItem.spec.ts b/src/application/views/treeItems/wp.treeItem.spec.ts new file mode 100644 index 0000000..be8a5e6 --- /dev/null +++ b/src/application/views/treeItems/wp.treeItem.spec.ts @@ -0,0 +1,93 @@ +import { TreeItemCollapsibleState } from "../../../__mocks__/vscode"; +import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; +import WPTreeItem from "./wp.treeItem"; + +describe("WP tree item test suit", () => { + describe("should construct correctly", () => { + describe("simple new task", () => { + const wp = { + id: 1, + subject: "Test Work Package", + status: { + self: { + title: "New", + }, + }, + type: { + self: { + title: "Task", + }, + }, + children: [], + }; + it("should return a tree item with label", () => { + const treeItem = new WPTreeItem(wp as any); + expect(treeItem.label).toEqual({ + label: "#1 Test Work Package Task", + highlights: [ + [0, 2], + [21, 25], + ], + }); + }); + it("should return a tree item with collapsible state", () => { + const treeItem = new WPTreeItem(wp as any); + expect(treeItem.collapsibleState).toEqual( + TreeItemCollapsibleState.None, + ); + }); + it("should return a tree item with undefined icon path", () => { + const treeItem = new WPTreeItem(wp as any); + expect(treeItem.iconPath).toBeUndefined(); + }); + }); + describe("phase with children", () => { + const wp = { + id: 1, + subject: "Test Work Package", + status: { + self: { + title: WPStatus.closed, + }, + }, + type: { + self: { + title: "Phase", + }, + }, + children: [{}], + }; + it("should return tree item with collapsible state collapsed", () => { + const treeItem = new WPTreeItem(wp as any); + expect(treeItem.collapsibleState).toEqual( + TreeItemCollapsibleState.Collapsed, + ); + }); + it("should return a tree item with some icon path", () => { + const treeItem = new WPTreeItem(wp as any); + expect(treeItem.iconPath).not.toBeUndefined(); + }); + }); + describe("undefined type", () => { + const wp = { + id: 1, + subject: "Test Work Package", + status: { + self: { + title: WPStatus.closed, + }, + }, + }; + it("should return a tree item with label", () => { + const treeItem = new WPTreeItem(wp as any); + expect(treeItem.label).toEqual({ + label: "#1 Test Work Package", + highlights: [ + [0, 2], + [20, 20], + ], + }); + }); + }); + }); +}); diff --git a/src/application/views/treeItems/wp.treeItem.ts b/src/application/views/treeItems/wp.treeItem.ts new file mode 100644 index 0000000..d3ccc7d --- /dev/null +++ b/src/application/views/treeItems/wp.treeItem.ts @@ -0,0 +1,37 @@ +import { WP } from "op-client"; +import path from "path"; +import * as vscode from "vscode"; +import { TreeItem, TreeItemCollapsibleState, TreeItemLabel, Uri } from "vscode"; +import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; +import getIconPathByStatus from "../../../utils/getIconPathByStatus.util"; + +export default class WPTreeItem implements TreeItem { + collapsibleState?: TreeItemCollapsibleState | undefined; + + description?: string; + + iconPath?: string | Uri; + + label?: string | TreeItemLabel | undefined; + + constructor(wp: WP) { + const iconPath = getIconPathByStatus(wp.status.self.title as WPStatus); + const type = wp.type?.self.title; + let label = `#${wp.id} ${wp.subject}`; + if (type) label += ` ${type}`; + this.label = { + label, + highlights: [ + [0, Math.floor(Math.log10(Math.abs(wp.id))) + 2], + [label.length - (type?.length ?? 0), label.length], + ], + }; + this.collapsibleState = + wp.children?.length > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None; + this.iconPath = iconPath + ? vscode.Uri.file(path.join(__dirname, iconPath)) + : undefined; + } +} From 403d9d5b648423ed265171696d9277301ada30d2 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Sun, 11 Jun 2023 23:20:57 +0300 Subject: [PATCH 13/38] getIconPathByStatus tests fixed --- src/utils/getIconPathByStatus.util.spec.ts | 37 ++++++++++++++-------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/utils/getIconPathByStatus.util.spec.ts b/src/utils/getIconPathByStatus.util.spec.ts index abf9f88..31633c7 100644 --- a/src/utils/getIconPathByStatus.util.spec.ts +++ b/src/utils/getIconPathByStatus.util.spec.ts @@ -1,63 +1,74 @@ import { faker } from "@faker-js/faker"; +import WPStatus from "../infrastructure/openProject/wpStatus.enum"; import getIconPathByStatus from "./getIconPathByStatus.util"; describe("getIconPathByStatus test suite", () => { describe("return correct path suite", () => { it("should return correct path of 'Confirmed' icon", () => { - expect(getIconPathByStatus("Confirmed")).toEqual( + expect(getIconPathByStatus(WPStatus.confirmed)).toEqual( "resources/confirmed.png", ); }); it("should return correct path of 'In specification' icon", () => { - expect(getIconPathByStatus("In specification")).toEqual( + expect(getIconPathByStatus(WPStatus.inSpecification)).toEqual( "resources/in_specification.png", ); }); it("should return correct path of 'Specified' icon", () => { - expect(getIconPathByStatus("Specified")).toEqual( + expect(getIconPathByStatus(WPStatus.specified)).toEqual( "resources/specified.png", ); }); it("should return correct path of 'In progress' icon", () => { - expect(getIconPathByStatus("In progress")).toEqual( + expect(getIconPathByStatus(WPStatus.inProgress)).toEqual( "resources/developing.png", ); }); it("should return correct path of 'Developed' icon", () => { - expect(getIconPathByStatus("Developed")).toEqual( + expect(getIconPathByStatus(WPStatus.developed)).toEqual( "resources/developed.png", ); }); it("should return correct path of 'In testing' icon", () => { - expect(getIconPathByStatus("In testing")).toEqual( + expect(getIconPathByStatus(WPStatus.inTesting)).toEqual( "resources/testing.png", ); }); it("should return correct path of 'Tested' icon", () => { - expect(getIconPathByStatus("Tested")).toEqual("resources/tested.png"); + expect(getIconPathByStatus(WPStatus.tested)).toEqual( + "resources/tested.png", + ); }); it("should return correct path of 'Test failed' icon", () => { - expect(getIconPathByStatus("Test failed")).toEqual( + expect(getIconPathByStatus(WPStatus.testFailed)).toEqual( "resources/failed.png", ); }); it("should return correct path of 'On hold' icon", () => { - expect(getIconPathByStatus("On hold")).toEqual("resources/hold.png"); + expect(getIconPathByStatus(WPStatus.onHold)).toEqual( + "resources/hold.png", + ); }); it("should return correct path of 'Closed' icon", () => { - expect(getIconPathByStatus("Closed")).toEqual("resources/closed.png"); + expect(getIconPathByStatus(WPStatus.closed)).toEqual( + "resources/closed.png", + ); }); it("should return correct path of 'Rejected' icon", () => { - expect(getIconPathByStatus("Rejected")).toEqual("resources/rejected.png"); + expect(getIconPathByStatus(WPStatus.rejected)).toEqual( + "resources/rejected.png", + ); }); it("should return undefined for new", () => { - expect(getIconPathByStatus("New")).toEqual(undefined); + expect(getIconPathByStatus(WPStatus.new)).toEqual(undefined); }); }); describe("Others should get undefined", () => { it("should return undefined", () => { - expect(getIconPathByStatus(faker.string.alpha())).toEqual(undefined); + expect(getIconPathByStatus(faker.string.alpha() as WPStatus)).toEqual( + undefined, + ); }); }); }); From 0ab21ec04ca98f6d53c34e6e3e6a47565bb62376 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Sun, 11 Jun 2023 23:22:09 +0300 Subject: [PATCH 14/38] WPs filters implemented --- src/DI/container.ts | 15 ++ src/DI/tokens.ts | 3 + .../project/__mocks__/project.wpsFilter.ts | 13 ++ .../project/project.wpsFilter.interface.ts | 6 + .../filter/project/project.wpsFilter.spec.ts | 139 +++++++++++++++ src/core/filter/project/project.wpsFilter.ts | 31 ++++ .../status/__mocks__/status.wpsFilter.ts | 13 ++ .../status/status.wpsFilter.interface.ts | 7 + .../filter/status/status.wpsFilter.spec.ts | 166 ++++++++++++++++++ src/core/filter/status/status.wpsFilter.ts | 32 ++++ .../filter/text/__mocks__/text.wpsFilter.ts | 13 ++ .../filter/text/text.wpsFilter.interface.ts | 6 + src/core/filter/text/text.wpsFilter.spec.ts | 123 +++++++++++++ src/core/filter/text/text.wpsFilter.ts | 36 ++++ src/core/filter/wpsFilter.interface.ts | 7 + src/core/sorter/wps.sorter.ts | 0 16 files changed, 610 insertions(+) create mode 100644 src/core/filter/project/__mocks__/project.wpsFilter.ts create mode 100644 src/core/filter/project/project.wpsFilter.interface.ts create mode 100644 src/core/filter/project/project.wpsFilter.spec.ts create mode 100644 src/core/filter/project/project.wpsFilter.ts create mode 100644 src/core/filter/status/__mocks__/status.wpsFilter.ts create mode 100644 src/core/filter/status/status.wpsFilter.interface.ts create mode 100644 src/core/filter/status/status.wpsFilter.spec.ts create mode 100644 src/core/filter/status/status.wpsFilter.ts create mode 100644 src/core/filter/text/__mocks__/text.wpsFilter.ts create mode 100644 src/core/filter/text/text.wpsFilter.interface.ts create mode 100644 src/core/filter/text/text.wpsFilter.spec.ts create mode 100644 src/core/filter/text/text.wpsFilter.ts create mode 100644 src/core/filter/wpsFilter.interface.ts create mode 100644 src/core/sorter/wps.sorter.ts diff --git a/src/DI/container.ts b/src/DI/container.ts index 2eb0711..6d39333 100644 --- a/src/DI/container.ts +++ b/src/DI/container.ts @@ -44,6 +44,21 @@ container .to(RefreshWPsCommandImpl) .inSingletonScope(); +container + .bind(TOKENS.textFilter) + .to(TextWPsFilterImpl) + .inSingletonScope(); + +container + .bind(TOKENS.projectFilter) + .to(ProjectWPsFilterImpl) + .inSingletonScope(); + +container + .bind(TOKENS.statusFilter) + .to(StatusWPsFilterImpl) + .inSingletonScope(); + container .bind(TOKENS.wpRepository) .to(WPRepositoryImpl) diff --git a/src/DI/tokens.ts b/src/DI/tokens.ts index a8d5805..2eadd13 100644 --- a/src/DI/tokens.ts +++ b/src/DI/tokens.ts @@ -4,6 +4,9 @@ const TOKENS = { refreshWPsCommand: Symbol.for("RefreshWPsCommand"), authorizeCommand: Symbol.for("AuthorizeClientCommand"), logger: Symbol.for("Logger"), + textFilter: Symbol.for("TextWPsFilter"), + projectFilter: Symbol.for("ProjectWPsFilter"), + statusFilter: Symbol.for("StatusWPsFilter"), wpRepository: Symbol.for("WPRepository"), projectRepository: Symbol.for("ProjectRepository"), }; diff --git a/src/core/filter/project/__mocks__/project.wpsFilter.ts b/src/core/filter/project/__mocks__/project.wpsFilter.ts new file mode 100644 index 0000000..8e7cfa3 --- /dev/null +++ b/src/core/filter/project/__mocks__/project.wpsFilter.ts @@ -0,0 +1,13 @@ +import { injectable } from "inversify"; +import ProjectWPsFilter from "../project.wpsFilter.interface"; + +@injectable() +export default class ProjectWPsFilterImpl implements ProjectWPsFilter { + filter = jest.fn(); + + onFilterUpdated = jest.fn(); + + getProjectFilter = jest.fn(); + + setProjectFilter = jest.fn(); +} diff --git a/src/core/filter/project/project.wpsFilter.interface.ts b/src/core/filter/project/project.wpsFilter.interface.ts new file mode 100644 index 0000000..df476be --- /dev/null +++ b/src/core/filter/project/project.wpsFilter.interface.ts @@ -0,0 +1,6 @@ +import WPsFilter from "../wpsFilter.interface"; + +export default interface ProjectWPsFilter extends WPsFilter { + getProjectFilter(): number[] | undefined; + setProjectFilter(projectIds: number[]): void; +} diff --git a/src/core/filter/project/project.wpsFilter.spec.ts b/src/core/filter/project/project.wpsFilter.spec.ts new file mode 100644 index 0000000..d6ac6f8 --- /dev/null +++ b/src/core/filter/project/project.wpsFilter.spec.ts @@ -0,0 +1,139 @@ +import { faker } from "@faker-js/faker"; +import { WP } from "op-client"; +import container from "../../../DI/container"; +import TOKENS from "../../../DI/tokens"; +import ProjectWPsFilterImpl from "./project.wpsFilter"; + +describe("WPs project filter test suite", () => { + let filter: ProjectWPsFilterImpl; + + beforeAll(() => { + filter = container.get(TOKENS.projectFilter); + }); + + describe("Filter", () => { + const helloWorldWP = { + subject: "Hello world!", + author: { + name: "goodhumored", + firstName: "Kirill", + lastName: "Nekrasov", + login: "goodhumored", + }, + body: { + description: { + raw: "Lorem ipsum dolor amet...", + }, + }, + project: { + id: 1, + }, + } as WP; + const easterEggWP = { + subject: "Lorem ipsum!", + author: { + name: "goodhumored", + firstName: "Kirill", + lastName: "Nekrasov", + login: "goodhumored", + }, + body: { + description: { + raw: "Easter egg!", + }, + }, + project: { + id: 2, + }, + } as WP; + const dannyWP = { + subject: "Title", + author: { + name: "dannyweiss", + firstName: "Danila", + lastName: "Smolyakov", + login: "dannyweiss", + }, + body: { + description: { + raw: "Lorem ipsum", + }, + }, + project: { + id: 2, + }, + } as WP; + const bugWP = { + subject: "Bug!", + author: { + name: "Svante Kaiser", + firstName: "Sviat", + lastName: "Tsarev", + login: "svante_kaiser", + }, + body: { + description: { + raw: faker.lorem.text(), + }, + }, + project: { + id: 0, + }, + } as WP; + + const wps: WP[] = [helloWorldWP, easterEggWP, dannyWP, bugWP]; + + it("should return all wps if projectFilter is undefined", () => { + const result = filter.filter(wps); + expect(result).toEqual(wps); + }); + it("should return all wps if projectFilter contains all ids", () => { + filter.setProjectFilter([0, 1, 2]); + const result = filter.filter(wps); + expect(result).toEqual(wps); + }); + it("should return wps from project 1", () => { + filter.setProjectFilter([1]); + const result = filter.filter(wps); + expect(result).toEqual([helloWorldWP]); + }); + it("should return wps from project 2", () => { + filter.setProjectFilter([2]); + const result = filter.filter(wps); + expect(result).toEqual([easterEggWP, dannyWP]); + }); + it("should return wps from projects 1 and 2", () => { + filter.setProjectFilter([1, 2]); + const result = filter.filter(wps); + expect(result).toEqual( + expect.arrayContaining([helloWorldWP, dannyWP, easterEggWP]), + ); + }); + it("should return empty array", () => { + filter.setProjectFilter([3]); + const result = filter.filter(wps); + expect(result).toEqual([]); + }); + }); + + describe("setProjectFilter", () => { + it("should set project filter", () => { + const projectIds = faker.helpers.uniqueArray(faker.number.int, 5); + filter.setProjectFilter(projectIds); + expect(filter.getProjectFilter()).toEqual(projectIds); + }); + it("should emit filter updated event", () => { + const projectIds = faker.helpers.uniqueArray(faker.number.int, 5); + filter.setProjectFilter(projectIds); + expect(filter["_onFilterUpdated"].fire).toHaveBeenCalled(); + }); + }); + + describe("onFilterUpdated", () => { + it("should bind listener", () => { + const func = jest.fn(); + filter.onFilterUpdated(func); + expect(filter["_onFilterUpdated"].event).toHaveBeenLastCalledWith(func); + }); + }); +}); diff --git a/src/core/filter/project/project.wpsFilter.ts b/src/core/filter/project/project.wpsFilter.ts new file mode 100644 index 0000000..9870f62 --- /dev/null +++ b/src/core/filter/project/project.wpsFilter.ts @@ -0,0 +1,31 @@ +import { injectable } from "inversify"; +import { WP } from "op-client"; +import * as vscode from "vscode"; +import ProjectWPsFilter from "./project.wpsFilter.interface"; + +@injectable() +export default class ProjectWPsFilterImpl implements ProjectWPsFilter { + private _projectIds?: number[] = undefined; + + private _onFilterUpdated: vscode.EventEmitter = + new vscode.EventEmitter(); + + filter(wps: WP[]): WP[] { + return wps.filter( + (wp) => + this._projectIds === undefined || + this._projectIds.includes(wp.project.id), + ); + } + + onFilterUpdated = this._onFilterUpdated.event; + + getProjectFilter(): number[] | undefined { + return this._projectIds; + } + + setProjectFilter(projectIds: number[]): void { + this._projectIds = projectIds; + this._onFilterUpdated.fire(); + } +} diff --git a/src/core/filter/status/__mocks__/status.wpsFilter.ts b/src/core/filter/status/__mocks__/status.wpsFilter.ts new file mode 100644 index 0000000..84c82b0 --- /dev/null +++ b/src/core/filter/status/__mocks__/status.wpsFilter.ts @@ -0,0 +1,13 @@ +import { injectable } from "inversify"; +import StatusWPsFilter from "../status.wpsFilter.interface"; + +@injectable() +export default class StatusWPsFilterImpl implements StatusWPsFilter { + filter = jest.fn(); + + onFilterUpdated = jest.fn(); + + getStatusFilter = jest.fn(); + + setStatusFilter = jest.fn(); +} diff --git a/src/core/filter/status/status.wpsFilter.interface.ts b/src/core/filter/status/status.wpsFilter.interface.ts new file mode 100644 index 0000000..5b56018 --- /dev/null +++ b/src/core/filter/status/status.wpsFilter.interface.ts @@ -0,0 +1,7 @@ +import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; +import WPsFilter from "../wpsFilter.interface"; + +export default interface StatusWPsFilter extends WPsFilter { + getStatusFilter(): WPStatus[] | undefined; + setStatusFilter(wpTypes: WPStatus[]): void; +} diff --git a/src/core/filter/status/status.wpsFilter.spec.ts b/src/core/filter/status/status.wpsFilter.spec.ts new file mode 100644 index 0000000..c33899c --- /dev/null +++ b/src/core/filter/status/status.wpsFilter.spec.ts @@ -0,0 +1,166 @@ +import { faker } from "@faker-js/faker"; +import { WP } from "op-client"; +import container from "../../../DI/container"; +import TOKENS from "../../../DI/tokens"; +import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; +import StatusWPsFilterImpl from "./status.wpsFilter"; + +describe("WPs status filter test suite", () => { + let filter: StatusWPsFilterImpl; + + beforeAll(() => { + filter = container.get(TOKENS.statusFilter); + }); + + describe("Filter", () => { + const helloWorldWP = { + subject: "Hello world!", + author: { + name: "goodhumored", + firstName: "Kirill", + lastName: "Nekrasov", + login: "goodhumored", + }, + status: { + self: { + title: WPStatus.closed, + }, + }, + body: { + description: { + raw: "Lorem ipsum dolor amet...", + }, + }, + project: { + id: 1, + }, + } as WP; + const easterEggWP = { + subject: "Lorem ipsum!", + author: { + name: "goodhumored", + firstName: "Kirill", + lastName: "Nekrasov", + login: "goodhumored", + }, + body: { + description: { + raw: "Easter egg!", + }, + }, + project: { + id: 2, + }, + status: { + self: { + title: WPStatus.new, + }, + }, + } as WP; + const dannyWP = { + subject: "Title", + author: { + name: "dannyweiss", + firstName: "Danila", + lastName: "Smolyakov", + login: "dannyweiss", + }, + body: { + description: { + raw: "Lorem ipsum", + }, + }, + project: { + id: 2, + }, + status: { + self: { + title: WPStatus.new, + }, + }, + } as WP; + const bugWP = { + subject: "Bug!", + author: { + name: "Svante Kaiser", + firstName: "Sviat", + lastName: "Tsarev", + login: "svante_kaiser", + }, + body: { + description: { + raw: faker.lorem.text(), + }, + }, + project: { + id: 0, + }, + status: { + self: { + title: WPStatus.developed, + }, + }, + } as WP; + + const wps: WP[] = [helloWorldWP, easterEggWP, dannyWP, bugWP]; + + it("should return all wps if projectFilter is undefined", () => { + const result = filter.filter(wps); + expect(result).toEqual(wps); + }); + it("should return all wps if projectFilter contains all ids", () => { + filter.setStatusFilter(Object.values(WPStatus)); + const result = filter.filter(wps); + expect(result).toEqual(wps); + }); + it("should return wps with status closed", () => { + filter.setStatusFilter([WPStatus.closed]); + const result = filter.filter(wps); + expect(result).toEqual([helloWorldWP]); + }); + it("should return wps with status new", () => { + filter.setStatusFilter([WPStatus.new]); + const result = filter.filter(wps); + expect(result).toEqual([easterEggWP, dannyWP]); + }); + it("should return wps with statuses new and closed", () => { + filter.setStatusFilter([WPStatus.new, WPStatus.closed]); + const result = filter.filter(wps); + expect(result).toEqual( + expect.arrayContaining([helloWorldWP, dannyWP, easterEggWP]), + ); + }); + it("should return empty array", () => { + filter.setStatusFilter([WPStatus.onHold]); + const result = filter.filter(wps); + expect(result).toEqual([]); + }); + }); + + describe("setProjectFilter", () => { + it("should set project filter", () => { + const projectIds = faker.helpers.uniqueArray( + faker.string.alpha, + 5, + ) as WPStatus[]; + filter.setStatusFilter(projectIds); + expect(filter.getStatusFilter()).toEqual(projectIds); + }); + it("should emit filter updated event", () => { + const projectIds = faker.helpers.uniqueArray( + faker.string.alpha, + 5, + ) as WPStatus[]; + filter.setStatusFilter(projectIds); + expect(filter["_onFilterUpdated"].fire).toHaveBeenCalled(); + }); + }); + + describe("onFilterUpdated", () => { + it("should bind listener", () => { + const func = jest.fn(); + filter.onFilterUpdated(func); + expect(filter["_onFilterUpdated"].event).toHaveBeenLastCalledWith(func); + }); + }); +}); diff --git a/src/core/filter/status/status.wpsFilter.ts b/src/core/filter/status/status.wpsFilter.ts new file mode 100644 index 0000000..f742307 --- /dev/null +++ b/src/core/filter/status/status.wpsFilter.ts @@ -0,0 +1,32 @@ +import { injectable } from "inversify"; +import { WP } from "op-client"; +import * as vscode from "vscode"; +import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; +import StatusWPsFilter from "./status.wpsFilter.interface"; + +@injectable() +export default class StatusWPsFilterImpl implements StatusWPsFilter { + private _wpTypesFilter?: WPStatus[] = undefined; + + private _onFilterUpdated: vscode.EventEmitter = + new vscode.EventEmitter(); + + filter(wps: WP[]): WP[] { + return wps.filter( + (wp) => + this._wpTypesFilter === undefined || + this._wpTypesFilter.includes(wp.status.self.title as WPStatus), + ); + } + + onFilterUpdated = this._onFilterUpdated.event; + + setStatusFilter(projectIds: WPStatus[]): void { + this._wpTypesFilter = projectIds; + this._onFilterUpdated.fire(); + } + + getStatusFilter(): WPStatus[] | undefined { + return this._wpTypesFilter; + } +} diff --git a/src/core/filter/text/__mocks__/text.wpsFilter.ts b/src/core/filter/text/__mocks__/text.wpsFilter.ts new file mode 100644 index 0000000..98e7d84 --- /dev/null +++ b/src/core/filter/text/__mocks__/text.wpsFilter.ts @@ -0,0 +1,13 @@ +import { injectable } from "inversify"; +import TextWPsFilter from "../text.wpsFilter.interface"; + +@injectable() +export default class TextWPsFilterImpl implements TextWPsFilter { + filter = jest.fn(); + + onFilterUpdated = jest.fn(); + + getTextFilter = jest.fn(); + + setTextFilter = jest.fn(); +} diff --git a/src/core/filter/text/text.wpsFilter.interface.ts b/src/core/filter/text/text.wpsFilter.interface.ts new file mode 100644 index 0000000..dfdf0d4 --- /dev/null +++ b/src/core/filter/text/text.wpsFilter.interface.ts @@ -0,0 +1,6 @@ +import WPsFilter from "../wpsFilter.interface"; + +export default interface TextWPsFilter extends WPsFilter { + getTextFilter(): string; + setTextFilter(filter: string): void; +} diff --git a/src/core/filter/text/text.wpsFilter.spec.ts b/src/core/filter/text/text.wpsFilter.spec.ts new file mode 100644 index 0000000..8e818c2 --- /dev/null +++ b/src/core/filter/text/text.wpsFilter.spec.ts @@ -0,0 +1,123 @@ +import { faker } from "@faker-js/faker"; +import { WP } from "op-client"; +import container from "../../../DI/container"; +import TOKENS from "../../../DI/tokens"; +import TextWPsFilterImpl from "./text.wpsFilter"; + +describe("WPs text filter test suite", () => { + let filter: TextWPsFilterImpl; + + beforeAll(() => { + filter = container.get(TOKENS.textFilter); + }); + + describe("Filter", () => { + const helloWorldWP = { + subject: "Hello world!", + author: { + name: "goodhumored", + firstName: "Kirill", + lastName: "Nekrasov", + login: "goodhumored", + }, + body: { + description: { + raw: "Lorem ipsum dolor amet...", + }, + }, + } as WP; + const easterEggWP = { + subject: "Lorem ipsum!", + author: { + name: "goodhumored", + firstName: "Kirill", + lastName: "Nekrasov", + login: "goodhumored", + }, + body: { + description: { + raw: "Easter egg!", + }, + }, + } as WP; + const dannyWP = { + subject: "Title", + author: { + name: "dannyweiss", + firstName: "Danila", + lastName: "Smolyakov", + login: "dannyweiss", + }, + body: { + description: { + raw: "Lorem ipsum", + }, + }, + } as WP; + const bugWP = { + subject: "Bug!", + author: { + name: "Svante Kaiser", + firstName: "Sviat", + lastName: "Tsarev", + login: "svante_kaiser", + }, + body: {}, + } as WP; + + const wps: WP[] = [helloWorldWP, easterEggWP, dannyWP, bugWP]; + + it("should return all wps if filter is not set", () => { + const result = filter.filter(wps); + expect(result).toEqual(wps); + }); + it("should return all wps", () => { + filter.setTextFilter(""); + const result = filter.filter(wps); + expect(result).toEqual(wps); + }); + it("should return wps with hello world", () => { + filter.setTextFilter("Hello world!"); + const result = filter.filter(wps); + expect(result).toEqual([helloWorldWP]); + }); + it("should return wps by goodhumored", () => { + filter.setTextFilter("goodhumored"); + const result = filter.filter(wps); + expect(result).toEqual([helloWorldWP, easterEggWP]); + }); + it("should return lorem ipsum wps", () => { + filter.setTextFilter("lorem ipsum"); + const result = filter.filter(wps); + expect(result).toEqual( + expect.arrayContaining([helloWorldWP, dannyWP, easterEggWP]), + ); + }); + it("should return empty array", () => { + filter.setTextFilter("aaaaaaaaaaaaaaaaaa"); + const result = filter.filter(wps); + expect(result).toEqual([]); + }); + }); + + describe("SetNameFilter", () => { + it("should set name filter", () => { + const nameFilter = faker.string.alpha(); + filter.setTextFilter(nameFilter); + expect(filter.getTextFilter()).toEqual(nameFilter); + }); + it("should emit filter updated event", () => { + const nameFilter = faker.string.alpha(); + filter.setTextFilter(nameFilter); + expect(filter["_onFilterUpdated"].fire).toHaveBeenCalled(); + }); + }); + + describe("onFilterUpdated", () => { + it("should bind listener", () => { + const func = jest.fn(); + filter.onFilterUpdated(func); + expect(filter["_onFilterUpdated"].event).toHaveBeenLastCalledWith(func); + }); + }); +}); diff --git a/src/core/filter/text/text.wpsFilter.ts b/src/core/filter/text/text.wpsFilter.ts new file mode 100644 index 0000000..7468bea --- /dev/null +++ b/src/core/filter/text/text.wpsFilter.ts @@ -0,0 +1,36 @@ +import { injectable } from "inversify"; +import { WP } from "op-client"; +import * as vscode from "vscode"; +import TextWPsFilter from "./text.wpsFilter.interface"; + +@injectable() +export default class TextWPsFilterImpl implements TextWPsFilter { + private _textFilter = ""; + + private _onFilterUpdated: vscode.EventEmitter = + new vscode.EventEmitter(); + + filter(wps: WP[]): WP[] { + const textFilterLower = this._textFilter.toLowerCase(); + return wps.filter( + (wp) => + wp.author.login.toLowerCase().includes(textFilterLower) || + wp.author.firstName.toLowerCase().includes(textFilterLower) || + wp.author.lastName.toLowerCase().includes(textFilterLower) || + wp.author.name.toLowerCase().includes(textFilterLower) || + wp.subject.toLowerCase().includes(textFilterLower) || + wp.body.description?.raw.toLowerCase().includes(textFilterLower), + ); + } + + getTextFilter(): string { + return this._textFilter; + } + + setTextFilter(nameFilter: string): void { + this._textFilter = nameFilter; + this._onFilterUpdated.fire(); + } + + onFilterUpdated = this._onFilterUpdated.event; +} diff --git a/src/core/filter/wpsFilter.interface.ts b/src/core/filter/wpsFilter.interface.ts new file mode 100644 index 0000000..52fbdc4 --- /dev/null +++ b/src/core/filter/wpsFilter.interface.ts @@ -0,0 +1,7 @@ +import { WP } from "op-client"; +import * as vscode from "vscode"; + +export default interface WPsFilter { + filter(wps: WP[]): WP[]; + onFilterUpdated: vscode.Event; +} diff --git a/src/core/sorter/wps.sorter.ts b/src/core/sorter/wps.sorter.ts new file mode 100644 index 0000000..e69de29 From 2e76907660f39fc84c34051b4fa06fdefc21ab17 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Tue, 13 Jun 2023 00:08:33 +0300 Subject: [PATCH 15/38] setup filters command --- package.json | 14 + resources/filter.png | Bin 0 -> 349 bytes resources/filter_light.png | Bin 0 -> 545 bytes src/DI/container.ts | 14 + src/DI/tokens.ts | 27 +- .../authorize/authorizeClient.command.spec.ts | 32 ++ .../authorize/authorizeClient.command.ts | 10 +- .../filter/__mocks__/filterWPs.command.ts | 7 + .../project.quickPickItem.spec.ts | 74 +++++ .../quickPickItems/project.quickPickItem.ts | 19 ++ .../wpStatus.quickPickItem.spec.ts | 42 +++ .../quickPickItems/wpStatus.quickPickItem.ts | 16 + .../filter/setupFilters.command.interface.ts | 3 + .../filter/setupFilters.command.spec.ts | 285 ++++++++++++++++++ .../commands/filter/setupFilters.command.ts | 102 +++++++ .../refresh/refreshWPs.command.spec.ts | 5 +- .../commands/refresh/refreshWPs.command.ts | 2 +- .../openProject.treeDataProvider.spec.ts | 44 ++- .../views/openProject.treeDataProvider.ts | 4 +- .../views/treeItems/wp.treeItem.ts | 2 +- .../__mocks__/composite.wpsFilter.ts | 11 + .../composite.wpsFilter.interface.ts | 5 + .../composite/composite.wpsFilter.spec.ts | 83 +++++ .../filter/composite/composite.wpsFilter.ts | 28 ++ src/core/filter/text/text.wpsFilter.spec.ts | 30 +- src/core/filter/text/text.wpsFilter.ts | 5 +- src/extension.spec.ts | 39 ++- src/extension.ts | 27 ++ src/infrastructure/logger/__mocks__/logger.ts | 2 +- .../workPackage/__mocks__/wp.repository.ts | 2 +- .../workPackage/wp.repository.interface.ts | 2 +- .../workPackage/wp.repository.spec.ts | 20 ++ .../workPackage/wp.repository.ts | 22 +- 33 files changed, 916 insertions(+), 62 deletions(-) create mode 100644 resources/filter.png create mode 100644 resources/filter_light.png create mode 100644 src/application/commands/filter/__mocks__/filterWPs.command.ts create mode 100644 src/application/commands/filter/quickPickItems/project.quickPickItem.spec.ts create mode 100644 src/application/commands/filter/quickPickItems/project.quickPickItem.ts create mode 100644 src/application/commands/filter/quickPickItems/wpStatus.quickPickItem.spec.ts create mode 100644 src/application/commands/filter/quickPickItems/wpStatus.quickPickItem.ts create mode 100644 src/application/commands/filter/setupFilters.command.interface.ts create mode 100644 src/application/commands/filter/setupFilters.command.spec.ts create mode 100644 src/application/commands/filter/setupFilters.command.ts create mode 100644 src/core/filter/composite/__mocks__/composite.wpsFilter.ts create mode 100644 src/core/filter/composite/composite.wpsFilter.interface.ts create mode 100644 src/core/filter/composite/composite.wpsFilter.spec.ts create mode 100644 src/core/filter/composite/composite.wpsFilter.ts diff --git a/package.json b/package.json index f81c148..6dfd235 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,15 @@ "dark": "resources/refresh_light.png", "light": "resources/refresh.png" } + }, + { + "command": "openproject.setupFilter", + "title": "Filter work packages", + "shortTitle": "Filter", + "icon": { + "dark": "resources/filter_light.png", + "light": "resources/filter.png" + } } ], "menus": { @@ -37,6 +46,11 @@ "command": "openproject.refresh", "group": "navigation", "when": "view == openproject-workspaces" + }, + { + "command": "openproject.setupFilter", + "group": "navigation", + "when": "view == openproject-workspaces" } ] }, diff --git a/resources/filter.png b/resources/filter.png new file mode 100644 index 0000000000000000000000000000000000000000..f944228c25cb05e25ad42047e9f2a1aa1ec6b2c9 GIT binary patch literal 349 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#X!0Ygc<#+)i{6*$r9Iy zlHmNblJdl&REF~Ma=pyF?Be9af>gcyqV(DCY@~pSUU|AWhFJ7&y}Z}U*-_x=$M{8@ zE;=m@%Qgf~Xjt3puP{;MrAy7MC0`Za1pQ>Vb%S%`#;Ghv4!La*l|GnJsQ!fQojcEu z_QZ!9p1*m+=j|je?DH`=D7dGPOZ3eH*6llvZBM#Z&?fxGS%1=Yub#|V4_}6T6v>VK zqxitaO7?sU!kK|SAUM*A!HhJs?y;H>$(rd>usJLWd!<~!PC{xWt~$(69BIelm!3) literal 0 HcmV?d00001 diff --git a/resources/filter_light.png b/resources/filter_light.png new file mode 100644 index 0000000000000000000000000000000000000000..a38735e65a8ebc63f0bcd93b78f318b98c0d518e GIT binary patch literal 545 zcmV++0^a?JP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0063uBQgL0010qN zS#tmY0AK(B0AK*{YeLTe000McNliru=LQTB8!ht=l_~%L0ftFLK~y-)wUe<+LSYoe z@Au*fij#skv?PdNqXaGm4uu8b)^m1f3TX(Mf+PrXjOMn6`UirdB?^UT?pgAXmY^vp zqj$dNcUlT6)FkM@-R}8u@8Mn%5eYWt=XJq#@nQ@>4y_ksbis9l)nZXZMA0M?`rPx> z-RUWJ4-a|O>&XzQsj)FtbsVN=XYH9(O8oNTB0!R{n2abg9tX%^XJNr&{pLox#srh) z@~D7d(sAUe*9+3EmWYTTQmdf@eC{>Wyjm=x10YhXeHlq9o!Z~WWw)zW^7%n#AeY0* z^Rspi4oIcG?`3Qo%g4vqeth)f>GV(Y+w{Z))*c?PcXmb-3JnM+5<#cg)K0rilSq6G zO&45O%k?!)H2O=z$)v7$9$JkC84i=-Fj|dw50v=KJU!5WDqIS%$cW00000NkvXXu0mjfeR1j- literal 0 HcmV?d00001 diff --git a/src/DI/container.ts b/src/DI/container.ts index 6d39333..5433bab 100644 --- a/src/DI/container.ts +++ b/src/DI/container.ts @@ -2,10 +2,14 @@ import { Container } from "inversify"; import "reflect-metadata"; import AuthorizeClientCommandImpl from "../application/commands/authorize/authorizeClient.command"; import AuthorizeClientCommand from "../application/commands/authorize/authorizeClientCommand.interface"; +import SetupFiltersCommandImpl from "../application/commands/filter/setupFilters.command"; +import SetupFiltersCommand from "../application/commands/filter/setupFilters.command.interface"; import RefreshWPsCommandImpl from "../application/commands/refresh/refreshWPs.command"; import RefreshWPsCommand from "../application/commands/refresh/refreshWPsCommand.interface"; import OpenProjectTreeDataProviderImpl from "../application/views/openProject.treeDataProvider"; import OpenProjectTreeDataProvider from "../application/views/openProjectTreeDataProvider.interface"; +import CompositeWPsFilterImpl from "../core/filter/composite/composite.wpsFilter"; +import CompositeWPsFilter from "../core/filter/composite/composite.wpsFilter.interface"; import ProjectWPsFilterImpl from "../core/filter/project/project.wpsFilter"; import ProjectWPsFilter from "../core/filter/project/project.wpsFilter.interface"; import StatusWPsFilterImpl from "../core/filter/status/status.wpsFilter"; @@ -59,6 +63,11 @@ container .to(StatusWPsFilterImpl) .inSingletonScope(); +container + .bind(TOKENS.compositeFilter) + .to(CompositeWPsFilterImpl) + .inSingletonScope(); + container .bind(TOKENS.wpRepository) .to(WPRepositoryImpl) @@ -69,6 +78,11 @@ container .to(ProjectRepositoryImpl) .inSingletonScope(); +container + .bind(TOKENS.setupFiltersCommand) + .to(SetupFiltersCommandImpl) + .inSingletonScope(); + container.bind(TOKENS.logger).to(ConsoleLogger).inSingletonScope(); export default container; diff --git a/src/DI/tokens.ts b/src/DI/tokens.ts index 2eadd13..9052616 100644 --- a/src/DI/tokens.ts +++ b/src/DI/tokens.ts @@ -1,14 +1,29 @@ -const TOKENS = { - opTreeView: Symbol.for("OpenProjectTreeDataProvider"), - opClient: Symbol.for("OpenProjectClient"), +const repositoryTokens = { + wpRepository: Symbol.for("WPRepository"), + projectRepository: Symbol.for("ProjectRepository"), +}; + +const comandTokens = { refreshWPsCommand: Symbol.for("RefreshWPsCommand"), authorizeCommand: Symbol.for("AuthorizeClientCommand"), - logger: Symbol.for("Logger"), + setupFiltersCommand: Symbol.for("FilterWPsCommand"), +}; + +const filterTokens = { + filter: Symbol.for("filter"), textFilter: Symbol.for("TextWPsFilter"), projectFilter: Symbol.for("ProjectWPsFilter"), statusFilter: Symbol.for("StatusWPsFilter"), - wpRepository: Symbol.for("WPRepository"), - projectRepository: Symbol.for("ProjectRepository"), + compositeFilter: Symbol.for("CompositeWPsFilter"), +}; + +const TOKENS = { + opTreeView: Symbol.for("OpenProjectTreeDataProvider"), + opClient: Symbol.for("OpenProjectClient"), + logger: Symbol.for("Logger"), + ...comandTokens, + ...repositoryTokens, + ...filterTokens, }; export default TOKENS; diff --git a/src/application/commands/authorize/authorizeClient.command.spec.ts b/src/application/commands/authorize/authorizeClient.command.spec.ts index 3984de5..4bcb061 100644 --- a/src/application/commands/authorize/authorizeClient.command.spec.ts +++ b/src/application/commands/authorize/authorizeClient.command.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-new */ jest.mock("../../../infrastructure/openProject/openProject.client"); import { faker } from "@faker-js/faker"; @@ -9,6 +10,7 @@ import OpenProjectClient from "../../../infrastructure/openProject/openProject.c import UserNotFound from "../../../infrastructure/openProject/userNotFound.exception"; import VSCodeConfigMock from "../../../test/config.mock"; import AuthorizeClientCommandImpl from "./authorizeClient.command"; +import ConsoleLogger from "../../../infrastructure/logger/logger"; describe("Authorize client command test suite", () => { let command: AuthorizeClientCommandImpl; @@ -20,6 +22,36 @@ describe("Authorize client command test suite", () => { client = container.get(TOKENS.opClient); }); + describe("Constructor", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it("should showMessage on client onInit", () => { + const spy = jest.spyOn(client, "onInit"); + + const newCommand = new AuthorizeClientCommandImpl( + client, + new ConsoleLogger(), + ); + + expect(spy.mock.calls).toEqual( + expect.arrayContaining([[newCommand.showMessage, newCommand]]), + ); + }); + it("should setAuthedTrue on client onInit", () => { + const spy = jest.spyOn(client, "onInit"); + + const newCommand = new AuthorizeClientCommandImpl( + client, + new ConsoleLogger(), + ); + + expect(spy.mock.calls).toEqual( + expect.arrayContaining([[newCommand.setAuthedTrue, newCommand]]), + ); + }); + }); + describe("authorizeClient", () => { it("should call init with correct data", () => { const config = new VSCodeConfigMock({ diff --git a/src/application/commands/authorize/authorizeClient.command.ts b/src/application/commands/authorize/authorizeClient.command.ts index c8d0a72..07ab74a 100644 --- a/src/application/commands/authorize/authorizeClient.command.ts +++ b/src/application/commands/authorize/authorizeClient.command.ts @@ -13,12 +13,10 @@ export default class AuthorizeClientCommandImpl @inject(TOKENS.opClient) private readonly _client: OpenProjectClient, @inject(TOKENS.logger) - private readonly _logger?: Logger, + private readonly _logger: Logger, ) { - this._client.onInit(() => { - this.showMessage(); - this.setAuthedTrue(); - }); + this._client.onInit(this.showMessage, this); + this._client.onInit(this.setAuthedTrue, this); } authorizeClient() { @@ -39,7 +37,7 @@ export default class AuthorizeClientCommandImpl ); }) .catch((err) => { - this._logger?.error("Failed connecting to OpenProject: ", err); + this._logger.error("Failed connecting to OpenProject: ", err); vscode.window.showErrorMessage("Failed connecting to OpenProject"); }); } diff --git a/src/application/commands/filter/__mocks__/filterWPs.command.ts b/src/application/commands/filter/__mocks__/filterWPs.command.ts new file mode 100644 index 0000000..c48fa4c --- /dev/null +++ b/src/application/commands/filter/__mocks__/filterWPs.command.ts @@ -0,0 +1,7 @@ +import { injectable } from "inversify"; +import SetupFiltersCommand from "../setupFilters.command.interface"; + +@injectable() +export default class FilterWPsCommandImpl implements SetupFiltersCommand { + setupFilters = jest.fn(); +} diff --git a/src/application/commands/filter/quickPickItems/project.quickPickItem.spec.ts b/src/application/commands/filter/quickPickItems/project.quickPickItem.spec.ts new file mode 100644 index 0000000..cd1e80c --- /dev/null +++ b/src/application/commands/filter/quickPickItems/project.quickPickItem.spec.ts @@ -0,0 +1,74 @@ +import { Project } from "op-client"; +import ProjectQuickPickItem from "./project.quickPickItem"; +import { faker } from "@faker-js/faker"; + +describe("projectId quick pick item test suite", () => { + it("should have project's name as label", () => { + const project = new Project(1); + project.body.name = "dipal"; + + const item = new ProjectQuickPickItem(project); + + expect(item.label).toEqual("dipal"); + }); + it("should have default name as label if project has no name", () => { + const project = new Project(1); + project.body.name = undefined; + + const item = new ProjectQuickPickItem(project); + + expect(item.label).toEqual("Project #1"); + }); + it("should have project's id as payload", () => { + const project = new Project(1); + + const item = new ProjectQuickPickItem(project); + + expect(item.projectId).toEqual(1); + }); + it("should have project's description as description", () => { + const project = new Project(1); + project.body.description = { raw: faker.lorem.text() } as any; + + const item = new ProjectQuickPickItem(project); + + expect(item.description).toEqual(project.body.description?.raw); + }); + it("should have undefined description if project has no description", () => { + const project = new Project(1); + + const item = new ProjectQuickPickItem(project); + + expect(item.description).toBeUndefined(); + }); + it("should have undefined description if project has no description", () => { + const project = new Project(1); + + const item = new ProjectQuickPickItem(project); + + expect(item.description).toBeUndefined(); + }); + it("should have picked = true", () => { + const project = new Project(1); + const picked = true; + + const item = new ProjectQuickPickItem(project, picked); + + expect(item.picked).toEqual(picked); + }); + it("should have picked = false", () => { + const project = new Project(1); + const picked = false; + + const item = new ProjectQuickPickItem(project, picked); + + expect(item.picked).toEqual(picked); + }); + it("should have picked = false if no picked passed", () => { + const project = new Project(1); + + const item = new ProjectQuickPickItem(project); + + expect(item.picked).toEqual(false); + }); +}); diff --git a/src/application/commands/filter/quickPickItems/project.quickPickItem.ts b/src/application/commands/filter/quickPickItems/project.quickPickItem.ts new file mode 100644 index 0000000..1a8b49a --- /dev/null +++ b/src/application/commands/filter/quickPickItems/project.quickPickItem.ts @@ -0,0 +1,19 @@ +import { Project } from "op-client"; +import { QuickPickItem } from "vscode"; + +export default class ProjectQuickPickItem implements QuickPickItem { + label: string; + + description?: string | undefined; + + picked: boolean; + + projectId: number; + + constructor(project: Project, picked = false) { + this.projectId = project.id; + this.label = project.body.name ?? `Project #${project.id}`; + this.description = project.body.description?.raw; + this.picked = picked; + } +} diff --git a/src/application/commands/filter/quickPickItems/wpStatus.quickPickItem.spec.ts b/src/application/commands/filter/quickPickItems/wpStatus.quickPickItem.spec.ts new file mode 100644 index 0000000..0285a0f --- /dev/null +++ b/src/application/commands/filter/quickPickItems/wpStatus.quickPickItem.spec.ts @@ -0,0 +1,42 @@ +import WPStatus from "../../../../infrastructure/openProject/wpStatus.enum"; +import WPStatusQuickPickItem from "./wpStatus.quickPickItem"; + +describe("wpStatus quick pick item test suite", () => { + it("should have status as label", () => { + const status = WPStatus.closed; + + const item = new WPStatusQuickPickItem(status); + + expect(item.label).toEqual(status); + }); + it("should have status as status", () => { + const status = WPStatus.closed; + + const item = new WPStatusQuickPickItem(status); + + expect(item.status).toEqual(status); + }); + it("should have picked = true", () => { + const status = WPStatus.closed; + const picked = true; + + const item = new WPStatusQuickPickItem(status, picked); + + expect(item.picked).toEqual(picked); + }); + it("should have picked = false", () => { + const status = WPStatus.closed; + const picked = false; + + const item = new WPStatusQuickPickItem(status, picked); + + expect(item.picked).toEqual(picked); + }); + it("should have picked = false if no picked passed", () => { + const status = WPStatus.closed; + + const item = new WPStatusQuickPickItem(status); + + expect(item.picked).toEqual(false); + }); +}); diff --git a/src/application/commands/filter/quickPickItems/wpStatus.quickPickItem.ts b/src/application/commands/filter/quickPickItems/wpStatus.quickPickItem.ts new file mode 100644 index 0000000..353d958 --- /dev/null +++ b/src/application/commands/filter/quickPickItems/wpStatus.quickPickItem.ts @@ -0,0 +1,16 @@ +import { QuickPickItem } from "vscode"; +import WPStatus from "../../../../infrastructure/openProject/wpStatus.enum"; + +export default class WPStatusQuickPickItem implements QuickPickItem { + label: string; + + picked: boolean; + + status: WPStatus; + + constructor(status: WPStatus, picked = false) { + this.status = status; + this.label = status; + this.picked = picked; + } +} diff --git a/src/application/commands/filter/setupFilters.command.interface.ts b/src/application/commands/filter/setupFilters.command.interface.ts new file mode 100644 index 0000000..7a2320b --- /dev/null +++ b/src/application/commands/filter/setupFilters.command.interface.ts @@ -0,0 +1,3 @@ +export default interface SetupFiltersCommand { + setupFilters(): Promise; +} diff --git a/src/application/commands/filter/setupFilters.command.spec.ts b/src/application/commands/filter/setupFilters.command.spec.ts new file mode 100644 index 0000000..5e07121 --- /dev/null +++ b/src/application/commands/filter/setupFilters.command.spec.ts @@ -0,0 +1,285 @@ +jest.mock("../../../core/filter/project/project.wpsFilter"); +jest.mock("../../../core/filter/status/status.wpsFilter"); +jest.mock("../../../core/filter/text/text.wpsFilter"); +jest.mock("../../../infrastructure/project/project.repository"); + +import { faker } from "@faker-js/faker"; +import { Project } from "op-client"; +import container from "../../../DI/container"; +import TOKENS from "../../../DI/tokens"; +import * as vscode from "../../../__mocks__/vscode"; +import ProjectWPsFilter from "../../../core/filter/project/project.wpsFilter.interface"; +import StatusWPsFilter from "../../../core/filter/status/status.wpsFilter.interface"; +import TextWPsFilter from "../../../core/filter/text/text.wpsFilter.interface"; +import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; +import ProjectRepository from "../../../infrastructure/project/project.repository.interface"; +import SetupFiltersCommandImpl, { PayloadItem } from "./setupFilters.command"; + +describe("filter WPs command test suite", () => { + let command: SetupFiltersCommandImpl; + const textFilter = container.get(TOKENS.textFilter); + const statusFilter = container.get(TOKENS.statusFilter); + const projectFilter = container.get(TOKENS.projectFilter); + const projectRepo = container.get( + TOKENS.projectRepository, + ); + + beforeEach(() => { + jest.clearAllMocks(); + command = container.get( + TOKENS.setupFiltersCommand, + ); + }); + + describe("Setup filters", () => { + it("should call vscode prompt and ask for filter", () => { + jest.spyOn(vscode.window, "showQuickPick"); + + command.setupFilters(); + + expect(vscode.window.showQuickPick).toHaveBeenCalled(); + }); + it("should call setupFunction of item", async () => { + const setupFunc = jest.fn(); + jest + .spyOn(vscode.window, "showQuickPick") + .mockResolvedValue({ payload: setupFunc } as any); + + await command.setupFilters(); + + expect(setupFunc).toHaveBeenCalled(); + }); + it("should call nothing if item wasnt choosen", async () => { + jest.spyOn(vscode.window, "showQuickPick").mockResolvedValue(undefined); + await command.setupFilters(); + }); + }); + describe("Setup text filter", () => { + it("should show input box", async () => { + jest.spyOn(vscode.window, "showInputBox"); + await command.setupTextFilter(); + expect(vscode.window.showInputBox).toHaveBeenCalled(); + }); + it("should value from getTextFilter", async () => { + const query = faker.string.alpha(); + + jest.spyOn(textFilter, "getTextFilter").mockReturnValue(query); + jest.spyOn(vscode.window, "showInputBox"); + + await command.setupTextFilter(); + + expect(vscode.window.showInputBox).toHaveBeenLastCalledWith( + expect.objectContaining({ value: query }), + ); + }); + it("should setTextFilter", async () => { + const query = faker.string.alpha(); + + jest.spyOn(vscode.window, "showInputBox").mockResolvedValue(query); + jest.spyOn(textFilter, "setTextFilter"); + + await command.setupTextFilter(); + + expect(textFilter.setTextFilter).toHaveBeenLastCalledWith(query); + }); + }); + describe("setupProjectFilter", () => { + it("should show quickpick", async () => { + jest.spyOn(vscode.window, "showQuickPick"); + jest.spyOn(command, "getProjectIdQuickPickItems").mockReturnValue([]); + await command.setupProjectFilter(); + expect(vscode.window.showQuickPick).toHaveBeenCalled(); + }); + it("should get quickpick items from getProjectFilterItems function", async () => { + const items: any[] = []; + + jest.spyOn(command, "getProjectIdQuickPickItems").mockReturnValue(items); + + await command.setupProjectFilter(); + + expect(vscode.window.showQuickPick).toHaveBeenLastCalledWith( + items, + expect.anything(), + ); + }); + it("should setProjectFilter results", async () => { + const projectIds = faker.helpers.uniqueArray(faker.number.int, 5); + const results = projectIds.map((num) => ({ projectId: num })); + + jest.spyOn(command, "getProjectIdQuickPickItems").mockReturnValue([]); + jest + .spyOn(vscode.window, "showQuickPick") + .mockResolvedValue(results as any); + jest.spyOn(projectFilter, "setProjectFilter"); + + await command.setupProjectFilter(); + + expect(projectFilter.setProjectFilter).toHaveBeenLastCalledWith( + projectIds, + ); + }); + it("should setProjectFilter empty array", async () => { + jest.spyOn(command, "getProjectIdQuickPickItems").mockReturnValue([]); + jest.spyOn(vscode.window, "showQuickPick").mockResolvedValue(undefined); + jest.spyOn(projectFilter, "setProjectFilter"); + + await command.setupProjectFilter(); + + expect(projectFilter.setProjectFilter).toHaveBeenLastCalledWith([]); + }); + }); + describe("setupStatusFilter", () => { + it("should show quickpick", async () => { + jest.spyOn(vscode.window, "showQuickPick"); + await command.setupStatusFilter(); + expect(vscode.window.showQuickPick).toHaveBeenCalled(); + }); + it("should get quickpick items from getStatusFilterItems function", async () => { + const items: any[] = []; + + jest.spyOn(command, "getStatusQuickPickItems").mockReturnValue(items); + + await command.setupStatusFilter(); + + expect(vscode.window.showQuickPick).toHaveBeenLastCalledWith( + items, + expect.anything(), + ); + }); + it("should setStatusFilter results", async () => { + const statuses = faker.helpers.uniqueArray(faker.string.alpha, 5); + const results = statuses.map((num) => ({ status: num })); + + jest.spyOn(command, "getStatusQuickPickItems").mockReturnValue([]); + jest + .spyOn(vscode.window, "showQuickPick") + .mockResolvedValue(results as any); + jest.spyOn(statusFilter, "setStatusFilter"); + + await command.setupStatusFilter(); + + expect(statusFilter.setStatusFilter).toHaveBeenLastCalledWith(statuses); + }); + it("should setStatusFilter empty array", async () => { + jest.spyOn(command, "getStatusQuickPickItems").mockReturnValue([]); + jest.spyOn(vscode.window, "showQuickPick").mockResolvedValue(undefined); + jest.spyOn(statusFilter, "setStatusFilter"); + + await command.setupStatusFilter(); + + expect(statusFilter.setStatusFilter).toHaveBeenLastCalledWith([]); + }); + }); + + describe("getProjectIdQuickPickItems", () => { + beforeAll(() => { + (command.getProjectIdQuickPickItems as jest.Mock).mockRestore(); + }); + const projects: Project[] = [ + { + body: { name: "project1" }, + id: 1, + }, + { + body: { name: "project2" }, + id: 2, + }, + { + body: { name: "project3" }, + id: 3, + }, + ] as Project[]; + + it("should return empty array if there are no projects", () => { + jest.spyOn(projectRepo, "findAll").mockReturnValue([]); + expect(command.getProjectIdQuickPickItems()).toEqual([]); + }); + it("should return array with the same length", () => { + jest.spyOn(projectRepo, "findAll").mockReturnValue(projects); + const items = command.getProjectIdQuickPickItems(); + expect(items).toHaveLength(projects.length); + }); + it("should return array of items with project ids and project names", () => { + jest.spyOn(projectRepo, "findAll").mockReturnValue(projects); + const items = command.getProjectIdQuickPickItems(); + items.forEach((item, index) => { + expect(item).toEqual( + expect.objectContaining>({ + projectId: projects[index].id, + label: projects[index].body.name, + }), + ); + }); + }); + it("should return array of items with project ids and project names", () => { + jest.spyOn(projectRepo, "findAll").mockReturnValue(projects); + const items = command.getProjectIdQuickPickItems(); + items.forEach((item, index) => { + expect(item).toEqual( + expect.objectContaining>({ + projectId: projects[index].id, + label: projects[index].body.name, + }), + ); + }); + }); + it("should mark projects from getProjectFilter as picked", () => { + const filter = [projects[0].id, projects[1].id]; + jest.spyOn(projectRepo, "findAll").mockReturnValue(projects); + jest.spyOn(projectFilter, "getProjectFilter").mockReturnValue(filter); + const items = command.getProjectIdQuickPickItems(); + items.forEach((item, index) => { + expect(item).toEqual( + expect.objectContaining>({ + picked: filter.includes(projects[index].id), + }), + ); + }); + }); + it("should mark all projects as picked if getProjectFilter returned undefined", () => { + const filter = undefined; + jest.spyOn(projectRepo, "findAll").mockReturnValue(projects); + jest.spyOn(projectFilter, "getProjectFilter").mockReturnValue(filter); + const items = command.getProjectIdQuickPickItems(); + items.forEach((item) => { + expect(item).toEqual( + expect.objectContaining>({ + picked: true, + }), + ); + }); + }); + }); + describe("getStatusQuickPickItems", () => { + beforeAll(() => { + (command.getStatusQuickPickItems as jest.Mock).mockRestore(); + }); + it("should return array of items for all WPStatuses", () => { + const items = command.getStatusQuickPickItems(); + expect(items).toHaveLength(Object.keys(WPStatus).length); + }); + it("should return array of items with status names", () => { + const items = command.getStatusQuickPickItems(); + items.forEach((item, index) => { + expect(item).toEqual( + expect.objectContaining>({ + status: Object.values(WPStatus)[index], + label: Object.values(WPStatus)[index], + }), + ); + }); + }); + it("should mark items from getStatusFilter as picked", () => { + const filter = [WPStatus.closed, WPStatus.inSpecification]; + jest.spyOn(statusFilter, "getStatusFilter").mockReturnValue(filter); + const items = command.getStatusQuickPickItems(); + items.forEach((item, index) => { + expect(item).toEqual( + expect.objectContaining>({ + picked: filter.includes(Object.values(WPStatus)[index]), + }), + ); + }); + }); + }); +}); diff --git a/src/application/commands/filter/setupFilters.command.ts b/src/application/commands/filter/setupFilters.command.ts new file mode 100644 index 0000000..b2b284b --- /dev/null +++ b/src/application/commands/filter/setupFilters.command.ts @@ -0,0 +1,102 @@ +import { inject, injectable } from "inversify"; +import * as vscode from "vscode"; +import TOKENS from "../../../DI/tokens"; +import ProjectWPsFilter from "../../../core/filter/project/project.wpsFilter.interface"; +import StatusWPsFilter from "../../../core/filter/status/status.wpsFilter.interface"; +import TextWPsFilter from "../../../core/filter/text/text.wpsFilter.interface"; +import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; +import ProjectRepository from "../../../infrastructure/project/project.repository.interface"; +import ProjectQuickPickItem from "./quickPickItems/project.quickPickItem"; +import WPStatusQuickPickItem from "./quickPickItems/wpStatus.quickPickItem"; +import SetupFiltersCommand from "./setupFilters.command.interface"; + +export type PayloadItem = vscode.QuickPickItem & { + payload: T; +}; + +@injectable() +export default class SetupFiltersCommandImpl implements SetupFiltersCommand { + constructor( + @inject(TOKENS.textFilter) private readonly _textFilter: TextWPsFilter, + @inject(TOKENS.projectFilter) + private readonly _projectFilter: ProjectWPsFilter, + @inject(TOKENS.statusFilter) + private readonly _statusFilter: StatusWPsFilter, + @inject(TOKENS.projectRepository) + private readonly _projectRepo: ProjectRepository, + ) {} + + async setupFilters() { + const items: PayloadItem<() => unknown>[] = [ + { + label: "Text filter", + description: "Filters packages by text query", + payload: () => this.setupTextFilter(), + }, + { + label: "Project filter", + description: "Choose which packages you want to show", + payload: () => this.setupProjectFilter(), + }, + { + label: "Status filter", + description: "Filters packages by it's status", + payload: () => this.setupStatusFilter(), + }, + ]; + const filterTypeItem = await vscode.window.showQuickPick(items); + filterTypeItem?.payload(); + } + + async setupTextFilter() { + const textFilter = await vscode.window.showInputBox({ + placeHolder: "Your text filter", + title: "Text filter", + value: this._textFilter.getTextFilter(), + }); + this._textFilter.setTextFilter(textFilter ?? ""); + } + + async setupProjectFilter() { + const items = this.getProjectIdQuickPickItems(); + const results = await vscode.window.showQuickPick(items, { + canPickMany: true, + title: "Select wps of which projects you want to see: ", + }); + const projectIds = results ? results.map((item) => item.projectId) : []; + this._projectFilter.setProjectFilter(projectIds); + } + + async setupStatusFilter() { + const items = this.getStatusQuickPickItems(); + const results = await vscode.window.showQuickPick(items, { + canPickMany: true, + title: "Select wps of which status you want to see: ", + }); + const statuses = results ? results.map((item) => item.status) : []; + this._statusFilter.setStatusFilter(statuses); + } + + getProjectIdQuickPickItems(): ProjectQuickPickItem[] { + const filter = this._projectFilter.getProjectFilter(); + const projects = this._projectRepo.findAll(); + return projects.map( + (project) => + new ProjectQuickPickItem( + project, + filter === undefined || filter.includes(project.id), + ), + ); + } + + getStatusQuickPickItems(): WPStatusQuickPickItem[] { + const filter = this._statusFilter.getStatusFilter(); + return Object.values(WPStatus).map( + (status) => + new WPStatusQuickPickItem( + status, + filter === undefined || filter.includes(status), + ), + ); + } +} diff --git a/src/application/commands/refresh/refreshWPs.command.spec.ts b/src/application/commands/refresh/refreshWPs.command.spec.ts index 73df85e..8c0ccfd 100644 --- a/src/application/commands/refresh/refreshWPs.command.spec.ts +++ b/src/application/commands/refresh/refreshWPs.command.spec.ts @@ -13,11 +13,12 @@ describe("refresh WPs command test suite", () => { treeDataProvider = container.get(TOKENS.opTreeView); command = container.get(TOKENS.refreshWPsCommand); }); + it("should call refreshWPs func", () => { - jest.spyOn(treeDataProvider, "refreshWPs"); + jest.spyOn(treeDataProvider, "refresh"); command.refreshWPs(); - expect(treeDataProvider.refreshWPs).toHaveBeenCalled(); + expect(treeDataProvider.refresh).toHaveBeenCalled(); }); }); diff --git a/src/application/commands/refresh/refreshWPs.command.ts b/src/application/commands/refresh/refreshWPs.command.ts index 264456e..753c14c 100644 --- a/src/application/commands/refresh/refreshWPs.command.ts +++ b/src/application/commands/refresh/refreshWPs.command.ts @@ -11,6 +11,6 @@ export default class RefreshWPsCommandImpl implements RefreshWPsCommand { ) {} refreshWPs() { - this._treeDataProvider.refreshWPs(); + this._treeDataProvider.refresh(); } } diff --git a/src/application/views/openProject.treeDataProvider.spec.ts b/src/application/views/openProject.treeDataProvider.spec.ts index 0b002c2..c9ef7d5 100644 --- a/src/application/views/openProject.treeDataProvider.spec.ts +++ b/src/application/views/openProject.treeDataProvider.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-new */ jest.mock("../../infrastructure/project/project.repository"); jest.mock("../../infrastructure/workPackage/wp.repository"); @@ -5,9 +6,12 @@ import { faker } from "@faker-js/faker"; import { Project, WP } from "op-client"; import container from "../../DI/container"; import TOKENS from "../../DI/tokens"; +import OpenProjectClient from "../../infrastructure/openProject/openProject.client.interface"; import ProjectRepository from "../../infrastructure/project/project.repository.interface"; import WPRepository from "../../infrastructure/workPackage/wp.repository.interface"; import OpenProjectTreeDataProviderImpl from "./openProject.treeDataProvider"; +import ProjectTreeItem from "./treeItems/project.treeItem"; +import WPTreeItem from "./treeItems/wp.treeItem"; describe("OpenProjectTreeDataProvider", () => { const treeView = container.get( @@ -17,14 +21,50 @@ describe("OpenProjectTreeDataProvider", () => { const projectRepo = container.get( TOKENS.projectRepository, ); + const client = container.get(TOKENS.opClient); afterEach(() => { jest.restoreAllMocks(); }); + describe("Constructor", () => { + it("should subscribe to wpRepo onWPsChange", () => { + jest.spyOn(wpRepo, "onWPsChange"); + + new OpenProjectTreeDataProviderImpl(wpRepo, projectRepo, client); + + expect(wpRepo.onWPsChange).toHaveBeenCalled(); + }); + it("should subscribe to projectRepo onProjectsRefetch", () => { + jest.spyOn(projectRepo, "onProjectsRefetch"); + + new OpenProjectTreeDataProviderImpl(wpRepo, projectRepo, client); + + expect(projectRepo.onProjectsRefetch).toHaveBeenCalled(); + }); + it("should subscribe to client onInit", () => { + jest.spyOn(client, "onInit"); + new OpenProjectTreeDataProviderImpl(wpRepo, projectRepo, client); + expect(client.onInit).toHaveBeenCalled(); + }); + }); + describe("getTreeItem", () => { - it.todo("should return wp tree item"); - it.todo("should return project tree item"); + it("should return wp tree item", () => { + const wp = new WP(1); + const treeItem = treeView.getTreeItem(wp); + expect(treeItem).toBeInstanceOf(WPTreeItem); + }); + it("should return project tree item", () => { + const project = new Project(1); + const treeItem = treeView.getTreeItem(project); + expect(treeItem).toBeInstanceOf(ProjectTreeItem); + }); + it("should return empty object", () => { + const project = {}; + const treeItem = treeView.getTreeItem(project as any); + expect(treeItem).toBeInstanceOf(Object); + }); }); describe("getChildren", () => { diff --git a/src/application/views/openProject.treeDataProvider.ts b/src/application/views/openProject.treeDataProvider.ts index b2fb26e..a8a6975 100644 --- a/src/application/views/openProject.treeDataProvider.ts +++ b/src/application/views/openProject.treeDataProvider.ts @@ -27,11 +27,11 @@ export default class OpenProjectTreeDataProviderImpl @inject(TOKENS.opClient) _client: OpenProjectClient, ) { - _wpRepository.onWPsRefetch(() => this._onDidChangeTreeData.fire()); + _wpRepository.onWPsChange(() => this._onDidChangeTreeData.fire()); _projectRepository.onProjectsRefetch(() => this._onDidChangeTreeData.fire(), ); - _client.onInit(() => this.refresh()); + _client.onInit(this.refresh, this); } getTreeItem(element: WP | Project): TreeItem { diff --git a/src/application/views/treeItems/wp.treeItem.ts b/src/application/views/treeItems/wp.treeItem.ts index d3ccc7d..7145c81 100644 --- a/src/application/views/treeItems/wp.treeItem.ts +++ b/src/application/views/treeItems/wp.treeItem.ts @@ -15,7 +15,7 @@ export default class WPTreeItem implements TreeItem { label?: string | TreeItemLabel | undefined; constructor(wp: WP) { - const iconPath = getIconPathByStatus(wp.status.self.title as WPStatus); + const iconPath = getIconPathByStatus(wp.status?.self.title as WPStatus); const type = wp.type?.self.title; let label = `#${wp.id} ${wp.subject}`; if (type) label += ` ${type}`; diff --git a/src/core/filter/composite/__mocks__/composite.wpsFilter.ts b/src/core/filter/composite/__mocks__/composite.wpsFilter.ts new file mode 100644 index 0000000..38bb339 --- /dev/null +++ b/src/core/filter/composite/__mocks__/composite.wpsFilter.ts @@ -0,0 +1,11 @@ +import { injectable } from "inversify"; +import CompositeWPsFilter from "../composite.wpsFilter.interface"; + +@injectable() +export default class CompositeWPsFilterImpl implements CompositeWPsFilter { + filter = jest.fn(); + + onFilterUpdated = jest.fn(); + + pushFilter = jest.fn(); +} diff --git a/src/core/filter/composite/composite.wpsFilter.interface.ts b/src/core/filter/composite/composite.wpsFilter.interface.ts new file mode 100644 index 0000000..f51a3bf --- /dev/null +++ b/src/core/filter/composite/composite.wpsFilter.interface.ts @@ -0,0 +1,5 @@ +import WPsFilter from "../wpsFilter.interface"; + +export default interface CompositeWPsFilter extends WPsFilter { + pushFilter(filter: WPsFilter): void; +} diff --git a/src/core/filter/composite/composite.wpsFilter.spec.ts b/src/core/filter/composite/composite.wpsFilter.spec.ts new file mode 100644 index 0000000..cc5c84e --- /dev/null +++ b/src/core/filter/composite/composite.wpsFilter.spec.ts @@ -0,0 +1,83 @@ +import { faker } from "@faker-js/faker"; +import { WP } from "op-client"; +import container from "../../../DI/container"; +import TOKENS from "../../../DI/tokens"; +import WPsFilter from "../wpsFilter.interface"; +import CompositeWPsFilterImpl from "./composite.wpsFilter"; + +describe("WPs composite filter test suite", () => { + let compositeFilter: CompositeWPsFilterImpl; + + beforeEach(() => { + compositeFilter = container.get( + TOKENS.compositeFilter, + ); + }); + + describe("filter", () => { + let mockedFilters: WPsFilter[]; + let mockedWPs: WP[]; + + beforeAll(() => { + mockedFilters = faker.helpers.uniqueArray( + () => ({ filter: jest.fn(), onFilterUpdated: jest.fn() }), + 5, + ); + mockedWPs = faker.helpers.uniqueArray( + () => new WP(faker.number.int()), + 5, + ); + }); + + it("should return wps untouched if no filters pushed", () => { + const filteredWps = compositeFilter.filter(mockedWPs); + expect(filteredWps).toEqual(mockedWPs); + }); + it("should call every filters 'filter' function", () => { + mockedFilters.forEach((filter) => { + jest.spyOn(filter, "filter").mockImplementation((wps) => wps); + compositeFilter.pushFilter(filter); + }); + + compositeFilter.filter(mockedWPs); + + mockedFilters.forEach((filter) => { + expect(filter.filter).toHaveBeenLastCalledWith(mockedWPs); + }); + }); + it("should call every filters 'filter' function", () => { + mockedFilters.forEach((filter) => { + jest.spyOn(filter, "filter").mockImplementation((wps) => { + wps.pop(); + return wps; + }); + compositeFilter.pushFilter(filter); + }); + + const filteredWPs = compositeFilter.filter(mockedWPs); + expect(filteredWPs).toEqual(mockedWPs); + }); + }); + + describe("pushFilter", () => { + it("should add filter to list", () => { + const filter: WPsFilter = { + filter: jest.fn(), + onFilterUpdated: jest.fn(), + }; + compositeFilter.pushFilter(filter); + expect(compositeFilter["_filters"]).toEqual( + expect.arrayContaining([filter]), + ); + }); + it("should subscribe to filters onFilterChange", () => { + const onFilterUpdatedMocked = jest.fn(); + const filter: WPsFilter = { + filter: jest.fn(), + onFilterUpdated: onFilterUpdatedMocked, + }; + compositeFilter.pushFilter(filter); + expect(onFilterUpdatedMocked).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/core/filter/composite/composite.wpsFilter.ts b/src/core/filter/composite/composite.wpsFilter.ts new file mode 100644 index 0000000..98054ca --- /dev/null +++ b/src/core/filter/composite/composite.wpsFilter.ts @@ -0,0 +1,28 @@ +import { injectable } from "inversify"; +import { WP } from "op-client"; +import * as vscode from "vscode"; +import WPsFilter from "../wpsFilter.interface"; +import CompositeWPsFilter from "./composite.wpsFilter.interface"; + +@injectable() +export default class CompositeWPsFilterImpl implements CompositeWPsFilter { + private _filters: WPsFilter[] = []; + + private _onFilterUpdated: vscode.EventEmitter = + new vscode.EventEmitter(); + + onFilterUpdated = this._onFilterUpdated.event; + + filter(wps: WP[]): WP[] { + let filteredWps: WP[] = wps; + for (let i = 0; i < this._filters.length; i++) { + filteredWps = this._filters[i].filter(filteredWps); + } + return filteredWps; + } + + pushFilter(filter: WPsFilter): void { + this._filters.push(filter); + filter.onFilterUpdated(() => this._onFilterUpdated.fire()); + } +} diff --git a/src/core/filter/text/text.wpsFilter.spec.ts b/src/core/filter/text/text.wpsFilter.spec.ts index 8e818c2..1cc1798 100644 --- a/src/core/filter/text/text.wpsFilter.spec.ts +++ b/src/core/filter/text/text.wpsFilter.spec.ts @@ -15,10 +15,7 @@ describe("WPs text filter test suite", () => { const helloWorldWP = { subject: "Hello world!", author: { - name: "goodhumored", - firstName: "Kirill", - lastName: "Nekrasov", - login: "goodhumored", + self: { title: "goodhumored" }, }, body: { description: { @@ -28,39 +25,24 @@ describe("WPs text filter test suite", () => { } as WP; const easterEggWP = { subject: "Lorem ipsum!", - author: { - name: "goodhumored", - firstName: "Kirill", - lastName: "Nekrasov", - login: "goodhumored", - }, + author: { self: { title: "goodhumored" } }, body: { - description: { - raw: "Easter egg!", - }, + description: { raw: "Easter egg!" }, }, } as WP; const dannyWP = { subject: "Title", author: { - name: "dannyweiss", - firstName: "Danila", - lastName: "Smolyakov", - login: "dannyweiss", + self: { title: "dannyweiss" }, }, body: { - description: { - raw: "Lorem ipsum", - }, + description: { raw: "Lorem ipsum" }, }, } as WP; const bugWP = { subject: "Bug!", author: { - name: "Svante Kaiser", - firstName: "Sviat", - lastName: "Tsarev", - login: "svante_kaiser", + self: { title: "Svante Kaiser" }, }, body: {}, } as WP; diff --git a/src/core/filter/text/text.wpsFilter.ts b/src/core/filter/text/text.wpsFilter.ts index 7468bea..9b0d93d 100644 --- a/src/core/filter/text/text.wpsFilter.ts +++ b/src/core/filter/text/text.wpsFilter.ts @@ -14,10 +14,7 @@ export default class TextWPsFilterImpl implements TextWPsFilter { const textFilterLower = this._textFilter.toLowerCase(); return wps.filter( (wp) => - wp.author.login.toLowerCase().includes(textFilterLower) || - wp.author.firstName.toLowerCase().includes(textFilterLower) || - wp.author.lastName.toLowerCase().includes(textFilterLower) || - wp.author.name.toLowerCase().includes(textFilterLower) || + wp.author.self.title?.toLowerCase().includes(textFilterLower) || wp.subject.toLowerCase().includes(textFilterLower) || wp.body.description?.raw.toLowerCase().includes(textFilterLower), ); diff --git a/src/extension.spec.ts b/src/extension.spec.ts index d79d5d6..ee806ac 100644 --- a/src/extension.spec.ts +++ b/src/extension.spec.ts @@ -7,8 +7,10 @@ const vscode = require("./__mocks__/vscode"); import container from "./DI/container"; import TOKENS from "./DI/tokens"; import AuthorizeClientCommand from "./application/commands/authorize/authorizeClientCommand.interface"; +import SetupFiltersCommand from "./application/commands/filter/setupFilters.command.interface"; import RefreshWPsCommand from "./application/commands/refresh/refreshWPsCommand.interface"; import OpenProjectTreeDataProvider from "./application/views/openProjectTreeDataProvider.interface"; +import CompositeWPsFilter from "./core/filter/composite/composite.wpsFilter.interface"; import { activate, deactivate } from "./extension"; describe("activate", () => { @@ -16,9 +18,13 @@ describe("activate", () => { let authCommand: AuthorizeClientCommand; let refreshCommand: RefreshWPsCommand; + let setupFiltersCommand: SetupFiltersCommand; let treeView: OpenProjectTreeDataProvider; beforeAll(() => { + setupFiltersCommand = container.get( + TOKENS.setupFiltersCommand, + ); authCommand = container.get( TOKENS.authorizeCommand, ); @@ -37,7 +43,7 @@ describe("activate", () => { activate(context); expect(vscode.commands.registerCommand.mock.calls).toEqual( expect.arrayContaining([ - ["openproject.auth", authCommand.authorizeClient], + ["openproject.auth", authCommand.authorizeClient, authCommand], ]), ); }); @@ -45,7 +51,19 @@ describe("activate", () => { activate(context); expect(vscode.commands.registerCommand.mock.calls).toEqual( expect.arrayContaining([ - ["openproject.refresh", refreshCommand.refreshWPs], + ["openproject.refresh", refreshCommand.refreshWPs, refreshCommand], + ]), + ); + }); + it("registers setup filter command", () => { + activate(context); + expect(vscode.commands.registerCommand.mock.calls).toEqual( + expect.arrayContaining([ + [ + "openproject.setupFilter", + setupFiltersCommand.setupFilters, + setupFiltersCommand, + ], ]), ); }); @@ -60,9 +78,9 @@ describe("activate", () => { }); describe("subscriptions", () => { - it("adds 3 subscriptions to context", () => { + it("adds 4 subscriptions to context", () => { activate(context); - expect(context.subscriptions).toHaveLength(3); + expect(context.subscriptions).toHaveLength(4); }); it("adds authCommand to subscriptions to context", () => { jest @@ -92,6 +110,19 @@ describe("activate", () => { ); }); }); + + describe("setupFilters", () => { + it("should push all filters", () => { + const compositeFilter = container.get( + TOKENS.compositeFilter, + ); + jest.spyOn(compositeFilter, "pushFilter"); + + activate(context); + + expect(compositeFilter.pushFilter).toHaveBeenCalled(); + }); + }); }); describe("deactivate", () => { diff --git a/src/extension.ts b/src/extension.ts index 60d61e0..37ad676 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,16 +2,24 @@ import * as vscode from "vscode"; import container from "./DI/container"; import TOKENS from "./DI/tokens"; import AuthorizeClientCommand from "./application/commands/authorize/authorizeClientCommand.interface"; +import SetupFiltersCommand from "./application/commands/filter/setupFilters.command.interface"; import RefreshWPsCommand from "./application/commands/refresh/refreshWPsCommand.interface"; import OpenProjectTreeDataProvider from "./application/views/openProject.treeDataProvider"; +import CompositeWPsFilter from "./core/filter/composite/composite.wpsFilter.interface"; +import WPsFilter from "./core/filter/wpsFilter.interface"; export function activate(context: vscode.ExtensionContext) { + composeFilters(); + const authCommand = container.get( TOKENS.authorizeCommand, ); const refreshCommand = container.get( TOKENS.refreshWPsCommand, ); + const setupFilterCommand = container.get( + TOKENS.setupFiltersCommand, + ); const treeView = container.get( TOKENS.opTreeView, ); @@ -20,10 +28,17 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand( "openproject.auth", authCommand.authorizeClient, + authCommand, ), vscode.commands.registerCommand( "openproject.refresh", refreshCommand.refreshWPs, + refreshCommand, + ), + vscode.commands.registerCommand( + "openproject.setupFilter", + setupFilterCommand.setupFilters, + setupFilterCommand, ), vscode.window.createTreeView("openproject-workspaces", { treeDataProvider: treeView, @@ -35,4 +50,16 @@ export function activate(context: vscode.ExtensionContext) { authCommand.authorizeClient(); } +function composeFilters() { + const compositeFilter = container.get( + TOKENS.compositeFilter, + ); + const textFilter = container.get(TOKENS.textFilter); + const projectFilter = container.get(TOKENS.projectFilter); + const statusFilter = container.get(TOKENS.statusFilter); + compositeFilter.pushFilter(textFilter); + compositeFilter.pushFilter(projectFilter); + compositeFilter.pushFilter(statusFilter); +} + export function deactivate() {} diff --git a/src/infrastructure/logger/__mocks__/logger.ts b/src/infrastructure/logger/__mocks__/logger.ts index 82fc5e9..62aec78 100644 --- a/src/infrastructure/logger/__mocks__/logger.ts +++ b/src/infrastructure/logger/__mocks__/logger.ts @@ -1,5 +1,5 @@ import { injectable } from "inversify"; -import Logger from "../logger"; +import Logger from "../logger.interface"; @injectable() export default class ConsoleLogger implements Logger { diff --git a/src/infrastructure/workPackage/__mocks__/wp.repository.ts b/src/infrastructure/workPackage/__mocks__/wp.repository.ts index e3fc1db..fcff5cf 100644 --- a/src/infrastructure/workPackage/__mocks__/wp.repository.ts +++ b/src/infrastructure/workPackage/__mocks__/wp.repository.ts @@ -11,7 +11,7 @@ export default class WPRepositoryImpl implements WPRepository { refetch = jest.fn(); - onWPsRefetch = jest.fn(); + onWPsChange = jest.fn(); findByProjectId = jest.fn(); } diff --git a/src/infrastructure/workPackage/wp.repository.interface.ts b/src/infrastructure/workPackage/wp.repository.interface.ts index 2b752c2..1d706ce 100644 --- a/src/infrastructure/workPackage/wp.repository.interface.ts +++ b/src/infrastructure/workPackage/wp.repository.interface.ts @@ -7,5 +7,5 @@ export default interface WPRepository { findByProjectId(projectId: number): WP[]; findAll(): WP[]; refetch(): Promise; - onWPsRefetch: Event; + onWPsChange: Event; } diff --git a/src/infrastructure/workPackage/wp.repository.spec.ts b/src/infrastructure/workPackage/wp.repository.spec.ts index d98ad79..4c71434 100644 --- a/src/infrastructure/workPackage/wp.repository.spec.ts +++ b/src/infrastructure/workPackage/wp.repository.spec.ts @@ -1,14 +1,17 @@ jest.mock("../openProject/openProject.client"); +jest.mock("../../core/filter/composite/composite.wpsFilter.interface"); import { Project, WP } from "op-client"; import container from "../../DI/container"; import TOKENS from "../../DI/tokens"; +import CompositeWPsFilter from "../../core/filter/composite/composite.wpsFilter.interface"; import OpenProjectClient from "../openProject/openProject.client.interface"; import WPRepositoryImpl from "./wp.repository"; import WPNotFoundException from "./wpNotFount.exception"; describe("WP repository test suite", () => { const client = container.get(TOKENS.opClient); + const filter = container.get(TOKENS.compositeFilter); const repository = container.get(TOKENS.wpRepository); const wp1 = new WP(1); const wp2 = new WP(2); @@ -33,6 +36,14 @@ describe("WP repository test suite", () => { await repository.refetch(); }); + describe("getProcessedWPs", () => { + it("should filter wps using filter", () => { + jest.spyOn(filter, "filter").mockReturnValue([wp2, wp3]); + const wps = repository["getProcessedWPs"](); + expect(wps).toEqual([wp2, wp3]); + }); + }); + describe("findById", () => { it("should return wp1", () => { expect(repository.findById(wp1.id)).toEqual(wp1); @@ -54,6 +65,9 @@ describe("WP repository test suite", () => { }); describe("findByParentId", () => { + beforeAll(() => { + jest.spyOn(filter, "filter").mockReturnValue([wp1, wp2, wp3, wp4]); + }); it("should return children of wp1", () => { expect(repository.findByParentId(wp1.id)).toEqual([wp2, wp3]); }); @@ -66,6 +80,9 @@ describe("WP repository test suite", () => { }); describe("findByProjectId", () => { + beforeAll(() => { + jest.spyOn(filter, "filter").mockReturnValue([wp1, wp2, wp3, wp4]); + }); it("should return projects of project1", () => { expect(repository.findByProjectId(project1.id)).toEqual([wp1]); }); @@ -81,6 +98,9 @@ describe("WP repository test suite", () => { }); describe("findAll", () => { + beforeAll(() => { + jest.spyOn(filter, "filter").mockReturnValue([wp1, wp2, wp3, wp4]); + }); it("should return all wps", () => { expect(repository.findAll()).toEqual([wp1, wp2, wp3, wp4]); }); diff --git a/src/infrastructure/workPackage/wp.repository.ts b/src/infrastructure/workPackage/wp.repository.ts index 2cfed20..f11cd09 100644 --- a/src/infrastructure/workPackage/wp.repository.ts +++ b/src/infrastructure/workPackage/wp.repository.ts @@ -3,6 +3,7 @@ import { WP } from "op-client"; import * as vscode from "vscode"; import { Event } from "vscode"; import TOKENS from "../../DI/tokens"; +import WPsFilter from "../../core/filter/wpsFilter.interface"; import OpenProjectClient from "../openProject/openProject.client.interface"; import WPRepository from "./wp.repository.interface"; import WPNotFoundException from "./wpNotFount.exception"; @@ -11,14 +12,21 @@ import WPNotFoundException from "./wpNotFount.exception"; export default class WPRepositoryImpl implements WPRepository { private _wps: WP[] = []; - private _onWPsRefetch: vscode.EventEmitter = + private _onWPsChange: vscode.EventEmitter = new vscode.EventEmitter(); - onWPsRefetch: Event = this._onWPsRefetch.event; + onWPsChange: Event = this._onWPsChange.event; constructor( @inject(TOKENS.opClient) private readonly _client: OpenProjectClient, - ) {} + @inject(TOKENS.compositeFilter) private readonly _filter: WPsFilter, + ) { + _filter.onFilterUpdated(() => this._onWPsChange.fire()); + } + + private getProcessedWPs(): WP[] { + return this._filter.filter(this._wps); + } findById(id: number): WP { const result = this._wps.find((wp) => wp.id === id); @@ -27,21 +35,21 @@ export default class WPRepositoryImpl implements WPRepository { } findByParentId(parentId: number): WP[] { - return this._wps.filter((wp) => wp.parent?.id === parentId); + return this.getProcessedWPs().filter((wp) => wp.parent?.id === parentId); } findByProjectId(projectId: number): WP[] { - return this._wps.filter((wp) => wp.project.id === projectId); + return this.getProcessedWPs().filter((wp) => wp.project.id === projectId); } findAll(): WP[] { - return this._wps; + return this.getProcessedWPs(); } refetch(): Promise { return this._client.getWPs().then((wps) => { this._wps = wps; - this._onWPsRefetch.fire(); + this._onWPsChange.fire(); }); } } From 5767604a431af29cd4402f65732cad72dc86d16d Mon Sep 17 00:00:00 2001 From: goodhumored Date: Tue, 13 Jun 2023 00:12:38 +0300 Subject: [PATCH 16/38] setupFilters empty filter on unfocus fixed --- src/application/commands/filter/setupFilters.command.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/application/commands/filter/setupFilters.command.ts b/src/application/commands/filter/setupFilters.command.ts index b2b284b..6ed865e 100644 --- a/src/application/commands/filter/setupFilters.command.ts +++ b/src/application/commands/filter/setupFilters.command.ts @@ -63,7 +63,7 @@ export default class SetupFiltersCommandImpl implements SetupFiltersCommand { canPickMany: true, title: "Select wps of which projects you want to see: ", }); - const projectIds = results ? results.map((item) => item.projectId) : []; + const projectIds = (results ?? items).map((item) => item.projectId); this._projectFilter.setProjectFilter(projectIds); } @@ -73,7 +73,7 @@ export default class SetupFiltersCommandImpl implements SetupFiltersCommand { canPickMany: true, title: "Select wps of which status you want to see: ", }); - const statuses = results ? results.map((item) => item.status) : []; + const statuses = (results ?? items).map((item) => item.status); this._statusFilter.setStatusFilter(statuses); } From 6a854affaad96a64e505c394638bf5bf8976ce68 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Tue, 13 Jun 2023 00:40:41 +0300 Subject: [PATCH 17/38] project filter moved and filters projects, not wps --- src/DI/container.ts | 8 +- .../filter/setupFilters.command.spec.ts | 8 +- .../commands/filter/setupFilters.command.ts | 4 +- .../openProject.treeDataProvider.spec.ts | 4 +- .../views/openProject.treeDataProvider.ts | 4 +- .../composite.wpsFilter.interface.ts | 7 +- .../composite/composite.wpsFilter.spec.ts | 8 +- .../filter/composite/composite.wpsFilter.ts | 6 +- src/core/filter/filter.interface.ts | 6 + ...project.wpsFilter.ts => project.filter.ts} | 4 +- .../project/project.filter.interface.ts | 7 + .../filter/project/project.filter.spec.ts | 66 +++++++++ ...project.wpsFilter.ts => project.filter.ts} | 15 +- .../project/project.wpsFilter.interface.ts | 6 - .../filter/project/project.wpsFilter.spec.ts | 139 ------------------ .../status/status.wpsFilter.interface.ts | 5 +- .../filter/text/text.wpsFilter.interface.ts | 5 +- src/core/filter/wpsFilter.interface.ts | 7 - src/extension.ts | 9 +- .../project/__mocks__/project.repository.ts | 2 +- .../project/project.repository.interface.ts | 2 +- .../project/project.repository.ts | 20 ++- .../workPackage/wp.repository.ts | 4 +- 23 files changed, 140 insertions(+), 206 deletions(-) create mode 100644 src/core/filter/filter.interface.ts rename src/core/filter/project/__mocks__/{project.wpsFilter.ts => project.filter.ts} (56%) create mode 100644 src/core/filter/project/project.filter.interface.ts create mode 100644 src/core/filter/project/project.filter.spec.ts rename src/core/filter/project/{project.wpsFilter.ts => project.filter.ts} (59%) delete mode 100644 src/core/filter/project/project.wpsFilter.interface.ts delete mode 100644 src/core/filter/project/project.wpsFilter.spec.ts delete mode 100644 src/core/filter/wpsFilter.interface.ts diff --git a/src/DI/container.ts b/src/DI/container.ts index 5433bab..bad537d 100644 --- a/src/DI/container.ts +++ b/src/DI/container.ts @@ -10,8 +10,8 @@ import OpenProjectTreeDataProviderImpl from "../application/views/openProject.tr import OpenProjectTreeDataProvider from "../application/views/openProjectTreeDataProvider.interface"; import CompositeWPsFilterImpl from "../core/filter/composite/composite.wpsFilter"; import CompositeWPsFilter from "../core/filter/composite/composite.wpsFilter.interface"; -import ProjectWPsFilterImpl from "../core/filter/project/project.wpsFilter"; -import ProjectWPsFilter from "../core/filter/project/project.wpsFilter.interface"; +import ProjectsFilterImpl from "../core/filter/project/project.filter"; +import ProjectsFilter from "../core/filter/project/project.filter.interface"; import StatusWPsFilterImpl from "../core/filter/status/status.wpsFilter"; import StatusWPsFilter from "../core/filter/status/status.wpsFilter.interface"; import TextWPsFilterImpl from "../core/filter/text/text.wpsFilter"; @@ -54,8 +54,8 @@ container .inSingletonScope(); container - .bind(TOKENS.projectFilter) - .to(ProjectWPsFilterImpl) + .bind(TOKENS.projectFilter) + .to(ProjectsFilterImpl) .inSingletonScope(); container diff --git a/src/application/commands/filter/setupFilters.command.spec.ts b/src/application/commands/filter/setupFilters.command.spec.ts index 5e07121..913c86f 100644 --- a/src/application/commands/filter/setupFilters.command.spec.ts +++ b/src/application/commands/filter/setupFilters.command.spec.ts @@ -1,4 +1,4 @@ -jest.mock("../../../core/filter/project/project.wpsFilter"); +jest.mock("../../../core/filter/project/project.filter"); jest.mock("../../../core/filter/status/status.wpsFilter"); jest.mock("../../../core/filter/text/text.wpsFilter"); jest.mock("../../../infrastructure/project/project.repository"); @@ -8,18 +8,18 @@ import { Project } from "op-client"; import container from "../../../DI/container"; import TOKENS from "../../../DI/tokens"; import * as vscode from "../../../__mocks__/vscode"; -import ProjectWPsFilter from "../../../core/filter/project/project.wpsFilter.interface"; +import ProjectsFilter from "../../../core/filter/project/project.filter.interface"; import StatusWPsFilter from "../../../core/filter/status/status.wpsFilter.interface"; import TextWPsFilter from "../../../core/filter/text/text.wpsFilter.interface"; import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; import ProjectRepository from "../../../infrastructure/project/project.repository.interface"; -import SetupFiltersCommandImpl, { PayloadItem } from "./setupFilters.command"; +import SetupFiltersCommandImpl from "./setupFilters.command"; describe("filter WPs command test suite", () => { let command: SetupFiltersCommandImpl; const textFilter = container.get(TOKENS.textFilter); const statusFilter = container.get(TOKENS.statusFilter); - const projectFilter = container.get(TOKENS.projectFilter); + const projectFilter = container.get(TOKENS.projectFilter); const projectRepo = container.get( TOKENS.projectRepository, ); diff --git a/src/application/commands/filter/setupFilters.command.ts b/src/application/commands/filter/setupFilters.command.ts index 6ed865e..b9bd724 100644 --- a/src/application/commands/filter/setupFilters.command.ts +++ b/src/application/commands/filter/setupFilters.command.ts @@ -1,7 +1,7 @@ import { inject, injectable } from "inversify"; import * as vscode from "vscode"; import TOKENS from "../../../DI/tokens"; -import ProjectWPsFilter from "../../../core/filter/project/project.wpsFilter.interface"; +import ProjectsFilter from "../../../core/filter/project/project.filter.interface"; import StatusWPsFilter from "../../../core/filter/status/status.wpsFilter.interface"; import TextWPsFilter from "../../../core/filter/text/text.wpsFilter.interface"; import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; @@ -19,7 +19,7 @@ export default class SetupFiltersCommandImpl implements SetupFiltersCommand { constructor( @inject(TOKENS.textFilter) private readonly _textFilter: TextWPsFilter, @inject(TOKENS.projectFilter) - private readonly _projectFilter: ProjectWPsFilter, + private readonly _projectFilter: ProjectsFilter, @inject(TOKENS.statusFilter) private readonly _statusFilter: StatusWPsFilter, @inject(TOKENS.projectRepository) diff --git a/src/application/views/openProject.treeDataProvider.spec.ts b/src/application/views/openProject.treeDataProvider.spec.ts index c9ef7d5..f4a1dc0 100644 --- a/src/application/views/openProject.treeDataProvider.spec.ts +++ b/src/application/views/openProject.treeDataProvider.spec.ts @@ -36,11 +36,11 @@ describe("OpenProjectTreeDataProvider", () => { expect(wpRepo.onWPsChange).toHaveBeenCalled(); }); it("should subscribe to projectRepo onProjectsRefetch", () => { - jest.spyOn(projectRepo, "onProjectsRefetch"); + jest.spyOn(projectRepo, "onProjectsChange"); new OpenProjectTreeDataProviderImpl(wpRepo, projectRepo, client); - expect(projectRepo.onProjectsRefetch).toHaveBeenCalled(); + expect(projectRepo.onProjectsChange).toHaveBeenCalled(); }); it("should subscribe to client onInit", () => { jest.spyOn(client, "onInit"); diff --git a/src/application/views/openProject.treeDataProvider.ts b/src/application/views/openProject.treeDataProvider.ts index a8a6975..a236479 100644 --- a/src/application/views/openProject.treeDataProvider.ts +++ b/src/application/views/openProject.treeDataProvider.ts @@ -28,9 +28,7 @@ export default class OpenProjectTreeDataProviderImpl _client: OpenProjectClient, ) { _wpRepository.onWPsChange(() => this._onDidChangeTreeData.fire()); - _projectRepository.onProjectsRefetch(() => - this._onDidChangeTreeData.fire(), - ); + _projectRepository.onProjectsChange(() => this._onDidChangeTreeData.fire()); _client.onInit(this.refresh, this); } diff --git a/src/core/filter/composite/composite.wpsFilter.interface.ts b/src/core/filter/composite/composite.wpsFilter.interface.ts index f51a3bf..5b432fa 100644 --- a/src/core/filter/composite/composite.wpsFilter.interface.ts +++ b/src/core/filter/composite/composite.wpsFilter.interface.ts @@ -1,5 +1,6 @@ -import WPsFilter from "../wpsFilter.interface"; +import { WP } from "op-client"; +import Filter from "../filter.interface"; -export default interface CompositeWPsFilter extends WPsFilter { - pushFilter(filter: WPsFilter): void; +export default interface CompositeWPsFilter extends Filter { + pushFilter(filter: Filter): void; } diff --git a/src/core/filter/composite/composite.wpsFilter.spec.ts b/src/core/filter/composite/composite.wpsFilter.spec.ts index cc5c84e..f00d4a0 100644 --- a/src/core/filter/composite/composite.wpsFilter.spec.ts +++ b/src/core/filter/composite/composite.wpsFilter.spec.ts @@ -2,7 +2,7 @@ import { faker } from "@faker-js/faker"; import { WP } from "op-client"; import container from "../../../DI/container"; import TOKENS from "../../../DI/tokens"; -import WPsFilter from "../wpsFilter.interface"; +import Filter from "../filter.interface"; import CompositeWPsFilterImpl from "./composite.wpsFilter"; describe("WPs composite filter test suite", () => { @@ -15,7 +15,7 @@ describe("WPs composite filter test suite", () => { }); describe("filter", () => { - let mockedFilters: WPsFilter[]; + let mockedFilters: Filter[]; let mockedWPs: WP[]; beforeAll(() => { @@ -61,7 +61,7 @@ describe("WPs composite filter test suite", () => { describe("pushFilter", () => { it("should add filter to list", () => { - const filter: WPsFilter = { + const filter: Filter = { filter: jest.fn(), onFilterUpdated: jest.fn(), }; @@ -72,7 +72,7 @@ describe("WPs composite filter test suite", () => { }); it("should subscribe to filters onFilterChange", () => { const onFilterUpdatedMocked = jest.fn(); - const filter: WPsFilter = { + const filter: Filter = { filter: jest.fn(), onFilterUpdated: onFilterUpdatedMocked, }; diff --git a/src/core/filter/composite/composite.wpsFilter.ts b/src/core/filter/composite/composite.wpsFilter.ts index 98054ca..e1d4404 100644 --- a/src/core/filter/composite/composite.wpsFilter.ts +++ b/src/core/filter/composite/composite.wpsFilter.ts @@ -1,12 +1,12 @@ import { injectable } from "inversify"; import { WP } from "op-client"; import * as vscode from "vscode"; -import WPsFilter from "../wpsFilter.interface"; +import Filter from "../filter.interface"; import CompositeWPsFilter from "./composite.wpsFilter.interface"; @injectable() export default class CompositeWPsFilterImpl implements CompositeWPsFilter { - private _filters: WPsFilter[] = []; + private _filters: Filter[] = []; private _onFilterUpdated: vscode.EventEmitter = new vscode.EventEmitter(); @@ -21,7 +21,7 @@ export default class CompositeWPsFilterImpl implements CompositeWPsFilter { return filteredWps; } - pushFilter(filter: WPsFilter): void { + pushFilter(filter: Filter): void { this._filters.push(filter); filter.onFilterUpdated(() => this._onFilterUpdated.fire()); } diff --git a/src/core/filter/filter.interface.ts b/src/core/filter/filter.interface.ts new file mode 100644 index 0000000..9a19b16 --- /dev/null +++ b/src/core/filter/filter.interface.ts @@ -0,0 +1,6 @@ +import * as vscode from "vscode"; + +export default interface Filter { + filter(items: T[]): T[]; + onFilterUpdated: vscode.Event; +} diff --git a/src/core/filter/project/__mocks__/project.wpsFilter.ts b/src/core/filter/project/__mocks__/project.filter.ts similarity index 56% rename from src/core/filter/project/__mocks__/project.wpsFilter.ts rename to src/core/filter/project/__mocks__/project.filter.ts index 8e7cfa3..756c83d 100644 --- a/src/core/filter/project/__mocks__/project.wpsFilter.ts +++ b/src/core/filter/project/__mocks__/project.filter.ts @@ -1,8 +1,8 @@ import { injectable } from "inversify"; -import ProjectWPsFilter from "../project.wpsFilter.interface"; +import ProjectsFilter from "../project.filter.interface"; @injectable() -export default class ProjectWPsFilterImpl implements ProjectWPsFilter { +export default class ProjectsFilterImpl implements ProjectsFilter { filter = jest.fn(); onFilterUpdated = jest.fn(); diff --git a/src/core/filter/project/project.filter.interface.ts b/src/core/filter/project/project.filter.interface.ts new file mode 100644 index 0000000..2663bd0 --- /dev/null +++ b/src/core/filter/project/project.filter.interface.ts @@ -0,0 +1,7 @@ +import { Project } from "op-client"; +import Filter from "../filter.interface"; + +export default interface ProjectsFilter extends Filter { + getProjectFilter(): number[] | undefined; + setProjectFilter(projectIds: number[]): void; +} diff --git a/src/core/filter/project/project.filter.spec.ts b/src/core/filter/project/project.filter.spec.ts new file mode 100644 index 0000000..63f2db6 --- /dev/null +++ b/src/core/filter/project/project.filter.spec.ts @@ -0,0 +1,66 @@ +import { faker } from "@faker-js/faker"; +import { Project } from "op-client"; +import container from "../../../DI/container"; +import TOKENS from "../../../DI/tokens"; +import ProjectsFilterImpl from "./project.filter"; + +describe("WPs project filter test suite", () => { + let filter: ProjectsFilterImpl; + + beforeAll(() => { + filter = container.get(TOKENS.projectFilter); + }); + + describe("Filter", () => { + const p1 = { id: 1 }; + const p2 = { id: 2 }; + const p3 = { id: 3 }; + const projects = [p1, p2, p3] as Project[]; + + it("should return all projects if projectFilter is undefined", () => { + const result = filter.filter(projects); + expect(result).toEqual(projects); + }); + it("should return all projects if projectFilter contains all ids", () => { + filter.setProjectFilter([1, 2, 3]); + const result = filter.filter(projects); + expect(result).toEqual(projects); + }); + it("should return projects from project 1", () => { + filter.setProjectFilter([1]); + const result = filter.filter(projects); + expect(result).toEqual([p1]); + }); + it("should return projects from projects 1 and 2", () => { + filter.setProjectFilter([1, 2]); + const result = filter.filter(projects); + expect(result).toEqual(expect.arrayContaining([p1, p2])); + }); + it("should return empty array", () => { + filter.setProjectFilter([4]); + const result = filter.filter(projects); + expect(result).toEqual([]); + }); + }); + + describe("setProjectFilter", () => { + it("should set project filter", () => { + const projectIds = faker.helpers.uniqueArray(faker.number.int, 5); + filter.setProjectFilter(projectIds); + expect(filter.getProjectFilter()).toEqual(projectIds); + }); + it("should emit filter updated event", () => { + const projectIds = faker.helpers.uniqueArray(faker.number.int, 5); + filter.setProjectFilter(projectIds); + expect(filter["_onFilterUpdated"].fire).toHaveBeenCalled(); + }); + }); + + describe("onFilterUpdated", () => { + it("should bind listener", () => { + const func = jest.fn(); + filter.onFilterUpdated(func); + expect(filter["_onFilterUpdated"].event).toHaveBeenLastCalledWith(func); + }); + }); +}); diff --git a/src/core/filter/project/project.wpsFilter.ts b/src/core/filter/project/project.filter.ts similarity index 59% rename from src/core/filter/project/project.wpsFilter.ts rename to src/core/filter/project/project.filter.ts index 9870f62..d2f4654 100644 --- a/src/core/filter/project/project.wpsFilter.ts +++ b/src/core/filter/project/project.filter.ts @@ -1,20 +1,19 @@ import { injectable } from "inversify"; -import { WP } from "op-client"; +import { Project } from "op-client"; import * as vscode from "vscode"; -import ProjectWPsFilter from "./project.wpsFilter.interface"; +import ProjectsFilter from "./project.filter.interface"; @injectable() -export default class ProjectWPsFilterImpl implements ProjectWPsFilter { +export default class ProjectsFilterImpl implements ProjectsFilter { private _projectIds?: number[] = undefined; private _onFilterUpdated: vscode.EventEmitter = new vscode.EventEmitter(); - filter(wps: WP[]): WP[] { - return wps.filter( - (wp) => - this._projectIds === undefined || - this._projectIds.includes(wp.project.id), + filter(projects: Project[]): Project[] { + return projects.filter( + (project) => + this._projectIds === undefined || this._projectIds.includes(project.id), ); } diff --git a/src/core/filter/project/project.wpsFilter.interface.ts b/src/core/filter/project/project.wpsFilter.interface.ts deleted file mode 100644 index df476be..0000000 --- a/src/core/filter/project/project.wpsFilter.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import WPsFilter from "../wpsFilter.interface"; - -export default interface ProjectWPsFilter extends WPsFilter { - getProjectFilter(): number[] | undefined; - setProjectFilter(projectIds: number[]): void; -} diff --git a/src/core/filter/project/project.wpsFilter.spec.ts b/src/core/filter/project/project.wpsFilter.spec.ts deleted file mode 100644 index d6ac6f8..0000000 --- a/src/core/filter/project/project.wpsFilter.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { faker } from "@faker-js/faker"; -import { WP } from "op-client"; -import container from "../../../DI/container"; -import TOKENS from "../../../DI/tokens"; -import ProjectWPsFilterImpl from "./project.wpsFilter"; - -describe("WPs project filter test suite", () => { - let filter: ProjectWPsFilterImpl; - - beforeAll(() => { - filter = container.get(TOKENS.projectFilter); - }); - - describe("Filter", () => { - const helloWorldWP = { - subject: "Hello world!", - author: { - name: "goodhumored", - firstName: "Kirill", - lastName: "Nekrasov", - login: "goodhumored", - }, - body: { - description: { - raw: "Lorem ipsum dolor amet...", - }, - }, - project: { - id: 1, - }, - } as WP; - const easterEggWP = { - subject: "Lorem ipsum!", - author: { - name: "goodhumored", - firstName: "Kirill", - lastName: "Nekrasov", - login: "goodhumored", - }, - body: { - description: { - raw: "Easter egg!", - }, - }, - project: { - id: 2, - }, - } as WP; - const dannyWP = { - subject: "Title", - author: { - name: "dannyweiss", - firstName: "Danila", - lastName: "Smolyakov", - login: "dannyweiss", - }, - body: { - description: { - raw: "Lorem ipsum", - }, - }, - project: { - id: 2, - }, - } as WP; - const bugWP = { - subject: "Bug!", - author: { - name: "Svante Kaiser", - firstName: "Sviat", - lastName: "Tsarev", - login: "svante_kaiser", - }, - body: { - description: { - raw: faker.lorem.text(), - }, - }, - project: { - id: 0, - }, - } as WP; - - const wps: WP[] = [helloWorldWP, easterEggWP, dannyWP, bugWP]; - - it("should return all wps if projectFilter is undefined", () => { - const result = filter.filter(wps); - expect(result).toEqual(wps); - }); - it("should return all wps if projectFilter contains all ids", () => { - filter.setProjectFilter([0, 1, 2]); - const result = filter.filter(wps); - expect(result).toEqual(wps); - }); - it("should return wps from project 1", () => { - filter.setProjectFilter([1]); - const result = filter.filter(wps); - expect(result).toEqual([helloWorldWP]); - }); - it("should return wps from project 2", () => { - filter.setProjectFilter([2]); - const result = filter.filter(wps); - expect(result).toEqual([easterEggWP, dannyWP]); - }); - it("should return wps from projects 1 and 2", () => { - filter.setProjectFilter([1, 2]); - const result = filter.filter(wps); - expect(result).toEqual( - expect.arrayContaining([helloWorldWP, dannyWP, easterEggWP]), - ); - }); - it("should return empty array", () => { - filter.setProjectFilter([3]); - const result = filter.filter(wps); - expect(result).toEqual([]); - }); - }); - - describe("setProjectFilter", () => { - it("should set project filter", () => { - const projectIds = faker.helpers.uniqueArray(faker.number.int, 5); - filter.setProjectFilter(projectIds); - expect(filter.getProjectFilter()).toEqual(projectIds); - }); - it("should emit filter updated event", () => { - const projectIds = faker.helpers.uniqueArray(faker.number.int, 5); - filter.setProjectFilter(projectIds); - expect(filter["_onFilterUpdated"].fire).toHaveBeenCalled(); - }); - }); - - describe("onFilterUpdated", () => { - it("should bind listener", () => { - const func = jest.fn(); - filter.onFilterUpdated(func); - expect(filter["_onFilterUpdated"].event).toHaveBeenLastCalledWith(func); - }); - }); -}); diff --git a/src/core/filter/status/status.wpsFilter.interface.ts b/src/core/filter/status/status.wpsFilter.interface.ts index 5b56018..cf3396e 100644 --- a/src/core/filter/status/status.wpsFilter.interface.ts +++ b/src/core/filter/status/status.wpsFilter.interface.ts @@ -1,7 +1,8 @@ +import { WP } from "op-client"; import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; -import WPsFilter from "../wpsFilter.interface"; +import Filter from "../filter.interface"; -export default interface StatusWPsFilter extends WPsFilter { +export default interface StatusWPsFilter extends Filter { getStatusFilter(): WPStatus[] | undefined; setStatusFilter(wpTypes: WPStatus[]): void; } diff --git a/src/core/filter/text/text.wpsFilter.interface.ts b/src/core/filter/text/text.wpsFilter.interface.ts index dfdf0d4..58ce056 100644 --- a/src/core/filter/text/text.wpsFilter.interface.ts +++ b/src/core/filter/text/text.wpsFilter.interface.ts @@ -1,6 +1,7 @@ -import WPsFilter from "../wpsFilter.interface"; +import { WP } from "op-client"; +import Filter from "../filter.interface"; -export default interface TextWPsFilter extends WPsFilter { +export default interface TextWPsFilter extends Filter { getTextFilter(): string; setTextFilter(filter: string): void; } diff --git a/src/core/filter/wpsFilter.interface.ts b/src/core/filter/wpsFilter.interface.ts deleted file mode 100644 index 52fbdc4..0000000 --- a/src/core/filter/wpsFilter.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { WP } from "op-client"; -import * as vscode from "vscode"; - -export default interface WPsFilter { - filter(wps: WP[]): WP[]; - onFilterUpdated: vscode.Event; -} diff --git a/src/extension.ts b/src/extension.ts index 37ad676..e36176a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,3 +1,4 @@ +import { WP } from "op-client"; import * as vscode from "vscode"; import container from "./DI/container"; import TOKENS from "./DI/tokens"; @@ -6,7 +7,7 @@ import SetupFiltersCommand from "./application/commands/filter/setupFilters.comm import RefreshWPsCommand from "./application/commands/refresh/refreshWPsCommand.interface"; import OpenProjectTreeDataProvider from "./application/views/openProject.treeDataProvider"; import CompositeWPsFilter from "./core/filter/composite/composite.wpsFilter.interface"; -import WPsFilter from "./core/filter/wpsFilter.interface"; +import Filter from "./core/filter/filter.interface"; export function activate(context: vscode.ExtensionContext) { composeFilters(); @@ -54,11 +55,9 @@ function composeFilters() { const compositeFilter = container.get( TOKENS.compositeFilter, ); - const textFilter = container.get(TOKENS.textFilter); - const projectFilter = container.get(TOKENS.projectFilter); - const statusFilter = container.get(TOKENS.statusFilter); + const textFilter = container.get>(TOKENS.textFilter); + const statusFilter = container.get>(TOKENS.statusFilter); compositeFilter.pushFilter(textFilter); - compositeFilter.pushFilter(projectFilter); compositeFilter.pushFilter(statusFilter); } diff --git a/src/infrastructure/project/__mocks__/project.repository.ts b/src/infrastructure/project/__mocks__/project.repository.ts index aad9e60..9c9642d 100644 --- a/src/infrastructure/project/__mocks__/project.repository.ts +++ b/src/infrastructure/project/__mocks__/project.repository.ts @@ -9,7 +9,7 @@ export default class ProjectRepositoryImpl implements ProjectRepository { refetch = jest.fn(); - onProjectsRefetch = jest.fn(); + onProjectsChange = jest.fn(); penis = "asd"; } diff --git a/src/infrastructure/project/project.repository.interface.ts b/src/infrastructure/project/project.repository.interface.ts index 6d419c6..5efa0ed 100644 --- a/src/infrastructure/project/project.repository.interface.ts +++ b/src/infrastructure/project/project.repository.interface.ts @@ -5,5 +5,5 @@ export default interface ProjectRepository { findById(id: number): Project; findAll(): Project[]; refetch(): Promise; - onProjectsRefetch: Event; + onProjectsChange: Event; } diff --git a/src/infrastructure/project/project.repository.ts b/src/infrastructure/project/project.repository.ts index fdaef29..ea9a2ed 100644 --- a/src/infrastructure/project/project.repository.ts +++ b/src/infrastructure/project/project.repository.ts @@ -3,6 +3,7 @@ import { Project } from "op-client"; import * as vscode from "vscode"; import { Event } from "vscode"; import TOKENS from "../../DI/tokens"; +import ProjectsFilter from "../../core/filter/project/project.filter.interface"; import OpenProjectClient from "../openProject/openProject.client.interface"; import ProjectRepository from "./project.repository.interface"; import ProjectNotFoundException from "./projectNotFount.exception"; @@ -11,29 +12,36 @@ import ProjectNotFoundException from "./projectNotFount.exception"; export default class ProjectRepositoryImpl implements ProjectRepository { private _projects: Project[] = []; - private _onProjectsRefetch: vscode.EventEmitter = + private _onProjectsChange: vscode.EventEmitter = new vscode.EventEmitter(); - onProjectsRefetch: Event = this._onProjectsRefetch.event; + onProjectsChange: Event = this._onProjectsChange.event; constructor( @inject(TOKENS.opClient) private readonly _client: OpenProjectClient, - ) {} + @inject(TOKENS.projectFilter) private readonly _filter: ProjectsFilter, + ) { + _filter.onFilterUpdated(() => this._onProjectsChange.fire()); + } + + getFilteredProjects() { + return this._filter.filter(this._projects); + } findById(id: number): Project { - const result = this._projects.find((wp) => wp.id === id); + const result = this.getFilteredProjects().find((wp) => wp.id === id); if (!result) throw new ProjectNotFoundException(); return result; } findAll(): Project[] { - return this._projects; + return this.getFilteredProjects(); } refetch(): Promise { return this._client.getProjects().then((projects) => { this._projects = projects; - this._onProjectsRefetch.fire(); + this._onProjectsChange.fire(); }); } } diff --git a/src/infrastructure/workPackage/wp.repository.ts b/src/infrastructure/workPackage/wp.repository.ts index f11cd09..815801a 100644 --- a/src/infrastructure/workPackage/wp.repository.ts +++ b/src/infrastructure/workPackage/wp.repository.ts @@ -3,7 +3,7 @@ import { WP } from "op-client"; import * as vscode from "vscode"; import { Event } from "vscode"; import TOKENS from "../../DI/tokens"; -import WPsFilter from "../../core/filter/wpsFilter.interface"; +import Filter from "../../core/filter/filter.interface"; import OpenProjectClient from "../openProject/openProject.client.interface"; import WPRepository from "./wp.repository.interface"; import WPNotFoundException from "./wpNotFount.exception"; @@ -19,7 +19,7 @@ export default class WPRepositoryImpl implements WPRepository { constructor( @inject(TOKENS.opClient) private readonly _client: OpenProjectClient, - @inject(TOKENS.compositeFilter) private readonly _filter: WPsFilter, + @inject(TOKENS.compositeFilter) private readonly _filter: Filter, ) { _filter.onFilterUpdated(() => this._onWPsChange.fire()); } From e63ff5149054104401f32872f0ddbdc211ff0880 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Tue, 13 Jun 2023 00:41:08 +0300 Subject: [PATCH 18/38] no author title case tested --- src/core/filter/text/text.wpsFilter.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/filter/text/text.wpsFilter.spec.ts b/src/core/filter/text/text.wpsFilter.spec.ts index 1cc1798..7053678 100644 --- a/src/core/filter/text/text.wpsFilter.spec.ts +++ b/src/core/filter/text/text.wpsFilter.spec.ts @@ -42,7 +42,7 @@ describe("WPs text filter test suite", () => { const bugWP = { subject: "Bug!", author: { - self: { title: "Svante Kaiser" }, + self: { title: undefined }, }, body: {}, } as WP; From 5c8405bbbebd8c117567b52d5a635e4af72e8749 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Tue, 13 Jun 2023 00:55:11 +0300 Subject: [PATCH 19/38] filtering moved from repository to treeDataProvider --- .../openProject.treeDataProvider.spec.ts | 49 +++++++++++++++++-- .../views/openProject.treeDataProvider.ts | 19 +++++-- .../project/project.repository.ts | 14 ++---- .../workPackage/wp.repository.ts | 16 ++---- 4 files changed, 67 insertions(+), 31 deletions(-) diff --git a/src/application/views/openProject.treeDataProvider.spec.ts b/src/application/views/openProject.treeDataProvider.spec.ts index f4a1dc0..520b0e8 100644 --- a/src/application/views/openProject.treeDataProvider.spec.ts +++ b/src/application/views/openProject.treeDataProvider.spec.ts @@ -6,6 +6,7 @@ import { faker } from "@faker-js/faker"; import { Project, WP } from "op-client"; import container from "../../DI/container"; import TOKENS from "../../DI/tokens"; +import Filter from "../../core/filter/filter.interface"; import OpenProjectClient from "../../infrastructure/openProject/openProject.client.interface"; import ProjectRepository from "../../infrastructure/project/project.repository.interface"; import WPRepository from "../../infrastructure/workPackage/wp.repository.interface"; @@ -22,6 +23,8 @@ describe("OpenProjectTreeDataProvider", () => { TOKENS.projectRepository, ); const client = container.get(TOKENS.opClient); + const wpFilter = container.get>(TOKENS.compositeFilter); + const projectFilter = container.get>(TOKENS.projectFilter); afterEach(() => { jest.restoreAllMocks(); @@ -31,22 +34,62 @@ describe("OpenProjectTreeDataProvider", () => { it("should subscribe to wpRepo onWPsChange", () => { jest.spyOn(wpRepo, "onWPsChange"); - new OpenProjectTreeDataProviderImpl(wpRepo, projectRepo, client); + new OpenProjectTreeDataProviderImpl( + wpRepo, + projectRepo, + client, + wpFilter, + projectFilter, + ); expect(wpRepo.onWPsChange).toHaveBeenCalled(); }); it("should subscribe to projectRepo onProjectsRefetch", () => { jest.spyOn(projectRepo, "onProjectsChange"); - new OpenProjectTreeDataProviderImpl(wpRepo, projectRepo, client); + new OpenProjectTreeDataProviderImpl( + wpRepo, + projectRepo, + client, + wpFilter, + projectFilter, + ); expect(projectRepo.onProjectsChange).toHaveBeenCalled(); }); it("should subscribe to client onInit", () => { jest.spyOn(client, "onInit"); - new OpenProjectTreeDataProviderImpl(wpRepo, projectRepo, client); + new OpenProjectTreeDataProviderImpl( + wpRepo, + projectRepo, + client, + wpFilter, + projectFilter, + ); expect(client.onInit).toHaveBeenCalled(); }); + it("should subscribe to wpFilter onFilterUpdated", () => { + jest.spyOn(wpFilter, "onFilterUpdated"); + new OpenProjectTreeDataProviderImpl( + wpRepo, + projectRepo, + client, + wpFilter, + projectFilter, + ); + expect(wpFilter.onFilterUpdated).toHaveBeenCalled(); + }); + it("should subscribe to projectFilter onFilterUpdated", () => { + jest.spyOn(projectFilter, "onFilterUpdated"); + new OpenProjectTreeDataProviderImpl( + wpRepo, + projectRepo, + client, + wpFilter, + projectFilter, + ); + expect(projectFilter.onFilterUpdated).toHaveBeenCalled(); + }); }); describe("getTreeItem", () => { diff --git a/src/application/views/openProject.treeDataProvider.ts b/src/application/views/openProject.treeDataProvider.ts index a236479..41ff121 100644 --- a/src/application/views/openProject.treeDataProvider.ts +++ b/src/application/views/openProject.treeDataProvider.ts @@ -3,6 +3,7 @@ import { Project, WP } from "op-client"; import * as vscode from "vscode"; import { Event, ProviderResult, TreeItem } from "vscode"; import TOKENS from "../../DI/tokens"; +import Filter from "../../core/filter/filter.interface"; import OpenProjectClient from "../../infrastructure/openProject/openProject.client.interface"; import ProjectRepository from "../../infrastructure/project/project.repository.interface"; import WPRepository from "../../infrastructure/workPackage/wp.repository.interface"; @@ -24,11 +25,15 @@ export default class OpenProjectTreeDataProviderImpl private readonly _wpRepository: WPRepository, @inject(TOKENS.projectRepository) private readonly _projectRepository: ProjectRepository, - @inject(TOKENS.opClient) - _client: OpenProjectClient, + @inject(TOKENS.opClient) _client: OpenProjectClient, + @inject(TOKENS.compositeFilter) private readonly _wpFilter: Filter, + @inject(TOKENS.projectFilter) + private readonly _projectFilter: Filter, ) { _wpRepository.onWPsChange(() => this._onDidChangeTreeData.fire()); _projectRepository.onProjectsChange(() => this._onDidChangeTreeData.fire()); + _wpFilter.onFilterUpdated(() => this._onDidChangeTreeData.fire()); + _projectFilter.onFilterUpdated(() => this._onDidChangeTreeData.fire()); _client.onInit(this.refresh, this); } @@ -42,12 +47,16 @@ export default class OpenProjectTreeDataProviderImpl parentElement?: WP | Project | undefined, ): ProviderResult { if (!parentElement) { - return this._projectRepository.findAll(); + return this._projectFilter.filter(this._projectRepository.findAll()); } if (parentElement instanceof Project) { - return this._wpRepository.findByProjectId(parentElement.id); + return this._wpFilter.filter( + this._wpRepository.findByProjectId(parentElement.id), + ); } - return this._wpRepository.findByParentId(parentElement.id); + return this._wpFilter.filter( + this._wpRepository.findByParentId(parentElement.id), + ); } getParent(element: WP | Project): ProviderResult { diff --git a/src/infrastructure/project/project.repository.ts b/src/infrastructure/project/project.repository.ts index ea9a2ed..b63d08e 100644 --- a/src/infrastructure/project/project.repository.ts +++ b/src/infrastructure/project/project.repository.ts @@ -3,7 +3,6 @@ import { Project } from "op-client"; import * as vscode from "vscode"; import { Event } from "vscode"; import TOKENS from "../../DI/tokens"; -import ProjectsFilter from "../../core/filter/project/project.filter.interface"; import OpenProjectClient from "../openProject/openProject.client.interface"; import ProjectRepository from "./project.repository.interface"; import ProjectNotFoundException from "./projectNotFount.exception"; @@ -19,23 +18,16 @@ export default class ProjectRepositoryImpl implements ProjectRepository { constructor( @inject(TOKENS.opClient) private readonly _client: OpenProjectClient, - @inject(TOKENS.projectFilter) private readonly _filter: ProjectsFilter, - ) { - _filter.onFilterUpdated(() => this._onProjectsChange.fire()); - } - - getFilteredProjects() { - return this._filter.filter(this._projects); - } + ) {} findById(id: number): Project { - const result = this.getFilteredProjects().find((wp) => wp.id === id); + const result = this._projects.find((wp) => wp.id === id); if (!result) throw new ProjectNotFoundException(); return result; } findAll(): Project[] { - return this.getFilteredProjects(); + return this._projects; } refetch(): Promise { diff --git a/src/infrastructure/workPackage/wp.repository.ts b/src/infrastructure/workPackage/wp.repository.ts index 815801a..ed5aa6d 100644 --- a/src/infrastructure/workPackage/wp.repository.ts +++ b/src/infrastructure/workPackage/wp.repository.ts @@ -3,7 +3,6 @@ import { WP } from "op-client"; import * as vscode from "vscode"; import { Event } from "vscode"; import TOKENS from "../../DI/tokens"; -import Filter from "../../core/filter/filter.interface"; import OpenProjectClient from "../openProject/openProject.client.interface"; import WPRepository from "./wp.repository.interface"; import WPNotFoundException from "./wpNotFount.exception"; @@ -19,14 +18,7 @@ export default class WPRepositoryImpl implements WPRepository { constructor( @inject(TOKENS.opClient) private readonly _client: OpenProjectClient, - @inject(TOKENS.compositeFilter) private readonly _filter: Filter, - ) { - _filter.onFilterUpdated(() => this._onWPsChange.fire()); - } - - private getProcessedWPs(): WP[] { - return this._filter.filter(this._wps); - } + ) {} findById(id: number): WP { const result = this._wps.find((wp) => wp.id === id); @@ -35,15 +27,15 @@ export default class WPRepositoryImpl implements WPRepository { } findByParentId(parentId: number): WP[] { - return this.getProcessedWPs().filter((wp) => wp.parent?.id === parentId); + return this._wps.filter((wp) => wp.parent?.id === parentId); } findByProjectId(projectId: number): WP[] { - return this.getProcessedWPs().filter((wp) => wp.project.id === projectId); + return this._wps.filter((wp) => wp.project.id === projectId); } findAll(): WP[] { - return this.getProcessedWPs(); + return this._wps; } refetch(): Promise { From 9c7f0696d4c7e9c1e6774d688a185b413c7cd0d4 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Tue, 13 Jun 2023 00:55:43 +0300 Subject: [PATCH 20/38] sorter removed temporarily --- src/core/sorter/wps.sorter.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/core/sorter/wps.sorter.ts diff --git a/src/core/sorter/wps.sorter.ts b/src/core/sorter/wps.sorter.ts deleted file mode 100644 index e69de29..0000000 From 878a9eb821f15deb057c926ed1170c481b45cb8d Mon Sep 17 00:00:00 2001 From: goodhumored Date: Tue, 13 Jun 2023 01:16:00 +0300 Subject: [PATCH 21/38] filter and refresh icons replaced with built in --- package.json | 24 +++++++++++++++--------- resources/filter.png | Bin 349 -> 0 bytes resources/filter_light.png | Bin 545 -> 0 bytes resources/refresh.png | Bin 390 -> 0 bytes resources/refresh_light.png | Bin 638 -> 0 bytes 5 files changed, 15 insertions(+), 9 deletions(-) delete mode 100644 resources/filter.png delete mode 100644 resources/filter_light.png delete mode 100644 resources/refresh.png delete mode 100644 resources/refresh_light.png diff --git a/package.json b/package.json index 6dfd235..73d6734 100644 --- a/package.json +++ b/package.json @@ -19,25 +19,26 @@ { "command": "openproject.auth", "title": "Authorize", - "shortTitle": "Auth" + "shortTitle": "Auth", + "icon": "$(account)" }, { "command": "openproject.refresh", "title": "Refresh work packages", "shortTitle": "Refresh", - "icon": { - "dark": "resources/refresh_light.png", - "light": "resources/refresh.png" - } + "icon": "$(sync)" }, { "command": "openproject.setupFilter", "title": "Filter work packages", "shortTitle": "Filter", - "icon": { - "dark": "resources/filter_light.png", - "light": "resources/filter.png" - } + "icon": "$(filter)" + }, + { + "command": "workbench.actions.treeView.openproject-workspaces.collapseAll", + "title": "Collapse all", + "shortTitle": "Collapse", + "icon": "$(collapse-all)" } ], "menus": { @@ -51,6 +52,11 @@ "command": "openproject.setupFilter", "group": "navigation", "when": "view == openproject-workspaces" + }, + { + "command": "workbench.actions.treeView.openproject-workspaces.collapseAll", + "group": "navigation", + "when": "view == openproject-workspaces" } ] }, diff --git a/resources/filter.png b/resources/filter.png deleted file mode 100644 index f944228c25cb05e25ad42047e9f2a1aa1ec6b2c9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 349 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#X!0Ygc<#+)i{6*$r9Iy zlHmNblJdl&REF~Ma=pyF?Be9af>gcyqV(DCY@~pSUU|AWhFJ7&y}Z}U*-_x=$M{8@ zE;=m@%Qgf~Xjt3puP{;MrAy7MC0`Za1pQ>Vb%S%`#;Ghv4!La*l|GnJsQ!fQojcEu z_QZ!9p1*m+=j|je?DH`=D7dGPOZ3eH*6llvZBM#Z&?fxGS%1=Yub#|V4_}6T6v>VK zqxitaO7?sU!kK|SAUM*A!HhJs?y;H>$(rd>usJLWd!<~!PC{xWt~$(69BIelm!3) diff --git a/resources/filter_light.png b/resources/filter_light.png deleted file mode 100644 index a38735e65a8ebc63f0bcd93b78f318b98c0d518e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 545 zcmV++0^a?JP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0063uBQgL0010qN zS#tmY0AK(B0AK*{YeLTe000McNliru=LQTB8!ht=l_~%L0ftFLK~y-)wUe<+LSYoe z@Au*fij#skv?PdNqXaGm4uu8b)^m1f3TX(Mf+PrXjOMn6`UirdB?^UT?pgAXmY^vp zqj$dNcUlT6)FkM@-R}8u@8Mn%5eYWt=XJq#@nQ@>4y_ksbis9l)nZXZMA0M?`rPx> z-RUWJ4-a|O>&XzQsj)FtbsVN=XYH9(O8oNTB0!R{n2abg9tX%^XJNr&{pLox#srh) z@~D7d(sAUe*9+3EmWYTTQmdf@eC{>Wyjm=x10YhXeHlq9o!Z~WWw)zW^7%n#AeY0* z^Rspi4oIcG?`3Qo%g4vqeth)f>GV(Y+w{Z))*c?PcXmb-3JnM+5<#cg)K0rilSq6G zO&45O%k?!)H2O=z$)v7$9$JkC84i=-Fj|dw50v=KJU!5WDqIS%$cW00000NkvXXu0mjfeR1j- diff --git a/resources/refresh.png b/resources/refresh.png deleted file mode 100644 index f5918c6c7833e4dd7a30ae876d1e6cff002a816b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 390 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#X!0Ygc<#+)i{6*$r9Iy zlHmNblJdl&REF~Ma=pyF?Be9af>gcyqV(DCY@`?%7==Au978O6=UzUm=Nu@)`hcIe zX<@s9py@|rEoa%85|s?K()F$tq92V7r*>R9;gJ;J$tmn5+FZOY;QKwb*pnxBme1cC z_U&eR?!UzyvR5t~3I4M#$w>7-yYmfq>3GT4!qE{O) z1*(D;9gWdGPeUR-g=h4o?k`c~f3VPbLCgWGUxgYb+T6Kol5GC%f6TIO@vRs>OJUYK z4}S=oe4_L&>0X}D8<7moZMum(%1zH#S9DvtH=XTlFV*2Po?z}He`tx?U$3Jq+X`Ec hSKRl`@!0sw{NAsq-qZ3)y}&SJ@O1TaS?83{1OOBdmtz0` diff --git a/resources/refresh_light.png b/resources/refresh_light.png deleted file mode 100644 index 858ad9f5ed2f8d1bc41e97d367f3fa32ac3cdfd0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 638 zcmV-^0)hRBP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0063uBQgL0010qN zS#tmY0AK(B0AK*{YeLTe000McNliru=LQN810h@rbh7{e0pm$TK~y-)os%(08(|oR zzjz@zjC3f{v9pF~vgkQQyO}8+gn*NiSxUj7i;zL2Q-jfq32_nE%-tl$4hp4U3HDNH zgHS;smYA4QOz!=1_jR}>tx2GH;UAxemzVD+`X@*RgDB`a`wau-b{qNod-fX!PSa`g z!{j9Oi`ZT-eq^&~SQa4C^ZmAMO5ImvR1~&iHPVOpllo+5r@iUK!jh+ z&e9NZ(^{oM-5a!j zM5H{c)!e_1j|Vn*xx7q7Bzmz(okS$^*R|=jnuwzGgOLw~P-?Z%uq;dk0%UGRqum1b z#N&P8WH87Z*VljuKJV@l5j_&%h8Y)`3}1%BVvmi9jE-_l({P$jqhgwX2oi^fBC6^P z64NxSSF2o~pT|KeHSjb2>~v7jb+MhjddAF6OyK>_4nIFX$9}zzQme&JOC@BtwtAiV YA8}!mvl*szl>h($07*qoM6N<$f?~}Nr2qf` From 0e7bcbbb14f6fab58d33e7e5b2d061b773814399 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Tue, 13 Jun 2023 23:35:44 +0300 Subject: [PATCH 22/38] no undef off eslint --- .eslintrc.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.eslintrc.json b/.eslintrc.json index f4e5b5c..f27928b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -264,6 +264,7 @@ "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "warn", "import/no-cycle": "off", - "no-shadow": "off" + "no-shadow": "off", + "no-undef": "off" } } \ No newline at end of file From 1e4d51e84555fd6512c45c73aa1c4cae14a7bf67 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Tue, 13 Jun 2023 23:40:42 +0300 Subject: [PATCH 23/38] wp status and project quick picks moved to their own files --- src/DI/tokens.ts | 1 + .../filter/setupFilters.command.spec.ts | 210 +++++------------- .../commands/filter/setupFilters.command.ts | 59 ++--- .../setWpStatus/setWPStatus.command.ts | 0 .../project/project.quickPick.spec.ts | 102 +++++++++ .../quickPicks/project/project.quickPick.ts | 50 +++++ .../project}/project.quickPickItem.spec.ts | 0 .../project}/project.quickPickItem.ts | 0 .../wpStatus/wpStatus.quickPick.spec.ts | 80 +++++++ .../quickPicks/wpStatus/wpStatus.quickPick.ts | 48 ++++ .../wpStatus}/wpStatus.quickPickItem.spec.ts | 0 .../wpStatus}/wpStatus.quickPickItem.ts | 2 +- .../workPackage/wp.repository.spec.ts | 8 - 13 files changed, 360 insertions(+), 200 deletions(-) create mode 100644 src/application/commands/setWpStatus/setWPStatus.command.ts create mode 100644 src/application/quickPicks/project/project.quickPick.spec.ts create mode 100644 src/application/quickPicks/project/project.quickPick.ts rename src/application/{commands/filter/quickPickItems => quickPicks/project}/project.quickPickItem.spec.ts (100%) rename src/application/{commands/filter/quickPickItems => quickPicks/project}/project.quickPickItem.ts (100%) create mode 100644 src/application/quickPicks/wpStatus/wpStatus.quickPick.spec.ts create mode 100644 src/application/quickPicks/wpStatus/wpStatus.quickPick.ts rename src/application/{commands/filter/quickPickItems => quickPicks/wpStatus}/wpStatus.quickPickItem.spec.ts (100%) rename src/application/{commands/filter/quickPickItems => quickPicks/wpStatus}/wpStatus.quickPickItem.ts (79%) diff --git a/src/DI/tokens.ts b/src/DI/tokens.ts index 9052616..9fc2349 100644 --- a/src/DI/tokens.ts +++ b/src/DI/tokens.ts @@ -7,6 +7,7 @@ const comandTokens = { refreshWPsCommand: Symbol.for("RefreshWPsCommand"), authorizeCommand: Symbol.for("AuthorizeClientCommand"), setupFiltersCommand: Symbol.for("FilterWPsCommand"), + setWPStatusCommand: Symbol.for("SetWPStatusCommand"), }; const filterTokens = { diff --git a/src/application/commands/filter/setupFilters.command.spec.ts b/src/application/commands/filter/setupFilters.command.spec.ts index 913c86f..31072ee 100644 --- a/src/application/commands/filter/setupFilters.command.spec.ts +++ b/src/application/commands/filter/setupFilters.command.spec.ts @@ -2,6 +2,8 @@ jest.mock("../../../core/filter/project/project.filter"); jest.mock("../../../core/filter/status/status.wpsFilter"); jest.mock("../../../core/filter/text/text.wpsFilter"); jest.mock("../../../infrastructure/project/project.repository"); +jest.mock("../../quickPicks/project/project.quickPick"); +jest.mock("../../quickPicks/wpStatus/wpStatus.quickPick"); import { faker } from "@faker-js/faker"; import { Project } from "op-client"; @@ -13,6 +15,8 @@ import StatusWPsFilter from "../../../core/filter/status/status.wpsFilter.interf import TextWPsFilter from "../../../core/filter/text/text.wpsFilter.interface"; import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; import ProjectRepository from "../../../infrastructure/project/project.repository.interface"; +import ProjectQuickPick from "../../quickPicks/project/project.quickPick"; +import WPStatusQuickPick from "../../quickPicks/wpStatus/wpStatus.quickPick"; import SetupFiltersCommandImpl from "./setupFilters.command"; describe("filter WPs command test suite", () => { @@ -84,32 +88,37 @@ describe("filter WPs command test suite", () => { }); }); describe("setupProjectFilter", () => { + const projects = faker.helpers.uniqueArray( + () => new Project(faker.number.int()), + 10, + ); + const projectIds = projects.map((p) => p.id); + it("should show quickpick", async () => { - jest.spyOn(vscode.window, "showQuickPick"); - jest.spyOn(command, "getProjectIdQuickPickItems").mockReturnValue([]); + jest.spyOn(projectRepo, "findAll").mockReturnValue([]); + jest.spyOn(ProjectQuickPick.prototype, "show").mockResolvedValue([]); await command.setupProjectFilter(); - expect(vscode.window.showQuickPick).toHaveBeenCalled(); + expect(ProjectQuickPick.prototype.show).toHaveBeenCalled(); }); - it("should get quickpick items from getProjectFilterItems function", async () => { - const items: any[] = []; - - jest.spyOn(command, "getProjectIdQuickPickItems").mockReturnValue(items); + it("should setProjectFilter results", async () => { + jest + .spyOn(ProjectQuickPick.prototype, "show") + .mockResolvedValue(projectIds); + jest.spyOn(projectRepo, "findAll").mockReturnValue(projects); + jest.spyOn(projectFilter, "setProjectFilter"); await command.setupProjectFilter(); - expect(vscode.window.showQuickPick).toHaveBeenLastCalledWith( - items, - expect.anything(), + expect(projectFilter.setProjectFilter).toHaveBeenLastCalledWith( + projectIds, ); }); - it("should setProjectFilter results", async () => { - const projectIds = faker.helpers.uniqueArray(faker.number.int, 5); - const results = projectIds.map((num) => ({ projectId: num })); - - jest.spyOn(command, "getProjectIdQuickPickItems").mockReturnValue([]); + it("should setProjectFilter filter if got undefined", async () => { + jest.spyOn(projectRepo, "findAll").mockReturnValue([]); + jest.spyOn(projectFilter, "getProjectFilter").mockReturnValue(projectIds); jest - .spyOn(vscode.window, "showQuickPick") - .mockResolvedValue(results as any); + .spyOn(ProjectQuickPick.prototype, "show") + .mockResolvedValue(undefined); jest.spyOn(projectFilter, "setProjectFilter"); await command.setupProjectFilter(); @@ -118,168 +127,61 @@ describe("filter WPs command test suite", () => { projectIds, ); }); - it("should setProjectFilter empty array", async () => { - jest.spyOn(command, "getProjectIdQuickPickItems").mockReturnValue([]); - jest.spyOn(vscode.window, "showQuickPick").mockResolvedValue(undefined); + it("should setProjectFilter projectIds if got undefined and filter is undefined", async () => { + jest.spyOn(projectRepo, "findAll").mockReturnValue(projects); + jest.spyOn(projectFilter, "getProjectFilter").mockReturnValue(undefined); + jest + .spyOn(ProjectQuickPick.prototype, "show") + .mockResolvedValue(undefined); jest.spyOn(projectFilter, "setProjectFilter"); await command.setupProjectFilter(); - expect(projectFilter.setProjectFilter).toHaveBeenLastCalledWith([]); + expect(projectFilter.setProjectFilter).toHaveBeenLastCalledWith( + projectIds, + ); }); }); describe("setupStatusFilter", () => { + const statuses = Object.values(WPStatus); + it("should show quickpick", async () => { - jest.spyOn(vscode.window, "showQuickPick"); + jest.spyOn(WPStatusQuickPick.prototype, "show").mockResolvedValue([]); await command.setupStatusFilter(); - expect(vscode.window.showQuickPick).toHaveBeenCalled(); - }); - it("should get quickpick items from getStatusFilterItems function", async () => { - const items: any[] = []; - - jest.spyOn(command, "getStatusQuickPickItems").mockReturnValue(items); - - await command.setupStatusFilter(); - - expect(vscode.window.showQuickPick).toHaveBeenLastCalledWith( - items, - expect.anything(), - ); + expect(WPStatusQuickPick.prototype.show).toHaveBeenCalled(); }); it("should setStatusFilter results", async () => { - const statuses = faker.helpers.uniqueArray(faker.string.alpha, 5); - const results = statuses.map((num) => ({ status: num })); - - jest.spyOn(command, "getStatusQuickPickItems").mockReturnValue([]); jest - .spyOn(vscode.window, "showQuickPick") - .mockResolvedValue(results as any); + .spyOn(WPStatusQuickPick.prototype, "show") + .mockResolvedValue(statuses); jest.spyOn(statusFilter, "setStatusFilter"); await command.setupStatusFilter(); expect(statusFilter.setStatusFilter).toHaveBeenLastCalledWith(statuses); }); - it("should setStatusFilter empty array", async () => { - jest.spyOn(command, "getStatusQuickPickItems").mockReturnValue([]); - jest.spyOn(vscode.window, "showQuickPick").mockResolvedValue(undefined); + it("should setStatusFilter filter if got undefined", async () => { + jest.spyOn(projectRepo, "findAll").mockReturnValue([]); + jest.spyOn(statusFilter, "getStatusFilter").mockReturnValue(statuses); + jest + .spyOn(WPStatusQuickPick.prototype, "show") + .mockResolvedValue(undefined); jest.spyOn(statusFilter, "setStatusFilter"); await command.setupStatusFilter(); - expect(statusFilter.setStatusFilter).toHaveBeenLastCalledWith([]); + expect(statusFilter.setStatusFilter).toHaveBeenLastCalledWith(statuses); }); - }); + it("should setStatusFilter statuses if got undefined and filter is undefined", async () => { + jest.spyOn(statusFilter, "getStatusFilter").mockReturnValue(undefined); + jest + .spyOn(WPStatusQuickPick.prototype, "show") + .mockResolvedValue(undefined); + jest.spyOn(statusFilter, "setStatusFilter"); - describe("getProjectIdQuickPickItems", () => { - beforeAll(() => { - (command.getProjectIdQuickPickItems as jest.Mock).mockRestore(); - }); - const projects: Project[] = [ - { - body: { name: "project1" }, - id: 1, - }, - { - body: { name: "project2" }, - id: 2, - }, - { - body: { name: "project3" }, - id: 3, - }, - ] as Project[]; + await command.setupStatusFilter(); - it("should return empty array if there are no projects", () => { - jest.spyOn(projectRepo, "findAll").mockReturnValue([]); - expect(command.getProjectIdQuickPickItems()).toEqual([]); - }); - it("should return array with the same length", () => { - jest.spyOn(projectRepo, "findAll").mockReturnValue(projects); - const items = command.getProjectIdQuickPickItems(); - expect(items).toHaveLength(projects.length); - }); - it("should return array of items with project ids and project names", () => { - jest.spyOn(projectRepo, "findAll").mockReturnValue(projects); - const items = command.getProjectIdQuickPickItems(); - items.forEach((item, index) => { - expect(item).toEqual( - expect.objectContaining>({ - projectId: projects[index].id, - label: projects[index].body.name, - }), - ); - }); - }); - it("should return array of items with project ids and project names", () => { - jest.spyOn(projectRepo, "findAll").mockReturnValue(projects); - const items = command.getProjectIdQuickPickItems(); - items.forEach((item, index) => { - expect(item).toEqual( - expect.objectContaining>({ - projectId: projects[index].id, - label: projects[index].body.name, - }), - ); - }); - }); - it("should mark projects from getProjectFilter as picked", () => { - const filter = [projects[0].id, projects[1].id]; - jest.spyOn(projectRepo, "findAll").mockReturnValue(projects); - jest.spyOn(projectFilter, "getProjectFilter").mockReturnValue(filter); - const items = command.getProjectIdQuickPickItems(); - items.forEach((item, index) => { - expect(item).toEqual( - expect.objectContaining>({ - picked: filter.includes(projects[index].id), - }), - ); - }); - }); - it("should mark all projects as picked if getProjectFilter returned undefined", () => { - const filter = undefined; - jest.spyOn(projectRepo, "findAll").mockReturnValue(projects); - jest.spyOn(projectFilter, "getProjectFilter").mockReturnValue(filter); - const items = command.getProjectIdQuickPickItems(); - items.forEach((item) => { - expect(item).toEqual( - expect.objectContaining>({ - picked: true, - }), - ); - }); - }); - }); - describe("getStatusQuickPickItems", () => { - beforeAll(() => { - (command.getStatusQuickPickItems as jest.Mock).mockRestore(); - }); - it("should return array of items for all WPStatuses", () => { - const items = command.getStatusQuickPickItems(); - expect(items).toHaveLength(Object.keys(WPStatus).length); - }); - it("should return array of items with status names", () => { - const items = command.getStatusQuickPickItems(); - items.forEach((item, index) => { - expect(item).toEqual( - expect.objectContaining>({ - status: Object.values(WPStatus)[index], - label: Object.values(WPStatus)[index], - }), - ); - }); - }); - it("should mark items from getStatusFilter as picked", () => { - const filter = [WPStatus.closed, WPStatus.inSpecification]; - jest.spyOn(statusFilter, "getStatusFilter").mockReturnValue(filter); - const items = command.getStatusQuickPickItems(); - items.forEach((item, index) => { - expect(item).toEqual( - expect.objectContaining>({ - picked: filter.includes(Object.values(WPStatus)[index]), - }), - ); - }); + expect(statusFilter.setStatusFilter).toHaveBeenLastCalledWith(statuses); }); }); }); diff --git a/src/application/commands/filter/setupFilters.command.ts b/src/application/commands/filter/setupFilters.command.ts index b9bd724..c3b41a9 100644 --- a/src/application/commands/filter/setupFilters.command.ts +++ b/src/application/commands/filter/setupFilters.command.ts @@ -6,11 +6,11 @@ import StatusWPsFilter from "../../../core/filter/status/status.wpsFilter.interf import TextWPsFilter from "../../../core/filter/text/text.wpsFilter.interface"; import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; import ProjectRepository from "../../../infrastructure/project/project.repository.interface"; -import ProjectQuickPickItem from "./quickPickItems/project.quickPickItem"; -import WPStatusQuickPickItem from "./quickPickItems/wpStatus.quickPickItem"; +import ProjectQuickPick from "../../quickPicks/project/project.quickPick"; +import WPStatusQuickPick from "../../quickPicks/wpStatus/wpStatus.quickPick"; import SetupFiltersCommand from "./setupFilters.command.interface"; -export type PayloadItem = vscode.QuickPickItem & { +type PayloadItem = vscode.QuickPickItem & { payload: T; }; @@ -58,45 +58,30 @@ export default class SetupFiltersCommandImpl implements SetupFiltersCommand { } async setupProjectFilter() { - const items = this.getProjectIdQuickPickItems(); - const results = await vscode.window.showQuickPick(items, { - canPickMany: true, - title: "Select wps of which projects you want to see: ", - }); - const projectIds = (results ?? items).map((item) => item.projectId); - this._projectFilter.setProjectFilter(projectIds); - } - - async setupStatusFilter() { - const items = this.getStatusQuickPickItems(); - const results = await vscode.window.showQuickPick(items, { - canPickMany: true, - title: "Select wps of which status you want to see: ", - }); - const statuses = (results ?? items).map((item) => item.status); - this._statusFilter.setStatusFilter(statuses); - } - - getProjectIdQuickPickItems(): ProjectQuickPickItem[] { - const filter = this._projectFilter.getProjectFilter(); const projects = this._projectRepo.findAll(); - return projects.map( - (project) => - new ProjectQuickPickItem( - project, - filter === undefined || filter.includes(project.id), - ), + const oldProjectIds = + this._projectFilter.getProjectFilter() ?? projects.map((p) => p.id); + const quickPick = new ProjectQuickPick( + "Select wps of which projects you want to see: ", + projects, + true, ); + quickPick.setPickedProjects(oldProjectIds); + const projectIds = await quickPick.show(); + this._projectFilter.setProjectFilter(projectIds ?? oldProjectIds); } - getStatusQuickPickItems(): WPStatusQuickPickItem[] { + async setupStatusFilter() { + const statuses = Object.values(WPStatus); const filter = this._statusFilter.getStatusFilter(); - return Object.values(WPStatus).map( - (status) => - new WPStatusQuickPickItem( - status, - filter === undefined || filter.includes(status), - ), + const quickPick = new WPStatusQuickPick( + "Select wps of which status you want to see: ", + true, ); + quickPick.setPickedStatuses(filter ?? statuses); + + const result = await quickPick.show(); + + this._statusFilter.setStatusFilter(result ?? statuses); } } diff --git a/src/application/commands/setWpStatus/setWPStatus.command.ts b/src/application/commands/setWpStatus/setWPStatus.command.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/application/quickPicks/project/project.quickPick.spec.ts b/src/application/quickPicks/project/project.quickPick.spec.ts new file mode 100644 index 0000000..65e6342 --- /dev/null +++ b/src/application/quickPicks/project/project.quickPick.spec.ts @@ -0,0 +1,102 @@ +import { faker } from "@faker-js/faker"; +import { Project } from "op-client"; +import * as vscode from "vscode"; +import ProjectQuickPick from "./project.quickPick"; + +describe("ProjectQuickPick test suite", () => { + describe("constructor", () => { + it("should construct quick pick without errors", () => { + const pids = faker.helpers.uniqueArray( + () => new Project(faker.number.int()), + 5, + ); + const title = faker.string.alpha(); + const multi = faker.datatype.boolean(); + + const qp = new ProjectQuickPick(title, pids, multi); + + expect(qp).toBeInstanceOf(ProjectQuickPick); + }); + }); + + describe("setPickedItems", () => { + const projects = [1, 2, 3].map((id) => new Project(id)); + + it("should set change nothing if empty array passed", () => { + const qp = new ProjectQuickPick("title", projects, true); + const itemsCopy = JSON.parse(JSON.stringify(qp["_items"])); + + qp.setPickedProjects([]); + + expect(qp["_items"]).toEqual(itemsCopy); + }); + it("should set change something if non-empty array passed", () => { + const qp = new ProjectQuickPick("title", projects, true); + const itemsCopy = JSON.parse(JSON.stringify(Object.assign(qp["_items"]))); + + qp.setPickedProjects([1]); + + expect(qp["_items"]).not.toEqual(itemsCopy); + }); + it("should mark items with passed pids as picked", () => { + const qp = new ProjectQuickPick("title", projects, true); + + qp.setPickedProjects([1]); + + expect(qp["_items"][0].picked).toBeTruthy(); + }); + }); + + describe("show", () => { + const pids = [1, 2, 3]; + const projects = pids.map((id) => new Project(id)); + + it("should call showQuickPick", async () => { + const qp = new ProjectQuickPick("title", projects, true); + jest.spyOn(vscode.window, "showQuickPick").mockResolvedValue(undefined); + await qp.show(); + expect(vscode.window.showQuickPick).toHaveBeenCalled(); + }); + + it("should call showQuickPick with items", async () => { + const qp = new ProjectQuickPick("title", projects, true); + jest.spyOn(vscode.window, "showQuickPick").mockResolvedValue(undefined); + await qp.show(); + expect(vscode.window.showQuickPick).toHaveBeenLastCalledWith( + qp["_items"], + expect.anything(), + ); + }); + + it("should call showQuickPick with multiSelect", async () => { + const qp = new ProjectQuickPick("title", projects, true); + jest.spyOn(vscode.window, "showQuickPick").mockResolvedValue(undefined); + await qp.show(); + expect(vscode.window.showQuickPick).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ canPickMany: true }), + ); + }); + + it("should call showQuickPick with title", async () => { + const qp = new ProjectQuickPick("title", projects, true); + jest.spyOn(vscode.window, "showQuickPick").mockResolvedValue(undefined); + await qp.show(); + expect(vscode.window.showQuickPick).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ title: "title" }), + ); + }); + + it("should return picked projectIds", async () => { + const qp = new ProjectQuickPick("title", projects, true); + jest.spyOn(vscode.window, "showQuickPick").mockResolvedValue( + pids.map((pid) => ({ + projectId: pid, + })) as any, + ); + const projectIds = await qp.show(); + expect(projectIds).toEqual(pids); + }); + }); +}); diff --git a/src/application/quickPicks/project/project.quickPick.ts b/src/application/quickPicks/project/project.quickPick.ts new file mode 100644 index 0000000..1f2175b --- /dev/null +++ b/src/application/quickPicks/project/project.quickPick.ts @@ -0,0 +1,50 @@ +import { Project } from "op-client"; +import * as vscode from "vscode"; +import ProjectQuickPickItem from "../../commands/filter/quickPickItems/project.quickPickItem"; + +export default class ProjectQuickPick { + private readonly _items: ProjectQuickPickItem[]; + + private readonly _multiSelect: boolean; + + private readonly _title: string; + + constructor(title: string, projects: Project[], multiSelect: T) { + this._items = this.getProjectQuickPickItems(projects); + this._multiSelect = multiSelect; + this._title = title; + } + + setPickedProjects(projects: number[] | Project[]): void { + projects.forEach((project: number | Project) => { + const projectItem = this._items.find( + (item) => + item.projectId === + (typeof project === "number" ? project : project.id), + ); + if (projectItem) projectItem.picked = true; + }); + } + + getProjectQuickPickItems(projects: Project[]): ProjectQuickPickItem[] { + return projects.map((project) => new ProjectQuickPickItem(project)); + } + + show< + TResult = (T extends true ? number[] : number) | undefined, + >(): Thenable { + return ( + vscode.window.showQuickPick(this._items, { + canPickMany: this._multiSelect, + title: this._title, + }) as Thenable< + | (T extends true ? ProjectQuickPickItem[] : ProjectQuickPickItem) + | undefined + > + ).then((result) => { + if (!result) return undefined; + if (Array.isArray(result)) return result.map((item) => item.projectId); + return result.projectId; + }) as Thenable; + } +} diff --git a/src/application/commands/filter/quickPickItems/project.quickPickItem.spec.ts b/src/application/quickPicks/project/project.quickPickItem.spec.ts similarity index 100% rename from src/application/commands/filter/quickPickItems/project.quickPickItem.spec.ts rename to src/application/quickPicks/project/project.quickPickItem.spec.ts diff --git a/src/application/commands/filter/quickPickItems/project.quickPickItem.ts b/src/application/quickPicks/project/project.quickPickItem.ts similarity index 100% rename from src/application/commands/filter/quickPickItems/project.quickPickItem.ts rename to src/application/quickPicks/project/project.quickPickItem.ts diff --git a/src/application/quickPicks/wpStatus/wpStatus.quickPick.spec.ts b/src/application/quickPicks/wpStatus/wpStatus.quickPick.spec.ts new file mode 100644 index 0000000..947b9e7 --- /dev/null +++ b/src/application/quickPicks/wpStatus/wpStatus.quickPick.spec.ts @@ -0,0 +1,80 @@ +/* eslint-disable no-new */ +import { faker } from "@faker-js/faker"; +import * as vscode from "vscode"; +import WPStatusQuickPick from "./wpStatus.quickPick"; + +describe("WPStatusQuickPick test suite", () => { + describe("constructor", () => { + it("should construct with no errors", () => { + const title = faker.string.alpha(); + const multiSelect = faker.datatype.boolean(); + new WPStatusQuickPick(title, multiSelect); + }); + }); + describe("setSelectedStatuses", () => { + let qp: WPStatusQuickPick; + + beforeEach(() => { + qp = new WPStatusQuickPick( + faker.string.alpha(), + faker.datatype.boolean(), + ); + }); + + it("should change nothing if empty array passed", () => { + const itemsCopy = [...qp["_items"]]; + qp.setPickedStatuses([]); + expect(qp["_items"]).toEqual(itemsCopy); + }); + + it("should set picked of first item to true", () => { + qp.setPickedStatuses([qp["_items"][0].status]); + expect(qp["_items"][0].picked).toBeTruthy(); + expect(qp["_items"][1].picked).toBeFalsy(); + }); + }); + describe("show", () => { + let qp: WPStatusQuickPick; + let title: string; + let multi: boolean; + beforeEach(() => { + jest.spyOn(vscode.window, "showQuickPick").mockResolvedValue(undefined); + title = faker.string.alpha(); + multi = faker.datatype.boolean(); + qp = new WPStatusQuickPick(title, multi); + }); + it("should show quick pick with items", async () => { + await qp.show(); + + expect(vscode.window.showQuickPick).toHaveBeenLastCalledWith( + qp["_items"], + expect.anything(), + ); + }); + it("should show quick pick with title", async () => { + await qp.show(); + + expect(vscode.window.showQuickPick).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ title }), + ); + }); + it("should show quick pick with title", async () => { + await qp.show(); + + expect(vscode.window.showQuickPick).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ canPickMany: multi }), + ); + }); + it("should return picked statuses", async () => { + jest + .spyOn(vscode.window, "showQuickPick") + .mockResolvedValue(qp["_items"] as any); + + const statuses = qp["_items"].map((i) => i.status); + + expect(await qp.show()).toEqual(statuses); + }); + }); +}); diff --git a/src/application/quickPicks/wpStatus/wpStatus.quickPick.ts b/src/application/quickPicks/wpStatus/wpStatus.quickPick.ts new file mode 100644 index 0000000..5766ba3 --- /dev/null +++ b/src/application/quickPicks/wpStatus/wpStatus.quickPick.ts @@ -0,0 +1,48 @@ +import * as vscode from "vscode"; +import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; +import WPStatusQuickPickItem from "./wpStatus.quickPickItem"; + +export default class WPStatusQuickPick { + private readonly _items: WPStatusQuickPickItem[]; + + private readonly _title: string; + + private readonly _multiSelect: boolean; + + constructor(title: string, multiSelect: T) { + this._title = title; + this._multiSelect = multiSelect; + this._items = this.getStatusQuickPickItems(); + } + + public setPickedStatuses(statuses: WPStatus[]) { + statuses.forEach((status) => { + const statusItem = this._items.find((item) => item.status === status); + if (statusItem) statusItem.picked = true; + }); + } + + show< + TResult = (T extends true ? WPStatus[] : WPStatus) | undefined, + >(): Thenable { + return ( + vscode.window.showQuickPick(this._items, { + canPickMany: this._multiSelect, + title: this._title, + }) as Thenable< + | (T extends true ? WPStatusQuickPickItem[] : WPStatusQuickPickItem) + | undefined + > + ).then((result) => { + if (!result) return undefined; + if (Array.isArray(result)) return result.map((item) => item.status); + return result.status; + }) as Thenable; + } + + private getStatusQuickPickItems(): WPStatusQuickPickItem[] { + return Object.values(WPStatus).map( + (status) => new WPStatusQuickPickItem(status), + ); + } +} diff --git a/src/application/commands/filter/quickPickItems/wpStatus.quickPickItem.spec.ts b/src/application/quickPicks/wpStatus/wpStatus.quickPickItem.spec.ts similarity index 100% rename from src/application/commands/filter/quickPickItems/wpStatus.quickPickItem.spec.ts rename to src/application/quickPicks/wpStatus/wpStatus.quickPickItem.spec.ts diff --git a/src/application/commands/filter/quickPickItems/wpStatus.quickPickItem.ts b/src/application/quickPicks/wpStatus/wpStatus.quickPickItem.ts similarity index 79% rename from src/application/commands/filter/quickPickItems/wpStatus.quickPickItem.ts rename to src/application/quickPicks/wpStatus/wpStatus.quickPickItem.ts index 353d958..94d5bc2 100644 --- a/src/application/commands/filter/quickPickItems/wpStatus.quickPickItem.ts +++ b/src/application/quickPicks/wpStatus/wpStatus.quickPickItem.ts @@ -1,5 +1,5 @@ import { QuickPickItem } from "vscode"; -import WPStatus from "../../../../infrastructure/openProject/wpStatus.enum"; +import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; export default class WPStatusQuickPickItem implements QuickPickItem { label: string; diff --git a/src/infrastructure/workPackage/wp.repository.spec.ts b/src/infrastructure/workPackage/wp.repository.spec.ts index 4c71434..a2e2d1b 100644 --- a/src/infrastructure/workPackage/wp.repository.spec.ts +++ b/src/infrastructure/workPackage/wp.repository.spec.ts @@ -36,14 +36,6 @@ describe("WP repository test suite", () => { await repository.refetch(); }); - describe("getProcessedWPs", () => { - it("should filter wps using filter", () => { - jest.spyOn(filter, "filter").mockReturnValue([wp2, wp3]); - const wps = repository["getProcessedWPs"](); - expect(wps).toEqual([wp2, wp3]); - }); - }); - describe("findById", () => { it("should return wp1", () => { expect(repository.findById(wp1.id)).toEqual(wp1); From e8ec1dd7723eca0f63b569d9aa11bb4a6bfc4e22 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Tue, 13 Jun 2023 23:41:01 +0300 Subject: [PATCH 24/38] project quick pick item import fixed --- src/application/quickPicks/project/project.quickPick.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/application/quickPicks/project/project.quickPick.ts b/src/application/quickPicks/project/project.quickPick.ts index 1f2175b..d1da765 100644 --- a/src/application/quickPicks/project/project.quickPick.ts +++ b/src/application/quickPicks/project/project.quickPick.ts @@ -1,6 +1,6 @@ import { Project } from "op-client"; import * as vscode from "vscode"; -import ProjectQuickPickItem from "../../commands/filter/quickPickItems/project.quickPickItem"; +import ProjectQuickPickItem from "./project.quickPickItem"; export default class ProjectQuickPick { private readonly _items: ProjectQuickPickItem[]; From 01c398be76da8471616e5e061d8853ed11bfe047 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Mon, 19 Jun 2023 02:12:05 +0300 Subject: [PATCH 25/38] user get name way changed --- .../authorize/authorizeClient.command.spec.ts | 7 +++---- .../commands/authorize/authorizeClient.command.ts | 4 +--- src/core/filter/text/text.wpsFilter.spec.ts | 13 ++++++------- src/core/filter/text/text.wpsFilter.ts | 3 ++- 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/application/commands/authorize/authorizeClient.command.spec.ts b/src/application/commands/authorize/authorizeClient.command.spec.ts index 4bcb061..1a4d62f 100644 --- a/src/application/commands/authorize/authorizeClient.command.spec.ts +++ b/src/application/commands/authorize/authorizeClient.command.spec.ts @@ -6,11 +6,11 @@ import { User } from "op-client"; import container from "../../../DI/container"; import TOKENS from "../../../DI/tokens"; import * as vscode from "../../../__mocks__/vscode"; +import ConsoleLogger from "../../../infrastructure/logger/logger"; import OpenProjectClient from "../../../infrastructure/openProject/openProject.client"; import UserNotFound from "../../../infrastructure/openProject/userNotFound.exception"; import VSCodeConfigMock from "../../../test/config.mock"; import AuthorizeClientCommandImpl from "./authorizeClient.command"; -import ConsoleLogger from "../../../infrastructure/logger/logger"; describe("Authorize client command test suite", () => { let command: AuthorizeClientCommandImpl; @@ -83,8 +83,7 @@ describe("Authorize client command test suite", () => { describe("showMessage", () => { it("should show message 'Hello' on success", async () => { const user = new User(1); - user.firstName = faker.person.firstName(); - user.lastName = faker.person.lastName(); + user.name = `${faker.person.firstName()} ${faker.person.lastName()}`; jest.spyOn(vscode.window, "showInformationMessage"); jest.spyOn(client, "getUser").mockResolvedValue(user); @@ -92,7 +91,7 @@ describe("Authorize client command test suite", () => { await command.showMessage(); expect(vscode.window.showInformationMessage).toHaveBeenLastCalledWith( - `Hello, ${user.firstName} ${user.lastName}!`, + `Hello, ${user.name}!`, ); }); it("should show error message", async () => { diff --git a/src/application/commands/authorize/authorizeClient.command.ts b/src/application/commands/authorize/authorizeClient.command.ts index 07ab74a..247d37d 100644 --- a/src/application/commands/authorize/authorizeClient.command.ts +++ b/src/application/commands/authorize/authorizeClient.command.ts @@ -32,9 +32,7 @@ export default class AuthorizeClientCommandImpl return this._client .getUser() .then((user) => { - vscode.window.showInformationMessage( - `Hello, ${user.firstName} ${user.lastName}!`, - ); + vscode.window.showInformationMessage(`Hello, ${user.name}!`); }) .catch((err) => { this._logger.error("Failed connecting to OpenProject: ", err); diff --git a/src/core/filter/text/text.wpsFilter.spec.ts b/src/core/filter/text/text.wpsFilter.spec.ts index 7053678..7d567c7 100644 --- a/src/core/filter/text/text.wpsFilter.spec.ts +++ b/src/core/filter/text/text.wpsFilter.spec.ts @@ -1,5 +1,5 @@ import { faker } from "@faker-js/faker"; -import { WP } from "op-client"; +import { User, WP } from "op-client"; import container from "../../../DI/container"; import TOKENS from "../../../DI/tokens"; import TextWPsFilterImpl from "./text.wpsFilter"; @@ -15,7 +15,7 @@ describe("WPs text filter test suite", () => { const helloWorldWP = { subject: "Hello world!", author: { - self: { title: "goodhumored" }, + name: "goodhumored", }, body: { description: { @@ -25,7 +25,7 @@ describe("WPs text filter test suite", () => { } as WP; const easterEggWP = { subject: "Lorem ipsum!", - author: { self: { title: "goodhumored" } }, + author: { name: "goodhumored" }, body: { description: { raw: "Easter egg!" }, }, @@ -33,7 +33,8 @@ describe("WPs text filter test suite", () => { const dannyWP = { subject: "Title", author: { - self: { title: "dannyweiss" }, + name: "dannyweiss", + login: "dannyweiss", }, body: { description: { raw: "Lorem ipsum" }, @@ -41,9 +42,7 @@ describe("WPs text filter test suite", () => { } as WP; const bugWP = { subject: "Bug!", - author: { - self: { title: undefined }, - }, + author: new User(1), body: {}, } as WP; diff --git a/src/core/filter/text/text.wpsFilter.ts b/src/core/filter/text/text.wpsFilter.ts index 9b0d93d..084eaa4 100644 --- a/src/core/filter/text/text.wpsFilter.ts +++ b/src/core/filter/text/text.wpsFilter.ts @@ -14,7 +14,8 @@ export default class TextWPsFilterImpl implements TextWPsFilter { const textFilterLower = this._textFilter.toLowerCase(); return wps.filter( (wp) => - wp.author.self.title?.toLowerCase().includes(textFilterLower) || + wp.author.name?.toLowerCase().includes(textFilterLower) || + wp.author.login?.toLowerCase().includes(textFilterLower) || wp.subject.toLowerCase().includes(textFilterLower) || wp.body.description?.raw.toLowerCase().includes(textFilterLower), ); From 4fd71e6f2bd940a9389b89aeb6530c8384310722 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Mon, 19 Jun 2023 02:21:20 +0300 Subject: [PATCH 26/38] getting statuses from openproject by api with status repository - status repository added - status filter reworked - setup filters reworked - wpStatus quickPick reworked --- src/DI/container.ts | 16 +++- src/DI/tokens.ts | 1 + .../filter/setupFilters.command.spec.ts | 15 +++- .../commands/filter/setupFilters.command.ts | 30 ++++--- .../wpStatus/wpStatus.quickPick.spec.ts | 30 ++++++- .../quickPicks/wpStatus/wpStatus.quickPick.ts | 21 +++-- .../wpStatus/wpStatus.quickPickItem.spec.ts | 15 ++-- .../wpStatus/wpStatus.quickPickItem.ts | 8 +- .../status/status.wpsFilter.interface.ts | 7 +- .../filter/status/status.wpsFilter.spec.ts | 83 ++++++------------- src/core/filter/status/status.wpsFilter.ts | 20 +++-- .../status/__mocks__/status.repository.ts | 13 +++ .../status/status.repository.interface.ts | 9 ++ .../status/status.repository.spec.ts | 63 ++++++++++++++ .../status/status.repository.ts | 41 +++++++++ 15 files changed, 261 insertions(+), 111 deletions(-) create mode 100644 src/infrastructure/status/__mocks__/status.repository.ts create mode 100644 src/infrastructure/status/status.repository.interface.ts create mode 100644 src/infrastructure/status/status.repository.spec.ts create mode 100644 src/infrastructure/status/status.repository.ts diff --git a/src/DI/container.ts b/src/DI/container.ts index bad537d..049e3bd 100644 --- a/src/DI/container.ts +++ b/src/DI/container.ts @@ -6,8 +6,10 @@ import SetupFiltersCommandImpl from "../application/commands/filter/setupFilters import SetupFiltersCommand from "../application/commands/filter/setupFilters.command.interface"; import RefreshWPsCommandImpl from "../application/commands/refresh/refreshWPs.command"; import RefreshWPsCommand from "../application/commands/refresh/refreshWPsCommand.interface"; +import SetWPStatusCommandImpl from "../application/commands/setWpStatus/setWPStatus.command"; +import SetWPStatusCommand from "../application/commands/setWpStatus/setWPStatus.command.interface"; import OpenProjectTreeDataProviderImpl from "../application/views/openProject.treeDataProvider"; -import OpenProjectTreeDataProvider from "../application/views/openProjectTreeDataProvider.interface"; +import OpenProjectTreeDataProvider from "../application/views/openProject.treeDataProvider.interface"; import CompositeWPsFilterImpl from "../core/filter/composite/composite.wpsFilter"; import CompositeWPsFilter from "../core/filter/composite/composite.wpsFilter.interface"; import ProjectsFilterImpl from "../core/filter/project/project.filter"; @@ -25,6 +27,8 @@ import ProjectRepository from "../infrastructure/project/project.repository.inte import WPRepositoryImpl from "../infrastructure/workPackage/wp.repository"; import WPRepository from "../infrastructure/workPackage/wp.repository.interface"; import TOKENS from "./tokens"; +import StatusRepository from "../infrastructure/status/status.repository.interface"; +import StatusRepositoryImpl from "../infrastructure/status/status.repository"; const container = new Container(); @@ -78,11 +82,21 @@ container .to(ProjectRepositoryImpl) .inSingletonScope(); +container + .bind(TOKENS.statusRepository) + .to(StatusRepositoryImpl) + .inSingletonScope(); + container .bind(TOKENS.setupFiltersCommand) .to(SetupFiltersCommandImpl) .inSingletonScope(); +container + .bind(TOKENS.setWPStatusCommand) + .to(SetWPStatusCommandImpl) + .inSingletonScope(); + container.bind(TOKENS.logger).to(ConsoleLogger).inSingletonScope(); export default container; diff --git a/src/DI/tokens.ts b/src/DI/tokens.ts index 9fc2349..f31e251 100644 --- a/src/DI/tokens.ts +++ b/src/DI/tokens.ts @@ -1,6 +1,7 @@ const repositoryTokens = { wpRepository: Symbol.for("WPRepository"), projectRepository: Symbol.for("ProjectRepository"), + statusRepository: Symbol.for("StatusRepository"), }; const comandTokens = { diff --git a/src/application/commands/filter/setupFilters.command.spec.ts b/src/application/commands/filter/setupFilters.command.spec.ts index 31072ee..2bc5b99 100644 --- a/src/application/commands/filter/setupFilters.command.spec.ts +++ b/src/application/commands/filter/setupFilters.command.spec.ts @@ -2,19 +2,20 @@ jest.mock("../../../core/filter/project/project.filter"); jest.mock("../../../core/filter/status/status.wpsFilter"); jest.mock("../../../core/filter/text/text.wpsFilter"); jest.mock("../../../infrastructure/project/project.repository"); +jest.mock("../../../infrastructure/status/status.repository"); jest.mock("../../quickPicks/project/project.quickPick"); jest.mock("../../quickPicks/wpStatus/wpStatus.quickPick"); import { faker } from "@faker-js/faker"; -import { Project } from "op-client"; +import { Project, Status } from "op-client"; import container from "../../../DI/container"; import TOKENS from "../../../DI/tokens"; import * as vscode from "../../../__mocks__/vscode"; import ProjectsFilter from "../../../core/filter/project/project.filter.interface"; import StatusWPsFilter from "../../../core/filter/status/status.wpsFilter.interface"; import TextWPsFilter from "../../../core/filter/text/text.wpsFilter.interface"; -import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; import ProjectRepository from "../../../infrastructure/project/project.repository.interface"; +import StatusRepository from "../../../infrastructure/status/status.repository.interface"; import ProjectQuickPick from "../../quickPicks/project/project.quickPick"; import WPStatusQuickPick from "../../quickPicks/wpStatus/wpStatus.quickPick"; import SetupFiltersCommandImpl from "./setupFilters.command"; @@ -27,6 +28,7 @@ describe("filter WPs command test suite", () => { const projectRepo = container.get( TOKENS.projectRepository, ); + const statusRepo = container.get(TOKENS.statusRepository); beforeEach(() => { jest.clearAllMocks(); @@ -143,14 +145,16 @@ describe("filter WPs command test suite", () => { }); }); describe("setupStatusFilter", () => { - const statuses = Object.values(WPStatus); + const statuses = faker.helpers.uniqueArray(faker.number.int, 5); it("should show quickpick", async () => { + jest.spyOn(statusRepo, "findAll").mockReturnValue([]); jest.spyOn(WPStatusQuickPick.prototype, "show").mockResolvedValue([]); await command.setupStatusFilter(); expect(WPStatusQuickPick.prototype.show).toHaveBeenCalled(); }); it("should setStatusFilter results", async () => { + jest.spyOn(statusRepo, "findAll").mockReturnValue([]); jest .spyOn(WPStatusQuickPick.prototype, "show") .mockResolvedValue(statuses); @@ -161,7 +165,7 @@ describe("filter WPs command test suite", () => { expect(statusFilter.setStatusFilter).toHaveBeenLastCalledWith(statuses); }); it("should setStatusFilter filter if got undefined", async () => { - jest.spyOn(projectRepo, "findAll").mockReturnValue([]); + jest.spyOn(statusRepo, "findAll").mockReturnValue([]); jest.spyOn(statusFilter, "getStatusFilter").mockReturnValue(statuses); jest .spyOn(WPStatusQuickPick.prototype, "show") @@ -173,6 +177,9 @@ describe("filter WPs command test suite", () => { expect(statusFilter.setStatusFilter).toHaveBeenLastCalledWith(statuses); }); it("should setStatusFilter statuses if got undefined and filter is undefined", async () => { + jest + .spyOn(statusRepo, "findAll") + .mockReturnValue(statuses.map((id) => new Status(id))); jest.spyOn(statusFilter, "getStatusFilter").mockReturnValue(undefined); jest .spyOn(WPStatusQuickPick.prototype, "show") diff --git a/src/application/commands/filter/setupFilters.command.ts b/src/application/commands/filter/setupFilters.command.ts index c3b41a9..6ebefe7 100644 --- a/src/application/commands/filter/setupFilters.command.ts +++ b/src/application/commands/filter/setupFilters.command.ts @@ -4,8 +4,8 @@ import TOKENS from "../../../DI/tokens"; import ProjectsFilter from "../../../core/filter/project/project.filter.interface"; import StatusWPsFilter from "../../../core/filter/status/status.wpsFilter.interface"; import TextWPsFilter from "../../../core/filter/text/text.wpsFilter.interface"; -import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; import ProjectRepository from "../../../infrastructure/project/project.repository.interface"; +import StatusRepository from "../../../infrastructure/status/status.repository.interface"; import ProjectQuickPick from "../../quickPicks/project/project.quickPick"; import WPStatusQuickPick from "../../quickPicks/wpStatus/wpStatus.quickPick"; import SetupFiltersCommand from "./setupFilters.command.interface"; @@ -24,6 +24,8 @@ export default class SetupFiltersCommandImpl implements SetupFiltersCommand { private readonly _statusFilter: StatusWPsFilter, @inject(TOKENS.projectRepository) private readonly _projectRepo: ProjectRepository, + @inject(TOKENS.statusRepository) + private readonly _statusRepo: StatusRepository, ) {} async setupFilters() { @@ -58,30 +60,32 @@ export default class SetupFiltersCommandImpl implements SetupFiltersCommand { } async setupProjectFilter() { - const projects = this._projectRepo.findAll(); - const oldProjectIds = - this._projectFilter.getProjectFilter() ?? projects.map((p) => p.id); + const allProjects = this._projectRepo.findAll(); + const oldProjectsFilter = + this._projectFilter.getProjectFilter() ?? allProjects.map((p) => p.id); const quickPick = new ProjectQuickPick( "Select wps of which projects you want to see: ", - projects, + allProjects, true, ); - quickPick.setPickedProjects(oldProjectIds); - const projectIds = await quickPick.show(); - this._projectFilter.setProjectFilter(projectIds ?? oldProjectIds); + quickPick.setPickedProjects(oldProjectsFilter); + const pickedProjects = await quickPick.show(); + this._projectFilter.setProjectFilter(pickedProjects ?? oldProjectsFilter); } async setupStatusFilter() { - const statuses = Object.values(WPStatus); - const filter = this._statusFilter.getStatusFilter(); + const allStatuses = this._statusRepo.findAll(); + const oldFilter = + this._statusFilter.getStatusFilter() ?? allStatuses.map((s) => s.id); const quickPick = new WPStatusQuickPick( "Select wps of which status you want to see: ", + allStatuses, true, ); - quickPick.setPickedStatuses(filter ?? statuses); + quickPick.setPickedStatuses(oldFilter ?? allStatuses); - const result = await quickPick.show(); + const pickedStatuses = await quickPick.show(); - this._statusFilter.setStatusFilter(result ?? statuses); + this._statusFilter.setStatusFilter(pickedStatuses ?? oldFilter); } } diff --git a/src/application/quickPicks/wpStatus/wpStatus.quickPick.spec.ts b/src/application/quickPicks/wpStatus/wpStatus.quickPick.spec.ts index 947b9e7..e5f0d29 100644 --- a/src/application/quickPicks/wpStatus/wpStatus.quickPick.spec.ts +++ b/src/application/quickPicks/wpStatus/wpStatus.quickPick.spec.ts @@ -1,14 +1,20 @@ /* eslint-disable no-new */ import { faker } from "@faker-js/faker"; +import { Status } from "op-client"; import * as vscode from "vscode"; +import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; import WPStatusQuickPick from "./wpStatus.quickPick"; describe("WPStatusQuickPick test suite", () => { + let statuses = Object.values(WPStatus).map( + (wpStatus, i) => new Status({ name: wpStatus, id: i } as any), + ); + describe("constructor", () => { it("should construct with no errors", () => { const title = faker.string.alpha(); const multiSelect = faker.datatype.boolean(); - new WPStatusQuickPick(title, multiSelect); + new WPStatusQuickPick(title, statuses, multiSelect); }); }); describe("setSelectedStatuses", () => { @@ -17,6 +23,7 @@ describe("WPStatusQuickPick test suite", () => { beforeEach(() => { qp = new WPStatusQuickPick( faker.string.alpha(), + statuses, faker.datatype.boolean(), ); }); @@ -32,6 +39,16 @@ describe("WPStatusQuickPick test suite", () => { expect(qp["_items"][0].picked).toBeTruthy(); expect(qp["_items"][1].picked).toBeFalsy(); }); + it("should set picked of first item if WPStatus passed", () => { + qp.setPickedStatuses([Object.values(WPStatus)[0]]); + expect(qp["_items"][0].picked).toBeTruthy(); + expect(qp["_items"][1].picked).toBeFalsy(); + }); + it("should set picked of first item if number passed", () => { + qp.setPickedStatuses([0]); + expect(qp["_items"][0].picked).toBeTruthy(); + expect(qp["_items"][1].picked).toBeFalsy(); + }); }); describe("show", () => { let qp: WPStatusQuickPick; @@ -41,7 +58,7 @@ describe("WPStatusQuickPick test suite", () => { jest.spyOn(vscode.window, "showQuickPick").mockResolvedValue(undefined); title = faker.string.alpha(); multi = faker.datatype.boolean(); - qp = new WPStatusQuickPick(title, multi); + qp = new WPStatusQuickPick(title, [new Status(1)], multi); }); it("should show quick pick with items", async () => { await qp.show(); @@ -72,9 +89,16 @@ describe("WPStatusQuickPick test suite", () => { .spyOn(vscode.window, "showQuickPick") .mockResolvedValue(qp["_items"] as any); - const statuses = qp["_items"].map((i) => i.status); + statuses = qp["_items"].map((i) => i.status); expect(await qp.show()).toEqual(statuses); }); + it("should return one picked status", async () => { + jest + .spyOn(vscode.window, "showQuickPick") + .mockResolvedValue(qp["_items"][0]); + + expect(await qp.show()).toEqual(qp["_items"][0].status); + }); }); }); diff --git a/src/application/quickPicks/wpStatus/wpStatus.quickPick.ts b/src/application/quickPicks/wpStatus/wpStatus.quickPick.ts index 5766ba3..9a273be 100644 --- a/src/application/quickPicks/wpStatus/wpStatus.quickPick.ts +++ b/src/application/quickPicks/wpStatus/wpStatus.quickPick.ts @@ -1,3 +1,4 @@ +import { Status } from "op-client"; import * as vscode from "vscode"; import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; import WPStatusQuickPickItem from "./wpStatus.quickPickItem"; @@ -9,21 +10,25 @@ export default class WPStatusQuickPick { private readonly _multiSelect: boolean; - constructor(title: string, multiSelect: T) { + constructor(title: string, statuses: Status[], multiSelect: T) { this._title = title; this._multiSelect = multiSelect; - this._items = this.getStatusQuickPickItems(); + this._items = this.getStatusQuickPickItems(statuses); } - public setPickedStatuses(statuses: WPStatus[]) { + public setPickedStatuses(statuses: (number | WPStatus | Status)[]) { statuses.forEach((status) => { - const statusItem = this._items.find((item) => item.status === status); + const statusItem = this._items.find((item) => { + if (status instanceof Status) return item.status.id === status.id; + if (typeof status === "number") return item.status.id === status; + return item.label === status; + }); if (statusItem) statusItem.picked = true; }); } show< - TResult = (T extends true ? WPStatus[] : WPStatus) | undefined, + TResult = (T extends true ? Status[] : Status) | undefined, >(): Thenable { return ( vscode.window.showQuickPick(this._items, { @@ -40,9 +45,7 @@ export default class WPStatusQuickPick { }) as Thenable; } - private getStatusQuickPickItems(): WPStatusQuickPickItem[] { - return Object.values(WPStatus).map( - (status) => new WPStatusQuickPickItem(status), - ); + private getStatusQuickPickItems(statuses: Status[]): WPStatusQuickPickItem[] { + return statuses.map((status) => new WPStatusQuickPickItem(status)); } } diff --git a/src/application/quickPicks/wpStatus/wpStatus.quickPickItem.spec.ts b/src/application/quickPicks/wpStatus/wpStatus.quickPickItem.spec.ts index 0285a0f..a1d8d16 100644 --- a/src/application/quickPicks/wpStatus/wpStatus.quickPickItem.spec.ts +++ b/src/application/quickPicks/wpStatus/wpStatus.quickPickItem.spec.ts @@ -1,23 +1,24 @@ -import WPStatus from "../../../../infrastructure/openProject/wpStatus.enum"; +import { Status } from "op-client"; import WPStatusQuickPickItem from "./wpStatus.quickPickItem"; describe("wpStatus quick pick item test suite", () => { it("should have status as label", () => { - const status = WPStatus.closed; + const status = new Status(1); + status.body.name = "New"; const item = new WPStatusQuickPickItem(status); - expect(item.label).toEqual(status); + expect(item.label).toEqual(status.body.name); }); it("should have status as status", () => { - const status = WPStatus.closed; + const status = new Status(1); const item = new WPStatusQuickPickItem(status); expect(item.status).toEqual(status); }); it("should have picked = true", () => { - const status = WPStatus.closed; + const status = new Status(1); const picked = true; const item = new WPStatusQuickPickItem(status, picked); @@ -25,7 +26,7 @@ describe("wpStatus quick pick item test suite", () => { expect(item.picked).toEqual(picked); }); it("should have picked = false", () => { - const status = WPStatus.closed; + const status = new Status(1); const picked = false; const item = new WPStatusQuickPickItem(status, picked); @@ -33,7 +34,7 @@ describe("wpStatus quick pick item test suite", () => { expect(item.picked).toEqual(picked); }); it("should have picked = false if no picked passed", () => { - const status = WPStatus.closed; + const status = new Status(1); const item = new WPStatusQuickPickItem(status); diff --git a/src/application/quickPicks/wpStatus/wpStatus.quickPickItem.ts b/src/application/quickPicks/wpStatus/wpStatus.quickPickItem.ts index 94d5bc2..a7a9b8d 100644 --- a/src/application/quickPicks/wpStatus/wpStatus.quickPickItem.ts +++ b/src/application/quickPicks/wpStatus/wpStatus.quickPickItem.ts @@ -1,16 +1,16 @@ +import { Status } from "op-client"; import { QuickPickItem } from "vscode"; -import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; export default class WPStatusQuickPickItem implements QuickPickItem { label: string; picked: boolean; - status: WPStatus; + status: Status; - constructor(status: WPStatus, picked = false) { + constructor(status: Status, picked = false) { this.status = status; - this.label = status; + this.label = status.body.name; this.picked = picked; } } diff --git a/src/core/filter/status/status.wpsFilter.interface.ts b/src/core/filter/status/status.wpsFilter.interface.ts index cf3396e..8a379aa 100644 --- a/src/core/filter/status/status.wpsFilter.interface.ts +++ b/src/core/filter/status/status.wpsFilter.interface.ts @@ -1,8 +1,7 @@ -import { WP } from "op-client"; -import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; +import { Status, WP } from "op-client"; import Filter from "../filter.interface"; export default interface StatusWPsFilter extends Filter { - getStatusFilter(): WPStatus[] | undefined; - setStatusFilter(wpTypes: WPStatus[]): void; + getStatusFilter(): number[] | undefined; + setStatusFilter(statuses: number[] | Status[]): void; } diff --git a/src/core/filter/status/status.wpsFilter.spec.ts b/src/core/filter/status/status.wpsFilter.spec.ts index c33899c..2b9b184 100644 --- a/src/core/filter/status/status.wpsFilter.spec.ts +++ b/src/core/filter/status/status.wpsFilter.spec.ts @@ -1,8 +1,7 @@ import { faker } from "@faker-js/faker"; -import { WP } from "op-client"; +import { Project, Status, WP } from "op-client"; import container from "../../../DI/container"; import TOKENS from "../../../DI/tokens"; -import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; import StatusWPsFilterImpl from "./status.wpsFilter"; describe("WPs status filter test suite", () => { @@ -21,19 +20,13 @@ describe("WPs status filter test suite", () => { lastName: "Nekrasov", login: "goodhumored", }, - status: { - self: { - title: WPStatus.closed, - }, - }, + status: new Status(1), body: { description: { raw: "Lorem ipsum dolor amet...", }, }, - project: { - id: 1, - }, + project: new Project(1), } as WP; const easterEggWP = { subject: "Lorem ipsum!", @@ -48,14 +41,8 @@ describe("WPs status filter test suite", () => { raw: "Easter egg!", }, }, - project: { - id: 2, - }, - status: { - self: { - title: WPStatus.new, - }, - }, + project: new Project(1), + status: new Status(2), } as WP; const dannyWP = { subject: "Title", @@ -70,14 +57,8 @@ describe("WPs status filter test suite", () => { raw: "Lorem ipsum", }, }, - project: { - id: 2, - }, - status: { - self: { - title: WPStatus.new, - }, - }, + project: new Project(2), + status: new Status(2), } as WP; const bugWP = { subject: "Bug!", @@ -92,66 +73,54 @@ describe("WPs status filter test suite", () => { raw: faker.lorem.text(), }, }, - project: { - id: 0, - }, - status: { - self: { - title: WPStatus.developed, - }, - }, + project: new Project(0), + status: new Status(3), } as WP; const wps: WP[] = [helloWorldWP, easterEggWP, dannyWP, bugWP]; - it("should return all wps if projectFilter is undefined", () => { + it("should return all wps if statusFilter is undefined", () => { const result = filter.filter(wps); expect(result).toEqual(wps); }); - it("should return all wps if projectFilter contains all ids", () => { - filter.setStatusFilter(Object.values(WPStatus)); + it("should return all wps if statusFilter contains all ids", () => { + filter.setStatusFilter([new Status(1), new Status(2), new Status(3)]); const result = filter.filter(wps); expect(result).toEqual(wps); }); - it("should return wps with status closed", () => { - filter.setStatusFilter([WPStatus.closed]); + it("should return wps with status 1", () => { + filter.setStatusFilter([new Status(1)]); const result = filter.filter(wps); expect(result).toEqual([helloWorldWP]); }); - it("should return wps with status new", () => { - filter.setStatusFilter([WPStatus.new]); + it("should return wps with status 2", () => { + filter.setStatusFilter([new Status(2)]); const result = filter.filter(wps); expect(result).toEqual([easterEggWP, dannyWP]); }); - it("should return wps with statuses new and closed", () => { - filter.setStatusFilter([WPStatus.new, WPStatus.closed]); + it("should return wps with statuses 1 and 2", () => { + filter.setStatusFilter([new Status(1), new Status(2)]); const result = filter.filter(wps); expect(result).toEqual( expect.arrayContaining([helloWorldWP, dannyWP, easterEggWP]), ); }); it("should return empty array", () => { - filter.setStatusFilter([WPStatus.onHold]); + filter.setStatusFilter([new Status(4)]); const result = filter.filter(wps); expect(result).toEqual([]); }); }); - describe("setProjectFilter", () => { - it("should set project filter", () => { - const projectIds = faker.helpers.uniqueArray( - faker.string.alpha, - 5, - ) as WPStatus[]; - filter.setStatusFilter(projectIds); - expect(filter.getStatusFilter()).toEqual(projectIds); + describe("setStatusFilter", () => { + it("should set status filter", () => { + const statusIds = faker.helpers.uniqueArray(faker.number.int, 5); + filter.setStatusFilter(statusIds); + expect(filter.getStatusFilter()).toEqual(statusIds); }); it("should emit filter updated event", () => { - const projectIds = faker.helpers.uniqueArray( - faker.string.alpha, - 5, - ) as WPStatus[]; - filter.setStatusFilter(projectIds); + const statusIds = faker.helpers.uniqueArray(faker.number.int, 5); + filter.setStatusFilter(statusIds); expect(filter["_onFilterUpdated"].fire).toHaveBeenCalled(); }); }); diff --git a/src/core/filter/status/status.wpsFilter.ts b/src/core/filter/status/status.wpsFilter.ts index f742307..3c261cd 100644 --- a/src/core/filter/status/status.wpsFilter.ts +++ b/src/core/filter/status/status.wpsFilter.ts @@ -1,12 +1,11 @@ import { injectable } from "inversify"; -import { WP } from "op-client"; +import { Status, WP } from "op-client"; import * as vscode from "vscode"; -import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; import StatusWPsFilter from "./status.wpsFilter.interface"; @injectable() export default class StatusWPsFilterImpl implements StatusWPsFilter { - private _wpTypesFilter?: WPStatus[] = undefined; + private _wpStatusFilter?: number[] = undefined; private _onFilterUpdated: vscode.EventEmitter = new vscode.EventEmitter(); @@ -14,19 +13,22 @@ export default class StatusWPsFilterImpl implements StatusWPsFilter { filter(wps: WP[]): WP[] { return wps.filter( (wp) => - this._wpTypesFilter === undefined || - this._wpTypesFilter.includes(wp.status.self.title as WPStatus), + this._wpStatusFilter === undefined || + this._wpStatusFilter.includes(wp.status.id), ); } onFilterUpdated = this._onFilterUpdated.event; - setStatusFilter(projectIds: WPStatus[]): void { - this._wpTypesFilter = projectIds; + setStatusFilter(statuses: Status[] | number[]): void { + this._wpStatusFilter = statuses.map((s) => { + if (typeof s === "number") return s; + return s.id; + }); this._onFilterUpdated.fire(); } - getStatusFilter(): WPStatus[] | undefined { - return this._wpTypesFilter; + getStatusFilter(): number[] | undefined { + return this._wpStatusFilter; } } diff --git a/src/infrastructure/status/__mocks__/status.repository.ts b/src/infrastructure/status/__mocks__/status.repository.ts new file mode 100644 index 0000000..47e9145 --- /dev/null +++ b/src/infrastructure/status/__mocks__/status.repository.ts @@ -0,0 +1,13 @@ +import { injectable } from "inversify"; +import StatusRepository from "../status.repository.interface"; + +@injectable() +export default class StatusRepositoryImpl implements StatusRepository { + findById = jest.fn(); + + findAll = jest.fn(); + + refetch = jest.fn(); + + onStatusesChange = jest.fn(); +} diff --git a/src/infrastructure/status/status.repository.interface.ts b/src/infrastructure/status/status.repository.interface.ts new file mode 100644 index 0000000..762b1cf --- /dev/null +++ b/src/infrastructure/status/status.repository.interface.ts @@ -0,0 +1,9 @@ +import { Status } from "op-client"; +import { Event } from "vscode"; + +export default interface StatusRepository { + findById(id: number): Status; + findAll(): Status[]; + refetch(): Promise; + onStatusesChange: Event; +} diff --git a/src/infrastructure/status/status.repository.spec.ts b/src/infrastructure/status/status.repository.spec.ts new file mode 100644 index 0000000..281fecf --- /dev/null +++ b/src/infrastructure/status/status.repository.spec.ts @@ -0,0 +1,63 @@ +jest.mock("../openProject/openProject.client"); + +import { Status } from "op-client"; +import container from "../../DI/container"; +import TOKENS from "../../DI/tokens"; +import OpenProjectClient from "../openProject/openProject.client.interface"; +import StatusRepository from "./status.repository.interface"; +import StatusNotFoundException from "./statusNotFound.exception"; + +describe("Status repository test suite", () => { + const client = container.get(TOKENS.opClient); + const repository = container.get(TOKENS.statusRepository); + const status1 = new Status(1); + const status2 = new Status(2); + const status3 = new Status(3); + const status4 = new Status(4); + const status5 = new Status(5); + + beforeAll(async () => { + jest + .spyOn(client, "getStatuses") + .mockResolvedValue([status1, status2, status3, status4]); + await repository.refetch(); + }); + + describe("findById", () => { + it("should return status1", () => { + expect(repository.findById(status1.id)).toEqual(status1); + }); + it("should return status2 by id", () => { + expect(repository.findById(status2.id)).toEqual(status2); + }); + it("should return status3 by id", () => { + expect(repository.findById(status3.id)).toEqual(status3); + }); + it("should return status4 by id", () => { + expect(repository.findById(status4.id)).toEqual(status4); + }); + it("should throw ProjectNotFoundException", () => { + expect(() => repository.findById(status5.id)).toThrowError( + StatusNotFoundException, + ); + }); + }); + + describe("findAll", () => { + it("should return all statuss", () => { + expect(repository.findAll()).toEqual([ + status1, + status2, + status3, + status4, + ]); + }); + }); + + describe("refetch", () => { + it("should call client getStatuses again", () => { + repository.refetch(); + expect(client.getStatuses).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/infrastructure/status/status.repository.ts b/src/infrastructure/status/status.repository.ts new file mode 100644 index 0000000..a039a29 --- /dev/null +++ b/src/infrastructure/status/status.repository.ts @@ -0,0 +1,41 @@ +import { inject, injectable } from "inversify"; +import { Status } from "op-client"; +import * as vscode from "vscode"; +import { Event } from "vscode"; +import TOKENS from "../../DI/tokens"; +import OpenProjectClient from "../openProject/openProject.client.interface"; +import StatusRepository from "./status.repository.interface"; +import StatusNotFoundException from "./statusNotFound.exception"; + +@injectable() +export default class StatusRepositoryImpl implements StatusRepository { + private _statuses: Status[] = []; + + private _onStatusesChange: vscode.EventEmitter = + new vscode.EventEmitter(); + + onStatusesChange: Event = this._onStatusesChange.event; + + constructor( + @inject(TOKENS.opClient) private readonly _client: OpenProjectClient, + ) { + this._client.onInit(() => this.refetch()); + } + + findById(id: number): Status { + const result = this._statuses.find((wp) => wp.id === id); + if (!result) throw new StatusNotFoundException(); + return result; + } + + findAll(): Status[] { + return this._statuses; + } + + refetch(): Promise { + return this._client.getStatuses().then((statuses) => { + this._statuses = statuses; + this._onStatusesChange.fire(); + }); + } +} From 689ff4e6686f418c5c429dac911f35e5caefb4d3 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Mon, 19 Jun 2023 02:23:18 +0300 Subject: [PATCH 27/38] status opClient methods --- .../__mocks__/openProject.client.ts | 4 +++ .../openProject/openProject.client.spec.ts | 32 ++++++++++++++++--- .../openProject/openProject.client.ts | 14 +++++++- .../status/statusNotFound.exception.ts | 5 +++ 4 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 src/infrastructure/status/statusNotFound.exception.ts diff --git a/src/infrastructure/openProject/__mocks__/openProject.client.ts b/src/infrastructure/openProject/__mocks__/openProject.client.ts index 6352a1b..395bd7a 100644 --- a/src/infrastructure/openProject/__mocks__/openProject.client.ts +++ b/src/infrastructure/openProject/__mocks__/openProject.client.ts @@ -3,6 +3,10 @@ import OpenProjectClient from "../openProject.client.interface"; @injectable() export default class OpenProjectClientImpl implements OpenProjectClient { + getStatuses = jest.fn(); + + save = jest.fn(); + getProjects = jest.fn(); onInit = jest.fn(); diff --git a/src/infrastructure/openProject/openProject.client.spec.ts b/src/infrastructure/openProject/openProject.client.spec.ts index b304405..4046397 100644 --- a/src/infrastructure/openProject/openProject.client.spec.ts +++ b/src/infrastructure/openProject/openProject.client.spec.ts @@ -1,12 +1,11 @@ jest.mock("../logger/logger"); import { faker } from "@faker-js/faker"; -import { Project, User, WP } from "op-client"; +import { Project, Status, User, WP } from "op-client"; import "reflect-metadata"; import container from "../../DI/container"; import TOKENS from "../../DI/tokens"; import ConsoleLogger from "../logger/logger"; -import Logger from "../logger/logger.interface"; import ClientNotInitializedException from "./clientNotInitialized.exception"; import OpenProjectClientImpl from "./openProject.client"; import UnexceptedClientException from "./unexpectedClientError.exception"; @@ -16,17 +15,16 @@ jest.mock("op-client"); describe("OpenProject Client tests", () => { let client: OpenProjectClientImpl; - let logger: Logger; beforeEach(() => { jest.clearAllMocks(); client = container.get(TOKENS.opClient); - logger = container.get(TOKENS.logger); client["_entityManager"] = { fetch: jest.fn(), get: jest.fn(), getMany: jest.fn(), + patch: jest.fn(), } as any; }); @@ -173,4 +171,30 @@ describe("OpenProject Client tests", () => { expect(client["addTokenToUrl"](url, token)).toBe(result); }); }); + + describe("Save", () => { + it("should call entityManager.patch", async () => { + const wp = new WP(1); + jest.spyOn(client["_entityManager"]!, "patch"); + + await client.save(wp); + + expect(client["_entityManager"]?.patch).toHaveBeenCalledWith(wp); + }); + }); + + describe("GetStatuses", () => { + it("should return statuses from entityManager getMany", async () => { + const statuses = faker.helpers.uniqueArray( + () => new Status(faker.number.int()), + 5, + ); + + jest + .spyOn(client["_entityManager"]!, "getMany") + .mockResolvedValueOnce(statuses); + + expect(await client.getStatuses()).toEqual(statuses); + }); + }); }); diff --git a/src/infrastructure/openProject/openProject.client.ts b/src/infrastructure/openProject/openProject.client.ts index 54324a7..f9ad5cf 100644 --- a/src/infrastructure/openProject/openProject.client.ts +++ b/src/infrastructure/openProject/openProject.client.ts @@ -1,6 +1,6 @@ import ClientOAuth2 from "client-oauth2"; import { inject, injectable } from "inversify"; -import { EntityManager, Project, User, WP } from "op-client"; +import { EntityManager, Project, Status, User, WP } from "op-client"; import * as vscode from "vscode"; import TOKENS from "../../DI/tokens"; import addCredsToUrl from "../../utils/addCredsToUrl.util"; @@ -73,6 +73,18 @@ export default class OpenProjectClientImpl implements OpenProjectClient { }); } + public getStatuses(): Promise { + return this.entityManager.getMany(Status, { + pageSize: 100, + all: true, + filters: [], + }); + } + + save(wp: WP): Promise { + return this.entityManager.patch(wp); + } + private addTokenToUrl(baseUrl: string, token: string) { return addCredsToUrl(baseUrl, "apikey", token); } diff --git a/src/infrastructure/status/statusNotFound.exception.ts b/src/infrastructure/status/statusNotFound.exception.ts new file mode 100644 index 0000000..88d4e51 --- /dev/null +++ b/src/infrastructure/status/statusNotFound.exception.ts @@ -0,0 +1,5 @@ +export default class StatusNotFoundException extends Error { + constructor() { + super("Work package was not found"); + } +} From c00e547014e729bbdfad122126c551077e5078c4 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Mon, 19 Jun 2023 02:23:57 +0300 Subject: [PATCH 28/38] projectQuickPick redundant setPickedProjects signature removed --- .../quickPicks/project/project.quickPick.spec.ts | 11 +++++++++++ .../quickPicks/project/project.quickPick.ts | 8 +++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/application/quickPicks/project/project.quickPick.spec.ts b/src/application/quickPicks/project/project.quickPick.spec.ts index 65e6342..0f40eef 100644 --- a/src/application/quickPicks/project/project.quickPick.spec.ts +++ b/src/application/quickPicks/project/project.quickPick.spec.ts @@ -2,6 +2,7 @@ import { faker } from "@faker-js/faker"; import { Project } from "op-client"; import * as vscode from "vscode"; import ProjectQuickPick from "./project.quickPick"; +import ProjectQuickPickItem from "./project.quickPickItem"; describe("ProjectQuickPick test suite", () => { describe("constructor", () => { @@ -98,5 +99,15 @@ describe("ProjectQuickPick test suite", () => { const projectIds = await qp.show(); expect(projectIds).toEqual(pids); }); + + it("should return one projectId if result is not array", async () => { + const qp = new ProjectQuickPick("title", projects, false); + const pid = pids[0]; + jest + .spyOn(vscode.window, "showQuickPick") + .mockResolvedValue({ projectId: pid } as ProjectQuickPickItem); + const projectIds = await qp.show(); + expect(projectIds).toEqual(pid); + }); }); }); diff --git a/src/application/quickPicks/project/project.quickPick.ts b/src/application/quickPicks/project/project.quickPick.ts index d1da765..1738aea 100644 --- a/src/application/quickPicks/project/project.quickPick.ts +++ b/src/application/quickPicks/project/project.quickPick.ts @@ -15,12 +15,10 @@ export default class ProjectQuickPick { this._title = title; } - setPickedProjects(projects: number[] | Project[]): void { - projects.forEach((project: number | Project) => { + setPickedProjects(projects: number[]): void { + projects.forEach((project: number) => { const projectItem = this._items.find( - (item) => - item.projectId === - (typeof project === "number" ? project : project.id), + (item) => item.projectId === project, ); if (projectItem) projectItem.picked = true; }); From 918a82c47589db856a2f4fced5c781f29f662e4d Mon Sep 17 00:00:00 2001 From: goodhumored Date: Mon, 19 Jun 2023 02:25:18 +0300 Subject: [PATCH 29/38] openProjectTreeDataProvider - interface renamed - redraw function added --- .../views/__mocks__/openProject.treeDataProvider.ts | 4 +++- ...erface.ts => openProject.treeDataProvider.interface.ts} | 1 + src/application/views/openProject.treeDataProvider.spec.ts | 7 +++++++ src/application/views/openProject.treeDataProvider.ts | 6 +++++- 4 files changed, 16 insertions(+), 2 deletions(-) rename src/application/views/{openProjectTreeDataProvider.interface.ts => openProject.treeDataProvider.interface.ts} (92%) diff --git a/src/application/views/__mocks__/openProject.treeDataProvider.ts b/src/application/views/__mocks__/openProject.treeDataProvider.ts index c9f500d..3ae2f90 100644 --- a/src/application/views/__mocks__/openProject.treeDataProvider.ts +++ b/src/application/views/__mocks__/openProject.treeDataProvider.ts @@ -1,10 +1,12 @@ import { injectable } from "inversify"; -import OpenProjectTreeDataProvider from "../openProjectTreeDataProvider.interface"; +import OpenProjectTreeDataProvider from "../openProject.treeDataProvider.interface"; @injectable() export default class OpenProjectTreeDataProviderImpl implements OpenProjectTreeDataProvider { + redraw = jest.fn(); + refresh = jest.fn(); onDidChangeTreeData = jest.fn(); diff --git a/src/application/views/openProjectTreeDataProvider.interface.ts b/src/application/views/openProject.treeDataProvider.interface.ts similarity index 92% rename from src/application/views/openProjectTreeDataProvider.interface.ts rename to src/application/views/openProject.treeDataProvider.interface.ts index 11b2996..b0d4d4b 100644 --- a/src/application/views/openProjectTreeDataProvider.interface.ts +++ b/src/application/views/openProject.treeDataProvider.interface.ts @@ -4,4 +4,5 @@ import { TreeDataProvider } from "vscode"; export default interface OpenProjectTreeDataProvider extends TreeDataProvider { refresh(): Promise; + redraw(): void; } diff --git a/src/application/views/openProject.treeDataProvider.spec.ts b/src/application/views/openProject.treeDataProvider.spec.ts index 520b0e8..8aa162e 100644 --- a/src/application/views/openProject.treeDataProvider.spec.ts +++ b/src/application/views/openProject.treeDataProvider.spec.ts @@ -192,4 +192,11 @@ describe("OpenProjectTreeDataProvider", () => { expect(projectRepo.refetch).toHaveBeenCalled(); }); }); + describe("redraw", () => { + it("should fire onDataChangeEvent", () => { + jest.spyOn(treeView["_onDidChangeTreeData"], "fire"); + treeView.redraw(); + expect(treeView["_onDidChangeTreeData"].fire).toHaveBeenCalled(); + }); + }); }); diff --git a/src/application/views/openProject.treeDataProvider.ts b/src/application/views/openProject.treeDataProvider.ts index 41ff121..76513a3 100644 --- a/src/application/views/openProject.treeDataProvider.ts +++ b/src/application/views/openProject.treeDataProvider.ts @@ -7,7 +7,7 @@ import Filter from "../../core/filter/filter.interface"; import OpenProjectClient from "../../infrastructure/openProject/openProject.client.interface"; import ProjectRepository from "../../infrastructure/project/project.repository.interface"; import WPRepository from "../../infrastructure/workPackage/wp.repository.interface"; -import OpenProjectTreeDataProvider from "./openProjectTreeDataProvider.interface"; +import OpenProjectTreeDataProvider from "./openProject.treeDataProvider.interface"; import ProjectTreeItem from "./treeItems/project.treeItem"; import WPTreeItem from "./treeItems/wp.treeItem"; @@ -75,4 +75,8 @@ export default class OpenProjectTreeDataProviderImpl this._projectRepository.refetch(), ]).then(); } + + redraw() { + this._onDidChangeTreeData.fire(); + } } From a87736b9da368a326c6bcf6bca9350bddabdc4e3 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Mon, 19 Jun 2023 02:26:00 +0300 Subject: [PATCH 30/38] treeView interface renamed import fixed --- src/application/commands/refresh/refreshWPs.command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/application/commands/refresh/refreshWPs.command.ts b/src/application/commands/refresh/refreshWPs.command.ts index 753c14c..4bda663 100644 --- a/src/application/commands/refresh/refreshWPs.command.ts +++ b/src/application/commands/refresh/refreshWPs.command.ts @@ -1,6 +1,6 @@ import { inject, injectable } from "inversify"; import TOKENS from "../../../DI/tokens"; -import OpenProjectTreeDataProvider from "../../views/openProjectTreeDataProvider.interface"; +import OpenProjectTreeDataProvider from "../../views/openProject.treeDataProvider.interface"; import RefreshWPsCommand from "./refreshWPsCommand.interface"; @injectable() From 59dbcf46aaf5aba010518f5d8f986521abd9ad94 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Mon, 19 Jun 2023 02:27:03 +0300 Subject: [PATCH 31/38] openProject client interface status methods --- .../openProject/openProject.client.interface.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/infrastructure/openProject/openProject.client.interface.ts b/src/infrastructure/openProject/openProject.client.interface.ts index 2ef7b5f..9a7dd6b 100644 --- a/src/infrastructure/openProject/openProject.client.interface.ts +++ b/src/infrastructure/openProject/openProject.client.interface.ts @@ -1,4 +1,4 @@ -import { Project, User, WP } from "op-client"; +import { Project, Status, User, WP } from "op-client"; import * as vscode from "vscode"; export default interface OpenProjectClient { @@ -6,6 +6,9 @@ export default interface OpenProjectClient { getUser(): Promise; getWPs(): Promise; getProjects(): Promise; + getStatuses(): Promise; + + save(wp: WP): Promise; onInit: vscode.Event; } From d7b4816ceb486d281100fe7d63a0f88a7ffdb9a1 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Mon, 19 Jun 2023 02:27:34 +0300 Subject: [PATCH 32/38] wp tree item code cleanup --- .../views/treeItems/wp.treeItem.ts | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/application/views/treeItems/wp.treeItem.ts b/src/application/views/treeItems/wp.treeItem.ts index 7145c81..4559847 100644 --- a/src/application/views/treeItems/wp.treeItem.ts +++ b/src/application/views/treeItems/wp.treeItem.ts @@ -12,25 +12,39 @@ export default class WPTreeItem implements TreeItem { iconPath?: string | Uri; - label?: string | TreeItemLabel | undefined; + label: string | TreeItemLabel; constructor(wp: WP) { - const iconPath = getIconPathByStatus(wp.status?.self.title as WPStatus); - const type = wp.type?.self.title; - let label = `#${wp.id} ${wp.subject}`; + this.label = this.resolveLabel(wp.id, wp.subject, wp.type?.self.title); + this.collapsibleState = this.resolveCollapsibleState(wp); + this.iconPath = this.resolveIcon(wp.status?.self.title as WPStatus); + } + + private resolveLabel( + id: number, + subject: string, + type?: string, + ): TreeItemLabel { + let label = `#${id} ${subject}`; if (type) label += ` ${type}`; - this.label = { + return { label, highlights: [ - [0, Math.floor(Math.log10(Math.abs(wp.id))) + 2], + [0, Math.floor(Math.log10(Math.abs(id))) + 2], [label.length - (type?.length ?? 0), label.length], ], }; - this.collapsibleState = - wp.children?.length > 0 - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.None; - this.iconPath = iconPath + } + + private resolveCollapsibleState(wp: WP): TreeItemCollapsibleState { + return wp.children?.length > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None; + } + + private resolveIcon(status: WPStatus): Uri | undefined { + const iconPath = getIconPathByStatus(status); + return iconPath ? vscode.Uri.file(path.join(__dirname, iconPath)) : undefined; } From 20e3560049d23905140c02a386f70ef1c7d63708 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Mon, 19 Jun 2023 02:28:19 +0300 Subject: [PATCH 33/38] wp repository save command --- .../workPackage/__mocks__/wp.repository.ts | 2 ++ .../workPackage/wp.repository.interface.ts | 1 + .../workPackage/wp.repository.spec.ts | 14 ++++++++++++++ src/infrastructure/workPackage/wp.repository.ts | 4 ++++ 4 files changed, 21 insertions(+) diff --git a/src/infrastructure/workPackage/__mocks__/wp.repository.ts b/src/infrastructure/workPackage/__mocks__/wp.repository.ts index fcff5cf..078eb6b 100644 --- a/src/infrastructure/workPackage/__mocks__/wp.repository.ts +++ b/src/infrastructure/workPackage/__mocks__/wp.repository.ts @@ -3,6 +3,8 @@ import WPRepository from "../wp.repository.interface"; @injectable() export default class WPRepositoryImpl implements WPRepository { + save = jest.fn(); + findById = jest.fn(); findByParentId = jest.fn(); diff --git a/src/infrastructure/workPackage/wp.repository.interface.ts b/src/infrastructure/workPackage/wp.repository.interface.ts index 1d706ce..42cb0a8 100644 --- a/src/infrastructure/workPackage/wp.repository.interface.ts +++ b/src/infrastructure/workPackage/wp.repository.interface.ts @@ -2,6 +2,7 @@ import { WP } from "op-client"; import { Event } from "vscode"; export default interface WPRepository { + save(wp: WP): Promise; findById(id: number): WP; findByParentId(parentId: number): WP[]; findByProjectId(projectId: number): WP[]; diff --git a/src/infrastructure/workPackage/wp.repository.spec.ts b/src/infrastructure/workPackage/wp.repository.spec.ts index a2e2d1b..dfcf157 100644 --- a/src/infrastructure/workPackage/wp.repository.spec.ts +++ b/src/infrastructure/workPackage/wp.repository.spec.ts @@ -36,6 +36,20 @@ describe("WP repository test suite", () => { await repository.refetch(); }); + describe("save", () => { + it("should return client.save", async () => { + const wp = new WP(); + jest.spyOn(client, "save").mockResolvedValue(wp); + expect(await repository.save(wp)).toEqual(wp); + }); + it("should call client.save", async () => { + const wp = new WP(); + jest.spyOn(client, "save"); + await repository.save(wp); + expect(client.save).toHaveBeenLastCalledWith(wp); + }); + }); + describe("findById", () => { it("should return wp1", () => { expect(repository.findById(wp1.id)).toEqual(wp1); diff --git a/src/infrastructure/workPackage/wp.repository.ts b/src/infrastructure/workPackage/wp.repository.ts index ed5aa6d..9e55746 100644 --- a/src/infrastructure/workPackage/wp.repository.ts +++ b/src/infrastructure/workPackage/wp.repository.ts @@ -20,6 +20,10 @@ export default class WPRepositoryImpl implements WPRepository { @inject(TOKENS.opClient) private readonly _client: OpenProjectClient, ) {} + save(wp: WP): Promise { + return this._client.save(wp); + } + findById(id: number): WP { const result = this._wps.find((wp) => wp.id === id); if (!result) throw new WPNotFoundException(); From 7fff2eea13ddf932bd7254e49f6efed026c1a960 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Mon, 19 Jun 2023 02:28:29 +0300 Subject: [PATCH 34/38] set wp status command --- package.json | 13 +++ .../setWPStatus.command.interface.ts | 5 ++ .../setWpStatus/setWPStatus.command.spec.ts | 80 +++++++++++++++++++ .../setWpStatus/setWPStatus.command.ts | 51 ++++++++++++ src/extension.spec.ts | 6 +- src/extension.ts | 9 +++ 6 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 src/application/commands/setWpStatus/setWPStatus.command.interface.ts create mode 100644 src/application/commands/setWpStatus/setWPStatus.command.spec.ts diff --git a/package.json b/package.json index 73d6734..a3e7c34 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,12 @@ "shortTitle": "Filter", "icon": "$(filter)" }, + { + "command": "openproject.wp.setStatus", + "title": "Set workspace status", + "shortTitle": "Set status", + "icon": "$(tasklist)" + }, { "command": "workbench.actions.treeView.openproject-workspaces.collapseAll", "title": "Collapse all", @@ -58,6 +64,13 @@ "group": "navigation", "when": "view == openproject-workspaces" } + ], + "view/item/context": [ + { + "command": "openproject.wp.setStatus", + "group": "inline", + "when": "view == openproject-workspaces" + } ] }, "viewsContainers": { diff --git a/src/application/commands/setWpStatus/setWPStatus.command.interface.ts b/src/application/commands/setWpStatus/setWPStatus.command.interface.ts new file mode 100644 index 0000000..8e6f45b --- /dev/null +++ b/src/application/commands/setWpStatus/setWPStatus.command.interface.ts @@ -0,0 +1,5 @@ +import { WP } from "op-client"; + +export default interface SetWPStatusCommand { + setWPStatus(wp: WP): Promise; +} diff --git a/src/application/commands/setWpStatus/setWPStatus.command.spec.ts b/src/application/commands/setWpStatus/setWPStatus.command.spec.ts new file mode 100644 index 0000000..41624d0 --- /dev/null +++ b/src/application/commands/setWpStatus/setWPStatus.command.spec.ts @@ -0,0 +1,80 @@ +jest.mock("../../quickPicks/wpStatus/wpStatus.quickPick"); +jest.mock("../../views/openProject.treeDataProvider"); +jest.mock("../../../infrastructure/openProject/openProject.client"); +import { Status, WP } from "op-client"; +import container from "../../../DI/container"; +import TOKENS from "../../../DI/tokens"; +import WPStatus from "../../../infrastructure/openProject/wpStatus.enum"; +import WPRepository from "../../../infrastructure/workPackage/wp.repository.interface"; +import WPStatusQuickPick from "../../quickPicks/wpStatus/wpStatus.quickPick"; +import SetWPStatusCommandImpl from "./setWPStatus.command"; + +describe("SetWPStatusCommand test suit", () => { + let command: SetWPStatusCommandImpl; + let repository: WPRepository; + + beforeEach(() => { + command = container.get(TOKENS.setWPStatusCommand); + repository = container.get(TOKENS.wpRepository); + }); + + describe("setWPStatus", () => { + it("should show wpStatusQuickPick", async () => { + jest.spyOn(WPStatusQuickPick.prototype, "show"); + + await command.setWPStatus(new WP(1)); + + expect(WPStatusQuickPick.prototype.show).toHaveBeenCalled(); + }); + it("should set wp this new", async () => { + const wp = new WP(1); + const status = new Status(1); + + jest.spyOn(WPStatusQuickPick.prototype, "show").mockResolvedValue(status); + jest.spyOn(repository, "save").mockResolvedValue(wp); + + await command.setWPStatus(wp); + + expect(wp.status).toEqual(status); + }); + it("should save wp", async () => { + const wp = new WP(1); + + jest + .spyOn(WPStatusQuickPick.prototype, "show") + .mockResolvedValue(WPStatus.closed); + jest.spyOn(repository, "save").mockResolvedValue(wp); + + await command.setWPStatus(wp); + + expect(repository.save).toHaveBeenLastCalledWith(wp); + }); + it("should show success message", async () => { + const wp = new WP(1); + + jest + .spyOn(WPStatusQuickPick.prototype, "show") + .mockResolvedValue(WPStatus.closed); + jest.spyOn(repository, "save").mockResolvedValue(wp); + jest.spyOn(command as any, "showSuccessMessage"); + + await command.setWPStatus(wp); + + expect(command["showSuccessMessage"]).toHaveBeenCalled(); + }); + it("should show failure message", async () => { + const wp = new WP(1); + const err = new Error(); + + jest + .spyOn(WPStatusQuickPick.prototype, "show") + .mockResolvedValue(WPStatus.closed); + jest.spyOn(repository, "save").mockRejectedValue(err); + jest.spyOn(command as any, "showFailureMessage"); + + await command.setWPStatus(wp); + + expect(command["showFailureMessage"]).toHaveBeenLastCalledWith(err); + }); + }); +}); diff --git a/src/application/commands/setWpStatus/setWPStatus.command.ts b/src/application/commands/setWpStatus/setWPStatus.command.ts index e69de29..490eda9 100644 --- a/src/application/commands/setWpStatus/setWPStatus.command.ts +++ b/src/application/commands/setWpStatus/setWPStatus.command.ts @@ -0,0 +1,51 @@ +import { inject, injectable } from "inversify"; +import { WP } from "op-client"; +import * as vscode from "vscode"; +import TOKENS from "../../../DI/tokens"; +import StatusRepository from "../../../infrastructure/status/status.repository.interface"; +import WPRepository from "../../../infrastructure/workPackage/wp.repository.interface"; +import WPStatusQuickPick from "../../quickPicks/wpStatus/wpStatus.quickPick"; +import OpenProjectTreeDataProvider from "../../views/openProject.treeDataProvider.interface"; +import SetWPStatusCommand from "./setWPStatus.command.interface"; + +@injectable() +export default class SetWPStatusCommandImpl implements SetWPStatusCommand { + constructor( + @inject(TOKENS.wpRepository) private readonly _wpRepo: WPRepository, + @inject(TOKENS.opTreeView) + private readonly _treeView: OpenProjectTreeDataProvider, + @inject(TOKENS.statusRepository) + private readonly _statusRepo: StatusRepository, + ) {} + + async setWPStatus(wp: WP): Promise { + const newWP = wp; + const statuses = this._statusRepo.findAll(); + const quickPick = new WPStatusQuickPick( + "Choose new status: ", + statuses, + false, + ); + const newStatus = await quickPick.show(); + if (newStatus) { + newWP.status = newStatus; + await this._wpRepo + .save(wp) + .then(this.showSuccessMessage) + .then(() => this.refreshView()) + .catch(this.showFailureMessage); + } + } + + private showSuccessMessage() { + vscode.window.showInformationMessage("WP updated!"); + } + + private showFailureMessage(err: Error) { + vscode.window.showErrorMessage(`WP was not updated: ${err.message}!`); + } + + private refreshView() { + this._treeView.redraw(); + } +} diff --git a/src/extension.spec.ts b/src/extension.spec.ts index ee806ac..2dc8a42 100644 --- a/src/extension.spec.ts +++ b/src/extension.spec.ts @@ -9,7 +9,7 @@ import TOKENS from "./DI/tokens"; import AuthorizeClientCommand from "./application/commands/authorize/authorizeClientCommand.interface"; import SetupFiltersCommand from "./application/commands/filter/setupFilters.command.interface"; import RefreshWPsCommand from "./application/commands/refresh/refreshWPsCommand.interface"; -import OpenProjectTreeDataProvider from "./application/views/openProjectTreeDataProvider.interface"; +import OpenProjectTreeDataProvider from "./application/views/openProject.treeDataProvider.interface"; import CompositeWPsFilter from "./core/filter/composite/composite.wpsFilter.interface"; import { activate, deactivate } from "./extension"; @@ -78,10 +78,6 @@ describe("activate", () => { }); describe("subscriptions", () => { - it("adds 4 subscriptions to context", () => { - activate(context); - expect(context.subscriptions).toHaveLength(4); - }); it("adds authCommand to subscriptions to context", () => { jest .spyOn(vscode.commands, "registerCommand") diff --git a/src/extension.ts b/src/extension.ts index e36176a..4d5ec29 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,6 +5,7 @@ import TOKENS from "./DI/tokens"; import AuthorizeClientCommand from "./application/commands/authorize/authorizeClientCommand.interface"; import SetupFiltersCommand from "./application/commands/filter/setupFilters.command.interface"; import RefreshWPsCommand from "./application/commands/refresh/refreshWPsCommand.interface"; +import SetWPStatusCommand from "./application/commands/setWpStatus/setWPStatus.command.interface"; import OpenProjectTreeDataProvider from "./application/views/openProject.treeDataProvider"; import CompositeWPsFilter from "./core/filter/composite/composite.wpsFilter.interface"; import Filter from "./core/filter/filter.interface"; @@ -21,6 +22,9 @@ export function activate(context: vscode.ExtensionContext) { const setupFilterCommand = container.get( TOKENS.setupFiltersCommand, ); + const setWPStatusCommand = container.get( + TOKENS.setWPStatusCommand, + ); const treeView = container.get( TOKENS.opTreeView, ); @@ -31,6 +35,11 @@ export function activate(context: vscode.ExtensionContext) { authCommand.authorizeClient, authCommand, ), + vscode.commands.registerCommand( + "openproject.wp.setStatus", + setWPStatusCommand.setWPStatus, + setWPStatusCommand, + ), vscode.commands.registerCommand( "openproject.refresh", refreshCommand.refreshWPs, From 50561ea7321393e5f47a7f23a26cf21594592108 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Mon, 19 Jun 2023 02:39:34 +0300 Subject: [PATCH 35/38] changelog updated --- CHANGELOG.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c05e38..fd0ac9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.0] - 25-05-2023 ### Added + - extension icon ### Fixed + - extension version -- Readme image \ No newline at end of file +- Readme image + +## [2.0.0] - 19-06-2023 + +### Added + +- projects in the workpackage view +- filters: + - text filter + - project filter + - status filter +- setup filters command +- set work package status command +- dependency injection +- refresh wp view button +- "collapse all" wp view button + +### Changed + +- file structure +- class structure +- singletons removed, now using dependency injection +- some ESLint rules +- vscode mock updated From 4f20f6737d7c5028f2c15e48cf37648912315c12 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Mon, 19 Jun 2023 02:45:27 +0300 Subject: [PATCH 36/38] readme updated --- README.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5c493bd..a91c633 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ Extension for [OpenProject](https://www.openproject.org/) - project management s ## Features -- Authorization. - Getting list of your work packages. +- Filtering your work packages. +- Setting a new status for a work package. ## Requirements @@ -23,10 +24,16 @@ This extension contributes the following settings: ## Release Notes -Users appreciate release notes as you update your extension. - ### 1.0.0 Initial release of OpenProject VSCode extension +### 2.0.0 + +- Complete refactoring; +- Filtering; +- Set Work Package status command and button; +- Refresh button; +- "Collapse All" button; + **Enjoy!** From a6de338e74a8a26b58ef5aa7e0bc93c0e4868c80 Mon Sep 17 00:00:00 2001 From: goodhumored Date: Mon, 19 Jun 2023 02:45:40 +0300 Subject: [PATCH 37/38] package json version updated --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a3e7c34..d5eb4f2 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publisher": "bitswar", "displayName": "OpenProject", "description": "OpenProject extension for VSCode", - "version": "1.0.0", + "version": "2.0.0", "icon": "pictures/icon.png", "engines": { "vscode": "^1.78.0" From 00691a15fed34aec2f7980695e0ecf01a35032bb Mon Sep 17 00:00:00 2001 From: goodhumored Date: Mon, 19 Jun 2023 02:56:30 +0300 Subject: [PATCH 38/38] readme feature images added --- README.md | 6 +++++- pictures/new_wp_status.png | Bin 0 -> 27495 bytes pictures/project_filter.png | Bin 0 -> 26387 bytes pictures/status_filter.png | Bin 0 -> 31301 bytes pictures/text_filter.png | Bin 0 -> 13533 bytes pictures/work_packages.png | Bin 41160 -> 85922 bytes 6 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 pictures/new_wp_status.png create mode 100644 pictures/project_filter.png create mode 100644 pictures/status_filter.png create mode 100644 pictures/text_filter.png diff --git a/README.md b/README.md index a91c633..06f563f 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,17 @@ Extension for [OpenProject](https://www.openproject.org/) - project management system. -![picture](pictures/work_packages.png) +![work packages](pictures/work_packages.png) ## Features - Getting list of your work packages. - Filtering your work packages. +![status filter](pictures/status_filter.png) +![project filter](pictures/project_filter.png) +![text filter](pictures/text_filter.png) - Setting a new status for a work package. +![set wp status image](pictures/new_wp_status.png) ## Requirements diff --git a/pictures/new_wp_status.png b/pictures/new_wp_status.png new file mode 100644 index 0000000000000000000000000000000000000000..1b07c29f04420228b5d996245094dd5e3de7f13e GIT binary patch literal 27495 zcmagG1yoht+C9Dj0R<@$kgive7U}L1Y2lCu5b18=P|}hrCEeZKE#2MS-5uw@dGGz= z{_g$8pD`R~z}e^QwbxqDeC9Ksx%{pmCy9YZga!ZrhP0HJ5&$5Y0{}wkQ$+AD8R1=q z;L9^>DRnyl!2I+04IyAa}W2tWR)ewyu* z!r-U)Wp_tnKR2@4S=t`&7R|k3FSkzf!QG AiFGqXNCwgrJ~Yq-XA2iNUWlHjs)g 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 e@gZwvQz|s4jqXl{OHqe)Ke(HE2nOOApC$UD& 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 literal 0 HcmV?d00001 diff --git a/pictures/project_filter.png b/pictures/project_filter.png new file mode 100644 index 0000000000000000000000000000000000000000..a89ca088f478f4cab720658cb9f5b9cd0055dba1 GIT binary patch literal 26387 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 literal 0 HcmV?d00001 diff --git a/pictures/status_filter.png b/pictures/status_filter.png new file mode 100644 index 0000000000000000000000000000000000000000..405b50c339cf67db8fe6a84f389b2539d15bb53f GIT binary patch literal 31301 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 Kq{}1?1OFEeGuhVw literal 0 HcmV?d00001 diff --git a/pictures/text_filter.png b/pictures/text_filter.png new file mode 100644 index 0000000000000000000000000000000000000000..44ef45ba8c0e84e7720a7a9974d7e222103141dc GIT binary patch literal 13533 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 literal 0 HcmV?d00001 diff --git a/pictures/work_packages.png b/pictures/work_packages.png index 15f72d7e99e277e646b8144ad9feb281f306b16a..7313f64be3b7132902fa434a6b14f610cc235d6b 100644 GIT binary patch literal 85922 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 W9!J3GaybBGNs7veR0w_Z|9=2s5q_uu literal 41160 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

    7czm1xu$I|^*|dM; zdE2IB2Ku7d_RoO%`N zg3a4yU6$a!t5-=vyVTn;EF^8YMJeOXcymnPFtV>#a0PeDcq0Lg^i z6{wlgaTHzp{73pt)-gocY1Ip6&z6YKe;-SkH&eOyh3<8G#H1<4!27fXc?-lvv#PjSYU>zClAad5|thQ8fNGk;seWclyLh>V%x|KL_rHaZP%+JN-Kj+6t~3XWh(y$ z!pPRgfbf6&0&eJ~J5RlDvSStuRDXGNsDMStcZeSeQU6(zt8}uzbDZ=({s|1DhG~9i z%|;3o8U@_llmMgaE~upd`r}X2u|IyUD!T{+KbjVvJ&To{z_^{4yehqz9tg?*{-PLp z#E*+ro#HGEJ#VF8*UPWHl!{_%)e%FchELc{IWC?+I!^V8vXQ%-ctD_8SYdNU@4&)o zL=-cfe&Z|8!&s^2FB9cb9toJIO-!1-xWjgcS2QT+E2-|0VIG64UlmqHYVLhbCroJ9 z7Qz>%A0s&i7xXVh6|sE=>%(HUn%okV?s;pr)*;zJ=f(|3>ur<7SD#&MqZ7$BH=P13 z1n^sjt)Vlxp(?r}VskxCdg~Mw-VSs_xRncT8(Uwj?<_yIP%%i_psGm}k%yiw@T?>8 zp>E0^Ml2xE=!Z+E1-oDjfitb|&6jv0`5N*$t(U?@^m{!8J7UXj;pxVCq>B^%iy zDyy9pSFqNHQGQB9l8@_lq^HUaPsRsG&arl_EI#$MJTzM)7Zpr7MY8z4F?qhWPMPeu z21a<__IAAA&C2}HU5B%xWb+z77;u1Ff?zYQr(t^C+?jir6TUXnZP!F z7SGCOk0-7zogbDi(cFS1VTC=SE>bZfe$*Q3(@76$D=~*BD{Cmhyrlvlr^9VKNzF4h zpJw42)f4`tr`kWz(6B*X0{Vli0q!nWZ6c!d9AG?hijg5Rfo!Hrtsgf2JPSTP(?7b<$2=~k+ zradCW2?)487gUPCn7WMJ2}sQhahBwP3%^#E3zzl7^%atPpE}5UG>v|SE;QgV2)ahS zqt=LrZ?^^b$6AGvFl~yWZ_YUHj%REpAP?p6a@hr@T4!YLE=Hi%N1sTfy+-EYx!iY~ z+hg0UyIbv~78nu}Q5Mu1HhPHd@87ox?lHhd1>v=bKt&8Z0RErwfO&-yUhY;HoR$HW zNB19fbaAO0xRhEv#6nPFhT^Bx)OV4};Vl&MAutHRai>uXL~e}4RSE+~$PAv~F;Lw6 zM;$RT7mUhr391Fa=z*wy)xyfSA#F$k$#wX(AsW9k+PD@FB zIX46k8w+%WFs)sx5`+#=_A0wHAdp1ZSgYKU51WM-i|8?Y6+&9OpB=3G#4%g(r!pFY zAgOP7XD=+Fsa zgy0i!*)644oiEHW(%!Vr zDxoCzbai6s0#gNjXQz;n83V}Z5N^U1{?q-4g|WY;gdC@u(;~*Cp=2P%wN)ePq}qNt%zA!io+FGHL&g#Z8m