diff --git a/.eslintrc.json b/.eslintrc.json index 0596d3e..f27928b 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" @@ -111,8 +109,12 @@ "warn", { "ignore": [ + -1, 0, - 1 + 1, + 2, + 5, + 10 ] } ], @@ -260,6 +262,9 @@ "@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-shadow": "off", + "no-undef": "off" } } \ No newline at end of file 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 diff --git a/README.md b/README.md index 5c493bd..06f563f 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,17 @@ Extension for [OpenProject](https://www.openproject.org/) - project management system. -![picture](pictures/work_packages.png) +![work packages](pictures/work_packages.png) ## Features -- Authorization. - 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 @@ -23,10 +28,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!** diff --git a/package.json b/package.json index 5e8b739..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" @@ -19,14 +19,60 @@ { "command": "openproject.auth", "title": "Authorize", - "shortTitle": "Auth" + "shortTitle": "Auth", + "icon": "$(account)" }, { "command": "openproject.refresh", "title": "Refresh work packages", - "shortTitle": "Refresh" + "shortTitle": "Refresh", + "icon": "$(sync)" + }, + { + "command": "openproject.setupFilter", + "title": "Filter work packages", + "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", + "shortTitle": "Collapse", + "icon": "$(collapse-all)" } ], + "menus": { + "view/title": [ + { + "command": "openproject.refresh", + "group": "navigation", + "when": "view == openproject-workspaces" + }, + { + "command": "openproject.setupFilter", + "group": "navigation", + "when": "view == openproject-workspaces" + }, + { + "command": "workbench.actions.treeView.openproject-workspaces.collapseAll", + "group": "navigation", + "when": "view == openproject-workspaces" + } + ], + "view/item/context": [ + { + "command": "openproject.wp.setStatus", + "group": "inline", + "when": "view == openproject-workspaces" + } + ] + }, "viewsContainers": { "activitybar": [ { @@ -113,7 +159,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": [ @@ -131,4 +179,4 @@ ], "coverageDirectory": "../coverage" } -} +} \ No newline at end of file diff --git a/pictures/new_wp_status.png b/pictures/new_wp_status.png new file mode 100644 index 0000000..1b07c29 Binary files /dev/null and b/pictures/new_wp_status.png differ diff --git a/pictures/project_filter.png b/pictures/project_filter.png new file mode 100644 index 0000000..a89ca08 Binary files /dev/null and b/pictures/project_filter.png differ diff --git a/pictures/status_filter.png b/pictures/status_filter.png new file mode 100644 index 0000000..405b50c Binary files /dev/null and b/pictures/status_filter.png differ diff --git a/pictures/text_filter.png b/pictures/text_filter.png new file mode 100644 index 0000000..44ef45b Binary files /dev/null and b/pictures/text_filter.png differ diff --git a/pictures/work_packages.png b/pictures/work_packages.png index 15f72d7..7313f64 100644 Binary files a/pictures/work_packages.png and b/pictures/work_packages.png differ diff --git a/src/DI/container.ts b/src/DI/container.ts new file mode 100644 index 0000000..049e3bd --- /dev/null +++ b/src/DI/container.ts @@ -0,0 +1,102 @@ +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 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/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"; +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"; +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/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"; +import StatusRepository from "../infrastructure/status/status.repository.interface"; +import StatusRepositoryImpl from "../infrastructure/status/status.repository"; + +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.textFilter) + .to(TextWPsFilterImpl) + .inSingletonScope(); + +container + .bind(TOKENS.projectFilter) + .to(ProjectsFilterImpl) + .inSingletonScope(); + +container + .bind(TOKENS.statusFilter) + .to(StatusWPsFilterImpl) + .inSingletonScope(); + +container + .bind(TOKENS.compositeFilter) + .to(CompositeWPsFilterImpl) + .inSingletonScope(); + +container + .bind(TOKENS.wpRepository) + .to(WPRepositoryImpl) + .inSingletonScope(); + +container + .bind(TOKENS.projectRepository) + .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 new file mode 100644 index 0000000..f31e251 --- /dev/null +++ b/src/DI/tokens.ts @@ -0,0 +1,31 @@ +const repositoryTokens = { + wpRepository: Symbol.for("WPRepository"), + projectRepository: Symbol.for("ProjectRepository"), + statusRepository: Symbol.for("StatusRepository"), +}; + +const comandTokens = { + refreshWPsCommand: Symbol.for("RefreshWPsCommand"), + authorizeCommand: Symbol.for("AuthorizeClientCommand"), + setupFiltersCommand: Symbol.for("FilterWPsCommand"), + setWPStatusCommand: Symbol.for("SetWPStatusCommand"), +}; + +const filterTokens = { + filter: Symbol.for("filter"), + textFilter: Symbol.for("TextWPsFilter"), + projectFilter: Symbol.for("ProjectWPsFilter"), + statusFilter: Symbol.for("StatusWPsFilter"), + 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/__mocks__/vscode.js b/src/__mocks__/vscode.ts similarity index 77% rename from src/__mocks__/vscode.js rename to src/__mocks__/vscode.ts index e406dc3..f89e93f 100644 --- a/src/__mocks__/vscode.js +++ b/src/__mocks__/vscode.ts @@ -2,9 +2,15 @@ /* 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(); + + event = jest.fn(); +} const languages = { createDiagnosticCollection: jest.fn(), @@ -12,7 +18,7 @@ const languages = { const StatusBarAlignment = {}; -const window = { +const windowMocked: typeof vscodeWindow = { createStatusBarItem: jest.fn(() => ({ show: jest.fn(), })), @@ -21,7 +27,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 +43,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 +53,7 @@ const debug = { onDidTerminateDebugSession: jest.fn(), startDebugging: jest.fn(), }; +const RangeMocked = jest.fn(); const commands = { executeCommand: jest.fn(), @@ -57,21 +66,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/application/commands/authorize/authorizeClient.command.spec.ts b/src/application/commands/authorize/authorizeClient.command.spec.ts new file mode 100644 index 0000000..1a4d62f --- /dev/null +++ b/src/application/commands/authorize/authorizeClient.command.spec.ts @@ -0,0 +1,121 @@ +/* eslint-disable no-new */ +jest.mock("../../../infrastructure/openProject/openProject.client"); + +import { faker } from "@faker-js/faker"; +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"; + +describe("Authorize client command test suite", () => { + let command: AuthorizeClientCommandImpl; + let client: OpenProjectClient; + + beforeEach(() => { + jest.clearAllMocks(); + command = container.get(TOKENS.authorizeCommand); + 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({ + 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", () => { + const emptyConfig = new VSCodeConfigMock({}); + jest + .spyOn(vscode.workspace, "getConfiguration") + .mockReturnValue(emptyConfig); + + command.authorizeClient(); + + expect(client.init).toHaveBeenLastCalledWith("", ""); + }); + }); + + describe("showMessage", () => { + it("should show message 'Hello' on success", async () => { + const user = new User(1); + user.name = `${faker.person.firstName()} ${faker.person.lastName()}`; + + jest.spyOn(vscode.window, "showInformationMessage"); + jest.spyOn(client, "getUser").mockResolvedValue(user); + + await command.showMessage(); + + expect(vscode.window.showInformationMessage).toHaveBeenLastCalledWith( + `Hello, ${user.name}!`, + ); + }); + it("should show error message", async () => { + jest.spyOn(vscode.window, "showErrorMessage"); + jest.spyOn(client, "getUser").mockRejectedValue(new UserNotFound()); + await command.showMessage(); + + expect(vscode.window.showErrorMessage).toHaveBeenLastCalledWith( + "Failed connecting to OpenProject", + ); + }); + }); + + describe("setAuthedTrue", () => { + it("should set 'openproject.authed' to true", () => { + jest.spyOn(vscode.commands, "executeCommand"); + + command.setAuthedTrue(); + + 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 new file mode 100644 index 0000000..247d37d --- /dev/null +++ b/src/application/commands/authorize/authorizeClient.command.ts @@ -0,0 +1,42 @@ +import { inject, injectable } from "inversify"; +import * as vscode from "vscode"; +import TOKENS from "../../../DI/tokens"; +import Logger from "../../../infrastructure/logger/logger.interface"; +import OpenProjectClient from "../../../infrastructure/openProject/openProject.client.interface"; +import AuthorizeClientCommand from "./authorizeClientCommand.interface"; + +@injectable() +export default class AuthorizeClientCommandImpl + implements AuthorizeClientCommand +{ + constructor( + @inject(TOKENS.opClient) + private readonly _client: OpenProjectClient, + @inject(TOKENS.logger) + private readonly _logger: Logger, + ) { + this._client.onInit(this.showMessage, this); + this._client.onInit(this.setAuthedTrue, this); + } + + authorizeClient() { + const config = vscode.workspace.getConfiguration("openproject"); + this._client.init(config.get("base_url") ?? "", config.get("token") ?? ""); + } + + setAuthedTrue() { + vscode.commands.executeCommand("setContext", "openproject.authed", true); + } + + showMessage() { + return this._client + .getUser() + .then((user) => { + vscode.window.showInformationMessage(`Hello, ${user.name}!`); + }) + .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 new file mode 100644 index 0000000..61d15f1 --- /dev/null +++ b/src/application/commands/authorize/authorizeClientCommand.interface.ts @@ -0,0 +1,3 @@ +export default interface AuthorizeClientCommand { + authorizeClient(): void; +} 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/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..2bc5b99 --- /dev/null +++ b/src/application/commands/filter/setupFilters.command.spec.ts @@ -0,0 +1,194 @@ +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, 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 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"; + +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, + ); + const statusRepo = container.get(TOKENS.statusRepository); + + 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", () => { + 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(projectRepo, "findAll").mockReturnValue([]); + jest.spyOn(ProjectQuickPick.prototype, "show").mockResolvedValue([]); + await command.setupProjectFilter(); + expect(ProjectQuickPick.prototype.show).toHaveBeenCalled(); + }); + 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(projectFilter.setProjectFilter).toHaveBeenLastCalledWith( + projectIds, + ); + }); + it("should setProjectFilter filter if got undefined", async () => { + jest.spyOn(projectRepo, "findAll").mockReturnValue([]); + jest.spyOn(projectFilter, "getProjectFilter").mockReturnValue(projectIds); + jest + .spyOn(ProjectQuickPick.prototype, "show") + .mockResolvedValue(undefined); + jest.spyOn(projectFilter, "setProjectFilter"); + + await command.setupProjectFilter(); + + expect(projectFilter.setProjectFilter).toHaveBeenLastCalledWith( + projectIds, + ); + }); + 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( + projectIds, + ); + }); + }); + describe("setupStatusFilter", () => { + 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); + jest.spyOn(statusFilter, "setStatusFilter"); + + await command.setupStatusFilter(); + + expect(statusFilter.setStatusFilter).toHaveBeenLastCalledWith(statuses); + }); + it("should setStatusFilter filter if got undefined", async () => { + jest.spyOn(statusRepo, "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(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") + .mockResolvedValue(undefined); + jest.spyOn(statusFilter, "setStatusFilter"); + + await command.setupStatusFilter(); + + expect(statusFilter.setStatusFilter).toHaveBeenLastCalledWith(statuses); + }); + }); +}); diff --git a/src/application/commands/filter/setupFilters.command.ts b/src/application/commands/filter/setupFilters.command.ts new file mode 100644 index 0000000..6ebefe7 --- /dev/null +++ b/src/application/commands/filter/setupFilters.command.ts @@ -0,0 +1,91 @@ +import { inject, injectable } from "inversify"; +import * as vscode from "vscode"; +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 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"; + +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: ProjectsFilter, + @inject(TOKENS.statusFilter) + private readonly _statusFilter: StatusWPsFilter, + @inject(TOKENS.projectRepository) + private readonly _projectRepo: ProjectRepository, + @inject(TOKENS.statusRepository) + private readonly _statusRepo: StatusRepository, + ) {} + + 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 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: ", + allProjects, + true, + ); + quickPick.setPickedProjects(oldProjectsFilter); + const pickedProjects = await quickPick.show(); + this._projectFilter.setProjectFilter(pickedProjects ?? oldProjectsFilter); + } + + async setupStatusFilter() { + 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(oldFilter ?? allStatuses); + + const pickedStatuses = await quickPick.show(); + + this._statusFilter.setStatusFilter(pickedStatuses ?? oldFilter); + } +} 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..8c0ccfd --- /dev/null +++ b/src/application/commands/refresh/refreshWPs.command.spec.ts @@ -0,0 +1,24 @@ +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, "refresh"); + + command.refreshWPs(); + + expect(treeDataProvider.refresh).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..4bda663 --- /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/openProject.treeDataProvider.interface"; +import RefreshWPsCommand from "./refreshWPsCommand.interface"; + +@injectable() +export default class RefreshWPsCommandImpl implements RefreshWPsCommand { + constructor( + @inject(TOKENS.opTreeView) + private readonly _treeDataProvider: OpenProjectTreeDataProvider, + ) {} + + refreshWPs() { + this._treeDataProvider.refresh(); + } +} 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/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 new file mode 100644 index 0000000..490eda9 --- /dev/null +++ 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/application/quickPicks/project/project.quickPick.spec.ts b/src/application/quickPicks/project/project.quickPick.spec.ts new file mode 100644 index 0000000..0f40eef --- /dev/null +++ b/src/application/quickPicks/project/project.quickPick.spec.ts @@ -0,0 +1,113 @@ +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", () => { + 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); + }); + + 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 new file mode 100644 index 0000000..1738aea --- /dev/null +++ b/src/application/quickPicks/project/project.quickPick.ts @@ -0,0 +1,48 @@ +import { Project } from "op-client"; +import * as vscode from "vscode"; +import ProjectQuickPickItem from "./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[]): void { + projects.forEach((project: number) => { + const projectItem = this._items.find( + (item) => item.projectId === project, + ); + 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/quickPicks/project/project.quickPickItem.spec.ts b/src/application/quickPicks/project/project.quickPickItem.spec.ts new file mode 100644 index 0000000..cd1e80c --- /dev/null +++ b/src/application/quickPicks/project/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/quickPicks/project/project.quickPickItem.ts b/src/application/quickPicks/project/project.quickPickItem.ts new file mode 100644 index 0000000..1a8b49a --- /dev/null +++ b/src/application/quickPicks/project/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/quickPicks/wpStatus/wpStatus.quickPick.spec.ts b/src/application/quickPicks/wpStatus/wpStatus.quickPick.spec.ts new file mode 100644 index 0000000..e5f0d29 --- /dev/null +++ b/src/application/quickPicks/wpStatus/wpStatus.quickPick.spec.ts @@ -0,0 +1,104 @@ +/* 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, statuses, multiSelect); + }); + }); + describe("setSelectedStatuses", () => { + let qp: WPStatusQuickPick; + + beforeEach(() => { + qp = new WPStatusQuickPick( + faker.string.alpha(), + statuses, + 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(); + }); + 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; + 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, [new Status(1)], 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); + + 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 new file mode 100644 index 0000000..9a273be --- /dev/null +++ b/src/application/quickPicks/wpStatus/wpStatus.quickPick.ts @@ -0,0 +1,51 @@ +import { Status } from "op-client"; +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, statuses: Status[], multiSelect: T) { + this._title = title; + this._multiSelect = multiSelect; + this._items = this.getStatusQuickPickItems(statuses); + } + + public setPickedStatuses(statuses: (number | WPStatus | Status)[]) { + statuses.forEach((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 ? Status[] : Status) | 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(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 new file mode 100644 index 0000000..a1d8d16 --- /dev/null +++ b/src/application/quickPicks/wpStatus/wpStatus.quickPickItem.spec.ts @@ -0,0 +1,43 @@ +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 = new Status(1); + status.body.name = "New"; + + const item = new WPStatusQuickPickItem(status); + + expect(item.label).toEqual(status.body.name); + }); + it("should have status as status", () => { + const status = new Status(1); + + const item = new WPStatusQuickPickItem(status); + + expect(item.status).toEqual(status); + }); + it("should have picked = true", () => { + const status = new Status(1); + const picked = true; + + const item = new WPStatusQuickPickItem(status, picked); + + expect(item.picked).toEqual(picked); + }); + it("should have picked = false", () => { + const status = new Status(1); + 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 = new Status(1); + + const item = new WPStatusQuickPickItem(status); + + expect(item.picked).toEqual(false); + }); +}); diff --git a/src/application/quickPicks/wpStatus/wpStatus.quickPickItem.ts b/src/application/quickPicks/wpStatus/wpStatus.quickPickItem.ts new file mode 100644 index 0000000..a7a9b8d --- /dev/null +++ b/src/application/quickPicks/wpStatus/wpStatus.quickPickItem.ts @@ -0,0 +1,16 @@ +import { Status } from "op-client"; +import { QuickPickItem } from "vscode"; + +export default class WPStatusQuickPickItem implements QuickPickItem { + label: string; + + picked: boolean; + + status: Status; + + constructor(status: Status, picked = false) { + this.status = status; + this.label = status.body.name; + this.picked = picked; + } +} diff --git a/src/application/views/__mocks__/openProject.treeDataProvider.ts b/src/application/views/__mocks__/openProject.treeDataProvider.ts new file mode 100644 index 0000000..3ae2f90 --- /dev/null +++ b/src/application/views/__mocks__/openProject.treeDataProvider.ts @@ -0,0 +1,23 @@ +import { injectable } from "inversify"; +import OpenProjectTreeDataProvider from "../openProject.treeDataProvider.interface"; + +@injectable() +export default class OpenProjectTreeDataProviderImpl + implements OpenProjectTreeDataProvider +{ + redraw = jest.fn(); + + refresh = jest.fn(); + + onDidChangeTreeData = jest.fn(); + + refreshWPs = jest.fn(); + + getTreeItem = jest.fn(); + + getChildren = jest.fn(); + + getParent = jest.fn(); + + resolveTreeItem = jest.fn(); +} diff --git a/src/application/views/openProject.treeDataProvider.interface.ts b/src/application/views/openProject.treeDataProvider.interface.ts new file mode 100644 index 0000000..b0d4d4b --- /dev/null +++ b/src/application/views/openProject.treeDataProvider.interface.ts @@ -0,0 +1,8 @@ +import { Project, WP } from "op-client"; +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 new file mode 100644 index 0000000..8aa162e --- /dev/null +++ b/src/application/views/openProject.treeDataProvider.spec.ts @@ -0,0 +1,202 @@ +/* eslint-disable no-new */ +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 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 OpenProjectTreeDataProviderImpl from "./openProject.treeDataProvider"; +import ProjectTreeItem from "./treeItems/project.treeItem"; +import WPTreeItem from "./treeItems/wp.treeItem"; + +describe("OpenProjectTreeDataProvider", () => { + const treeView = container.get( + TOKENS.opTreeView, + ); + const wpRepo = container.get(TOKENS.wpRepository); + const projectRepo = container.get( + TOKENS.projectRepository, + ); + const client = container.get(TOKENS.opClient); + const wpFilter = container.get>(TOKENS.compositeFilter); + const projectFilter = container.get>(TOKENS.projectFilter); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("Constructor", () => { + it("should subscribe to wpRepo onWPsChange", () => { + jest.spyOn(wpRepo, "onWPsChange"); + + 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, + wpFilter, + projectFilter, + ); + + expect(projectRepo.onProjectsChange).toHaveBeenCalled(); + }); + it("should subscribe to client onInit", () => { + jest.spyOn(client, "onInit"); + 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", () => { + 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", () => { + it("should return the project", () => { + const projects = faker.helpers.uniqueArray( + () => new Project(faker.number.int()), + 10, + ); + + jest.spyOn(projectRepo, "findAll").mockReturnValue(projects); + + const result = treeView.getChildren(); + expect(result).toEqual(projects); + }); + it("should return wps of project", () => { + const wps = faker.helpers.uniqueArray( + () => new WP(faker.number.int()), + 10, + ); + + 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 wps = faker.helpers.uniqueArray( + () => new WP(faker.number.int()), + 10, + ); + + jest.spyOn(wpRepo, "findByParentId").mockReturnValue(wps); + + const result = treeView.getChildren(new WP(1)); + expect(result).toEqual(wps); + }); + }); + + describe("resolveTreeItem", () => { + it("should return item", () => { + const item = {}; + expect(treeView.resolveTreeItem(item)).toEqual(item); + }); + }); + + describe("getParent", () => { + 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(); + }); + }); + 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 new file mode 100644 index 0000000..76513a3 --- /dev/null +++ b/src/application/views/openProject.treeDataProvider.ts @@ -0,0 +1,82 @@ +import { inject, injectable } from "inversify"; +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"; +import OpenProjectTreeDataProvider from "./openProject.treeDataProvider.interface"; +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) _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); + } + + 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 | Project | undefined, + ): ProviderResult { + if (!parentElement) { + return this._projectFilter.filter(this._projectRepository.findAll()); + } + if (parentElement instanceof Project) { + return this._wpFilter.filter( + this._wpRepository.findByProjectId(parentElement.id), + ); + } + return this._wpFilter.filter( + this._wpRepository.findByParentId(parentElement.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; + } + + refresh(): Promise { + return Promise.all([ + this._wpRepository.refetch(), + this._projectRepository.refetch(), + ]).then(); + } + + redraw() { + this._onDidChangeTreeData.fire(); + } +} 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..4559847 --- /dev/null +++ b/src/application/views/treeItems/wp.treeItem.ts @@ -0,0 +1,51 @@ +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; + + constructor(wp: WP) { + 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}`; + return { + label, + highlights: [ + [0, Math.floor(Math.log10(Math.abs(id))) + 2], + [label.length - (type?.length ?? 0), label.length], + ], + }; + } + + 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; + } +} diff --git a/src/commands/authorizeClient.command.spec.ts b/src/commands/authorizeClient.command.spec.ts deleted file mode 100644 index 613d609..0000000 --- a/src/commands/authorizeClient.command.spec.ts +++ /dev/null @@ -1,110 +0,0 @@ -// jest.mock("../__mocks__/vscode", () => vscode); -jest.mock("../openProject.client"); -jest.mock("../views/openProject.treeDataProvider"); -const vscode = require("../__mocks__/vscode"); - -import { faker } from "@faker-js/faker"; -import { User } from "op-client"; -import OpenProjectClient from "../openProject.client"; -import VSCodeConfigMock from "../test/config.mock"; -import OpenProjectTreeDataProvider from "../views/openProject.treeDataProvider"; -import authorizeClient from "./authorizeClient.command"; - -describe("Authorize client command test suit", () => { - const client = new OpenProjectClient(); - const config = new VSCodeConfigMock({ - base_url: faker.internet.url(), - token: faker.string.sample(), - }); - const user = new User(1); - const treeDataProvider = { refreshWPs: jest.fn() }; - - beforeEach(() => { - jest.clearAllMocks(); - jest.spyOn(OpenProjectClient, "getInstance").mockReturnValue(client); - jest.spyOn(vscode.workspace, "getConfiguration").mockReturnValue(config); - jest.spyOn(client, "init").mockResolvedValue(user); - - jest - .spyOn(OpenProjectTreeDataProvider, "getInstance") - .mockReturnValue(treeDataProvider as any); - - user.firstName = faker.person.firstName(); - user.lastName = faker.person.lastName(); - }); - - describe("Init call", () => { - it("should call init with correct data", async () => { - await authorizeClient(); - expect(client.init).toHaveBeenLastCalledWith( - config.get("base_url"), - config.get("token"), - ); - }); - it("should call init empty string", async () => { - const emptyConfig = new VSCodeConfigMock({}); - jest - .spyOn(vscode.workspace, "getConfiguration") - .mockReturnValue(emptyConfig); - - await authorizeClient(); - - expect(client.init).toHaveBeenLastCalledWith("", ""); - }); - }); - - describe("On success", () => { - it("should set 'openproject.authed' to true on success", async () => { - jest.spyOn(vscode.commands, "executeCommand"); - - await authorizeClient(); - - expect(vscode.commands.executeCommand).toHaveBeenLastCalledWith( - "setContext", - "openproject.authed", - true, - ); - }); - - it("should show message 'Hello' on success", async () => { - jest.spyOn(vscode.window, "showInformationMessage"); - - await authorizeClient(); - - expect(vscode.window.showInformationMessage).toHaveBeenLastCalledWith( - `Hello, ${user.firstName} ${user.lastName}!`, - ); - }); - - it("should call 'refresh WPs' on treeDataProvider", async () => { - await authorizeClient(); - - expect(treeDataProvider.refreshWPs).toHaveBeenCalled(); - }); - }); - describe("On fail", () => { - it("should show error message", async () => { - jest.spyOn(vscode.window, "showErrorMessage"); - jest.spyOn(client, "init").mockResolvedValue(undefined); - - await authorizeClient(); - - expect(vscode.window.showErrorMessage).toHaveBeenLastCalledWith( - "Failed connecting to OpenProject", - ); - }); - - it("should call nothing else", async () => { - jest.spyOn(client, "init").mockResolvedValue(undefined); - jest.spyOn(OpenProjectTreeDataProvider, "getInstance"); - jest.spyOn(vscode.commands, "executeCommand"); - jest.spyOn(vscode.window, "showInformationMessage"); - - await authorizeClient(); - - expect(OpenProjectTreeDataProvider.getInstance).not.toHaveBeenCalled(); - expect(vscode.commands.executeCommand).not.toHaveBeenCalled(); - expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/commands/authorizeClient.command.ts b/src/commands/authorizeClient.command.ts 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/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..5b432fa --- /dev/null +++ b/src/core/filter/composite/composite.wpsFilter.interface.ts @@ -0,0 +1,6 @@ +import { WP } from "op-client"; +import Filter from "../filter.interface"; + +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 new file mode 100644 index 0000000..f00d4a0 --- /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 Filter from "../filter.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: Filter[]; + 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: Filter = { + 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: Filter = { + 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..e1d4404 --- /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 Filter from "../filter.interface"; +import CompositeWPsFilter from "./composite.wpsFilter.interface"; + +@injectable() +export default class CompositeWPsFilterImpl implements CompositeWPsFilter { + private _filters: Filter[] = []; + + 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: 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.filter.ts b/src/core/filter/project/__mocks__/project.filter.ts new file mode 100644 index 0000000..756c83d --- /dev/null +++ b/src/core/filter/project/__mocks__/project.filter.ts @@ -0,0 +1,13 @@ +import { injectable } from "inversify"; +import ProjectsFilter from "../project.filter.interface"; + +@injectable() +export default class ProjectsFilterImpl implements ProjectsFilter { + filter = jest.fn(); + + onFilterUpdated = jest.fn(); + + getProjectFilter = jest.fn(); + + setProjectFilter = 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.filter.ts b/src/core/filter/project/project.filter.ts new file mode 100644 index 0000000..d2f4654 --- /dev/null +++ b/src/core/filter/project/project.filter.ts @@ -0,0 +1,30 @@ +import { injectable } from "inversify"; +import { Project } from "op-client"; +import * as vscode from "vscode"; +import ProjectsFilter from "./project.filter.interface"; + +@injectable() +export default class ProjectsFilterImpl implements ProjectsFilter { + private _projectIds?: number[] = undefined; + + private _onFilterUpdated: vscode.EventEmitter = + new vscode.EventEmitter(); + + filter(projects: Project[]): Project[] { + return projects.filter( + (project) => + this._projectIds === undefined || this._projectIds.includes(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..8a379aa --- /dev/null +++ b/src/core/filter/status/status.wpsFilter.interface.ts @@ -0,0 +1,7 @@ +import { Status, WP } from "op-client"; +import Filter from "../filter.interface"; + +export default interface StatusWPsFilter extends Filter { + 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 new file mode 100644 index 0000000..2b9b184 --- /dev/null +++ b/src/core/filter/status/status.wpsFilter.spec.ts @@ -0,0 +1,135 @@ +import { faker } from "@faker-js/faker"; +import { Project, Status, WP } from "op-client"; +import container from "../../../DI/container"; +import TOKENS from "../../../DI/tokens"; +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: new Status(1), + body: { + description: { + raw: "Lorem ipsum dolor amet...", + }, + }, + project: new Project(1), + } as WP; + const easterEggWP = { + subject: "Lorem ipsum!", + author: { + name: "goodhumored", + firstName: "Kirill", + lastName: "Nekrasov", + login: "goodhumored", + }, + body: { + description: { + raw: "Easter egg!", + }, + }, + project: new Project(1), + status: new Status(2), + } as WP; + const dannyWP = { + subject: "Title", + author: { + name: "dannyweiss", + firstName: "Danila", + lastName: "Smolyakov", + login: "dannyweiss", + }, + body: { + description: { + raw: "Lorem ipsum", + }, + }, + project: new Project(2), + status: new Status(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: new Project(0), + status: new Status(3), + } as WP; + + const wps: WP[] = [helloWorldWP, easterEggWP, dannyWP, bugWP]; + + it("should return all wps if statusFilter is undefined", () => { + const result = filter.filter(wps); + expect(result).toEqual(wps); + }); + 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 1", () => { + filter.setStatusFilter([new Status(1)]); + const result = filter.filter(wps); + expect(result).toEqual([helloWorldWP]); + }); + 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 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([new Status(4)]); + const result = filter.filter(wps); + expect(result).toEqual([]); + }); + }); + + 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 statusIds = faker.helpers.uniqueArray(faker.number.int, 5); + filter.setStatusFilter(statusIds); + 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..3c261cd --- /dev/null +++ b/src/core/filter/status/status.wpsFilter.ts @@ -0,0 +1,34 @@ +import { injectable } from "inversify"; +import { Status, WP } from "op-client"; +import * as vscode from "vscode"; +import StatusWPsFilter from "./status.wpsFilter.interface"; + +@injectable() +export default class StatusWPsFilterImpl implements StatusWPsFilter { + private _wpStatusFilter?: number[] = undefined; + + private _onFilterUpdated: vscode.EventEmitter = + new vscode.EventEmitter(); + + filter(wps: WP[]): WP[] { + return wps.filter( + (wp) => + this._wpStatusFilter === undefined || + this._wpStatusFilter.includes(wp.status.id), + ); + } + + onFilterUpdated = this._onFilterUpdated.event; + + setStatusFilter(statuses: Status[] | number[]): void { + this._wpStatusFilter = statuses.map((s) => { + if (typeof s === "number") return s; + return s.id; + }); + this._onFilterUpdated.fire(); + } + + getStatusFilter(): number[] | undefined { + return this._wpStatusFilter; + } +} 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..58ce056 --- /dev/null +++ b/src/core/filter/text/text.wpsFilter.interface.ts @@ -0,0 +1,7 @@ +import { WP } from "op-client"; +import Filter from "../filter.interface"; + +export default interface TextWPsFilter extends Filter { + 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..7d567c7 --- /dev/null +++ b/src/core/filter/text/text.wpsFilter.spec.ts @@ -0,0 +1,104 @@ +import { faker } from "@faker-js/faker"; +import { User, 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", + }, + body: { + description: { + raw: "Lorem ipsum dolor amet...", + }, + }, + } as WP; + const easterEggWP = { + subject: "Lorem ipsum!", + author: { name: "goodhumored" }, + body: { + description: { raw: "Easter egg!" }, + }, + } as WP; + const dannyWP = { + subject: "Title", + author: { + name: "dannyweiss", + login: "dannyweiss", + }, + body: { + description: { raw: "Lorem ipsum" }, + }, + } as WP; + const bugWP = { + subject: "Bug!", + author: new User(1), + 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..084eaa4 --- /dev/null +++ b/src/core/filter/text/text.wpsFilter.ts @@ -0,0 +1,34 @@ +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.name?.toLowerCase().includes(textFilterLower) || + wp.author.login?.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/extension.spec.ts b/src/extension.spec.ts index 0e473d7..2dc8a42 100644 --- a/src/extension.spec.ts +++ b/src/extension.spec.ts @@ -1,47 +1,123 @@ -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 SetupFiltersCommand from "./application/commands/filter/setupFilters.command.interface"; +import RefreshWPsCommand from "./application/commands/refresh/refreshWPsCommand.interface"; +import OpenProjectTreeDataProvider from "./application/views/openProject.treeDataProvider.interface"; +import CompositeWPsFilter from "./core/filter/composite/composite.wpsFilter.interface"; import { activate, deactivate } from "./extension"; describe("activate", () => { let context: any; + let authCommand: AuthorizeClientCommand; + let refreshCommand: RefreshWPsCommand; + let setupFiltersCommand: SetupFiltersCommand; + let treeView: OpenProjectTreeDataProvider; + + beforeAll(() => { + setupFiltersCommand = container.get( + TOKENS.setupFiltersCommand, + ); + 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, authCommand], + ]), + ); + }); + it("registers refresh command", () => { + activate(context); + expect(vscode.commands.registerCommand.mock.calls).toEqual( + expect.arrayContaining([ + ["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, + ], + ]), + ); + }); }); - 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 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"]), + ); + }); + }); + + describe("setupFilters", () => { + it("should push all filters", () => { + const compositeFilter = container.get( + TOKENS.compositeFilter, + ); + jest.spyOn(compositeFilter, "pushFilter"); + + activate(context); + + expect(compositeFilter.pushFilter).toHaveBeenCalled(); + }); }); }); diff --git a/src/extension.ts b/src/extension.ts index bc39f6f..4d5ec29 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,22 +1,73 @@ +import { WP } from "op-client"; 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 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"; export function activate(context: vscode.ExtensionContext) { - authorizeClient(); - const authCommand = vscode.commands.registerCommand( - "openproject.auth", - authorizeClient, + composeFilters(); + + const authCommand = container.get( + TOKENS.authorizeCommand, + ); + const refreshCommand = container.get( + TOKENS.refreshWPsCommand, + ); + const setupFilterCommand = container.get( + TOKENS.setupFiltersCommand, + ); + const setWPStatusCommand = container.get( + TOKENS.setWPStatusCommand, + ); + const treeView = container.get( + TOKENS.opTreeView, ); - const refreshWPsCommand = vscode.commands.registerCommand( - "openproject.refresh", - refreshWPs, + + const components = [ + vscode.commands.registerCommand( + "openproject.auth", + authCommand.authorizeClient, + authCommand, + ), + vscode.commands.registerCommand( + "openproject.wp.setStatus", + setWPStatusCommand.setWPStatus, + setWPStatusCommand, + ), + vscode.commands.registerCommand( + "openproject.refresh", + refreshCommand.refreshWPs, + refreshCommand, + ), + vscode.commands.registerCommand( + "openproject.setupFilter", + setupFilterCommand.setupFilters, + setupFilterCommand, + ), + vscode.window.createTreeView("openproject-workspaces", { + treeDataProvider: treeView, + }), + ]; + + context.subscriptions.push(...components); + + authCommand.authorizeClient(); +} + +function composeFilters() { + const compositeFilter = container.get( + TOKENS.compositeFilter, ); - vscode.window.createTreeView("openproject-workspaces", { - treeDataProvider: OpenProjectTreeDataProvider.getInstance(), - }); - context.subscriptions.push(authCommand, refreshWPsCommand); + const textFilter = container.get>(TOKENS.textFilter); + const statusFilter = container.get>(TOKENS.statusFilter); + compositeFilter.pushFilter(textFilter); + compositeFilter.pushFilter(statusFilter); } 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..62aec78 --- /dev/null +++ b/src/infrastructure/logger/__mocks__/logger.ts @@ -0,0 +1,11 @@ +import { injectable } from "inversify"; +import Logger from "../logger.interface"; + +@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 new file mode 100644 index 0000000..fab93ff --- /dev/null +++ b/src/infrastructure/logger/logger.interface.ts @@ -0,0 +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 new file mode 100644 index 0000000..391ca43 --- /dev/null +++ b/src/infrastructure/logger/logger.spec.ts @@ -0,0 +1,41 @@ +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.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!"; + + 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..b9a579c --- /dev/null +++ b/src/infrastructure/logger/logger.ts @@ -0,0 +1,17 @@ +import { injectable } from "inversify"; +import Logger from "./logger.interface"; + +@injectable() +export default class ConsoleLogger implements Logger { + log(...messages: unknown[]): void { + console.log(...messages); + } + + debug(...messages: unknown[]): void { + console.debug(...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..395bd7a --- /dev/null +++ b/src/infrastructure/openProject/__mocks__/openProject.client.ts @@ -0,0 +1,19 @@ +import { injectable } from "inversify"; +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(); + + 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.interface.ts b/src/infrastructure/openProject/openProject.client.interface.ts new file mode 100644 index 0000000..9a7dd6b --- /dev/null +++ b/src/infrastructure/openProject/openProject.client.interface.ts @@ -0,0 +1,14 @@ +import { Project, Status, 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; + getStatuses(): Promise; + + save(wp: WP): Promise; + + onInit: vscode.Event; +} diff --git a/src/infrastructure/openProject/openProject.client.spec.ts b/src/infrastructure/openProject/openProject.client.spec.ts new file mode 100644 index 0000000..4046397 --- /dev/null +++ b/src/infrastructure/openProject/openProject.client.spec.ts @@ -0,0 +1,200 @@ +jest.mock("../logger/logger"); + +import { faker } from "@faker-js/faker"; +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 ClientNotInitializedException from "./clientNotInitialized.exception"; +import OpenProjectClientImpl from "./openProject.client"; +import UnexceptedClientException from "./unexpectedClientError.exception"; +import UserNotFound from "./userNotFound.exception"; + +jest.mock("op-client"); + +describe("OpenProject Client tests", () => { + let client: OpenProjectClientImpl; + + beforeEach(() => { + jest.clearAllMocks(); + client = container.get(TOKENS.opClient); + + client["_entityManager"] = { + fetch: jest.fn(), + get: jest.fn(), + getMany: jest.fn(), + patch: jest.fn(), + } as any; + }); + + describe("Initialization", () => { + it("should be initialized correctly", () => { + const token = faker.string.alphanumeric(); + const baseUrl = faker.internet.url(); + + 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"); + + 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); + }); + }); + + 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("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); + const url = "http://google.com"; + + const result = `http://apikey:${token}@google.com/`; + + 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 new file mode 100644 index 0000000..f9ad5cf --- /dev/null +++ b/src/infrastructure/openProject/openProject.client.ts @@ -0,0 +1,91 @@ +import ClientOAuth2 from "client-oauth2"; +import { inject, injectable } from "inversify"; +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"; +import Logger from "../logger/logger"; +import ClientNotInitializedException from "./clientNotInitialized.exception"; +import OpenProjectClient from "./openProject.client.interface"; +import UnexceptedClientException from "./unexpectedClientError.exception"; +import UserNotFound from "./userNotFound.exception"; + +@injectable() +export default class OpenProjectClientImpl implements OpenProjectClient { + 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; + } + + private set entityManager(value: EntityManager) { + this._entityManager = value; + } + + public init(baseUrl: string, token: string): void { + this.entityManager = new EntityManager({ + baseUrl: this.addTokenToUrl(baseUrl, token), + createLogger: () => this._logger, + token: new ClientOAuth2({}).createToken(token, {}), + }); + this._onInit.fire(); + } + + 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: [], + }); + } + + public getProjects(): Promise { + return this.entityManager.getMany(Project, { + pageSize: 100, + all: true, + filters: [], + }); + } + + 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/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/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/infrastructure/project/__mocks__/project.repository.ts b/src/infrastructure/project/__mocks__/project.repository.ts new file mode 100644 index 0000000..9c9642d --- /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(); + + onProjectsChange = 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..5efa0ed --- /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; + onProjectsChange: 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..b63d08e --- /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 _onProjectsChange: vscode.EventEmitter = + new vscode.EventEmitter(); + + onProjectsChange: Event = this._onProjectsChange.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._onProjectsChange.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/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(); + }); + } +} 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"); + } +} diff --git a/src/infrastructure/workPackage/__mocks__/wp.repository.ts b/src/infrastructure/workPackage/__mocks__/wp.repository.ts new file mode 100644 index 0000000..078eb6b --- /dev/null +++ b/src/infrastructure/workPackage/__mocks__/wp.repository.ts @@ -0,0 +1,19 @@ +import { injectable } from "inversify"; +import WPRepository from "../wp.repository.interface"; + +@injectable() +export default class WPRepositoryImpl implements WPRepository { + save = jest.fn(); + + findById = jest.fn(); + + findByParentId = jest.fn(); + + findAll = jest.fn(); + + refetch = 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 new file mode 100644 index 0000000..42cb0a8 --- /dev/null +++ b/src/infrastructure/workPackage/wp.repository.interface.ts @@ -0,0 +1,12 @@ +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[]; + findAll(): WP[]; + refetch(): Promise; + onWPsChange: 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..dfcf157 --- /dev/null +++ b/src/infrastructure/workPackage/wp.repository.spec.ts @@ -0,0 +1,121 @@ +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); + 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("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); + }); + 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", () => { + beforeAll(() => { + jest.spyOn(filter, "filter").mockReturnValue([wp1, wp2, wp3, wp4]); + }); + 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", () => { + beforeAll(() => { + jest.spyOn(filter, "filter").mockReturnValue([wp1, wp2, wp3, wp4]); + }); + 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", () => { + beforeAll(() => { + jest.spyOn(filter, "filter").mockReturnValue([wp1, wp2, wp3, wp4]); + }); + 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..9e55746 --- /dev/null +++ b/src/infrastructure/workPackage/wp.repository.ts @@ -0,0 +1,51 @@ +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 _onWPsChange: vscode.EventEmitter = + new vscode.EventEmitter(); + + onWPsChange: Event = this._onWPsChange.event; + + constructor( + @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(); + 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._onWPsChange.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"); + } +} 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..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 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( + 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, + ); }); }); }); 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; diff --git a/src/views/openProject.treeDataProvider.spec.ts b/src/views/openProject.treeDataProvider.spec.ts deleted file mode 100644 index 37c0d5d..0000000 --- a/src/views/openProject.treeDataProvider.spec.ts +++ /dev/null @@ -1,372 +0,0 @@ -import { TreeItemCollapsibleState } from "vscode"; -import OpenProjectTreeDataProvider from "./openProject.treeDataProvider"; - -describe("OpenProjectTreeDataProvider", () => { - let instance: OpenProjectTreeDataProvider; - - beforeEach(() => { - instance = OpenProjectTreeDataProvider.getInstance(); - jest.spyOn(instance["_client"], "getWPs").mockResolvedValue([]); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - describe("refreshWPs", () => { - it("should update the workPackages array with new work packages", async () => { - instance["_onDidChangeTreeData"] = { fire: jest.fn() } as any; - jest.spyOn(instance["_client"], "getWPs").mockResolvedValue([ - { - id: 1, - subject: "Test Work Package", - status: { - self: { - title: "New", - }, - }, - children: [], - parent: null, - ancestor: null, - } as any, - ]); - - await instance.refreshWPs(); - - expect(instance["workPackages"]).toEqual([ - { - id: 1, - subject: "Test Work Package", - status: { - self: { - title: "New", - }, - }, - children: [], - parent: null, - ancestor: null, - }, - ]); - }); - it("should fire _onDidChangeTreeData", async () => { - instance["_onDidChangeTreeData"] = { fire: jest.fn() } as any; - jest.spyOn(instance["_client"], "getWPs").mockResolvedValue([ - { - id: 1, - subject: "Test Work Package", - status: { - self: { - title: "New", - }, - }, - children: [], - parent: null, - ancestor: null, - } as any, - ]); - - await instance.refreshWPs(); - - expect(instance["_onDidChangeTreeData"].fire).toHaveBeenCalled(); - }); - }); - - describe("getTreeItem", () => { - it("should return a tree item with label, collapsible state and icon path", async () => { - const wp = { - id: 1, - subject: "Test Work Package", - status: { - self: { - title: "New", - }, - }, - children: [], - parent: null, - ancestor: null, - }; - const treeItem = await instance.getTreeItem(wp as any); - expect(treeItem.label).toEqual("#1 Test Work Package"); - expect(treeItem.collapsibleState).toEqual(TreeItemCollapsibleState.None); - expect(treeItem.iconPath).toBeUndefined(); - }); - it("should return a tree item with label, collapsible state and icon path", async () => { - const wp = { - id: 1, - subject: "Test Work Package", - status: { - self: { - title: "New", - }, - }, - children: null, - parent: null, - ancestor: null, - }; - const treeItem = await instance.getTreeItem(wp as any); - expect(treeItem.label).toEqual("#1 Test Work Package"); - expect(treeItem.collapsibleState).toEqual(TreeItemCollapsibleState.None); - expect(treeItem.iconPath).toBeUndefined(); - }); - it("should return a tree item with label, collapsible state = collapsed and icon path", async () => { - const wp = { - id: 1, - subject: "Test Work Package", - status: { - self: { - title: "New", - }, - }, - children: [{}], - parent: null, - ancestor: null, - }; - const treeItem = await instance.getTreeItem(wp as any); - expect(treeItem.label).toEqual("#1 Test Work Package"); - expect(treeItem.collapsibleState).toEqual( - TreeItemCollapsibleState.Collapsed, - ); - expect(treeItem.iconPath).toBeUndefined(); - }); - it("should return a tree item with label, collapsible state and some icon path", async () => { - const wp = { - id: 1, - subject: "Test Work Package", - status: { - self: { - title: "In progress", - }, - }, - children: [{}], - parent: null, - ancestor: null, - }; - const treeItem = await instance.getTreeItem(wp as any); - expect(treeItem.label).toEqual("#1 Test Work Package"); - expect(treeItem.collapsibleState).toEqual( - TreeItemCollapsibleState.Collapsed, - ); - expect(treeItem.iconPath).not.toBeUndefined(); - }); - }); - - describe("getChildren", () => { - it("should return the top-level work packages", () => { - instance["workPackages"] = [ - { - id: 1, - subject: "Test Work Package 1", - status: { - self: { - title: "New", - }, - }, - children: [], - parent: null, - ancestor: null, - }, - { - id: 2, - subject: "Test Work Package 2", - status: { - self: { - title: "New", - }, - }, - children: [], - parent: null, - ancestor: null, - }, - ] as any; - const topLevelWPs = instance.getChildren(); - expect(topLevelWPs).toEqual([ - { - id: 1, - subject: "Test Work Package 1", - status: { - self: { - title: "New", - }, - }, - children: [], - parent: null, - ancestor: null, - }, - { - id: 2, - subject: "Test Work Package 2", - status: { - self: { - title: "New", - }, - }, - children: [], - parent: null, - ancestor: null, - }, - ]); - }); - - it("should return the children of a work package", () => { - const parentWP = { - id: 1, - subject: "Parent Work Package", - status: { - self: { - title: "New", - }, - }, - children: [ - { - id: 2, - subject: "Child Work Package", - status: { - self: { - title: "New", - }, - }, - children: [], - parent: { - id: 1, - subject: "Parent Work Package", - }, - ancestor: null, - }, - ], - parent: null, - ancestor: null, - }; - instance["workPackages"] = [parentWP, ...parentWP.children] as any; - - expect(instance.getChildren(parentWP as any)).toEqual([ - { - id: 2, - subject: "Child Work Package", - status: { - self: { - title: "New", - }, - }, - children: [], - parent: { - id: 1, - subject: "Parent Work Package", - }, - ancestor: null, - }, - ]); - }); - }); - - describe("resolveTreeItem", () => { - it("should return item", () => { - const item = {}; - expect(instance.resolveTreeItem(item)).toEqual(item); - }); - }); - - describe("getParent", () => { - it("should return the ancestor of a work package", () => { - const ancestorWP = { - id: 1, - subject: "Ancestor Work Package", - status: { - self: { - title: "New", - }, - }, - children: [ - { - id: 2, - subject: "Parent Work Package", - status: { - self: { - title: "New", - }, - }, - children: [ - { - id: 3, - subject: "Child Work Package", - status: { - self: { - title: "New", - }, - }, - children: [], - parent: { - id: 2, - subject: "Parent Work Package", - }, - ancestor: { - id: 1, - subject: "Ancestor Work Package", - }, - }, - ], - parent: null, - ancestor: { - id: 1, - subject: "Ancestor Work Package", - }, - }, - ], - parent: null, - ancestor: null, - }; - instance["workPackages"] = [ancestorWP, ...ancestorWP.children] as any; - const parentWP = instance.getParent({ - id: 3, - subject: "Child Work Package", - status: { - self: { - title: "New", - }, - }, - children: [], - parent: { - id: 2, - subject: "Parent Work Package", - }, - ancestor: { - id: 1, - subject: "Ancestor Work Package", - }, - } as any); - expect(parentWP).toEqual({ - id: 2, - subject: "Parent Work Package", - status: { - self: { - title: "New", - }, - }, - children: [ - { - id: 3, - subject: "Child Work Package", - status: { - self: { - title: "New", - }, - }, - children: [], - parent: { - id: 2, - subject: "Parent Work Package", - }, - ancestor: { - id: 1, - subject: "Ancestor Work Package", - }, - }, - ], - parent: null, - ancestor: { - id: 1, - subject: "Ancestor Work Package", - }, - }); - }); - }); -}); diff --git a/src/views/openProject.treeDataProvider.ts b/src/views/openProject.treeDataProvider.ts deleted file mode 100644 index e5fbe43..0000000 --- a/src/views/openProject.treeDataProvider.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { WP } from "op-client"; -import * as vscode from "vscode"; -import { Event, ProviderResult, TreeDataProvider, TreeItem } from "vscode"; -import OpenProjectClient from "../openProject.client"; -import getIconPathByStatus from "../utils/getIconPathByStatus.util"; -import path from "path"; - -export default class OpenProjectTreeDataProvider - implements TreeDataProvider -{ - private static _instance: OpenProjectTreeDataProvider; - - public static getInstance(): OpenProjectTreeDataProvider { - if (!this._instance) { - this._instance = new OpenProjectTreeDataProvider(); - } - return this._instance; - } - - private _client: OpenProjectClient; - - private workPackages: WP[] = []; - - private _onDidChangeTreeData: vscode.EventEmitter< - void | WP | WP[] | null | undefined - > = new vscode.EventEmitter(); - - private constructor() { - this._client = OpenProjectClient.getInstance(); - this.refreshWPs(); - } - - onDidChangeTreeData?: Event = - this._onDidChangeTreeData.event; - - getTreeItem(element: WP): TreeItem | Promise { - const iconPath = getIconPathByStatus(element.status.self.title); - return { - label: `#${element.id} ${element.subject}`, - collapsibleState: - element.children?.length > 0 - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.None, - iconPath: iconPath - ? vscode.Uri.file(path.join(__dirname, iconPath)) - : undefined, - }; - } - - getChildren(element?: WP | undefined): ProviderResult { - if (!element) { - return this.workPackages.filter((wp) => !wp.parent); - } - return this.workPackages.filter((wp) => wp.parent?.id === element.id); - } - - getParent(element: WP): ProviderResult { - return this.workPackages.find((wp) => wp.id === element.parent.id); - } - - resolveTreeItem(item: TreeItem): ProviderResult { - return item; - } - - refreshWPs(): Promise | undefined { - return this._client.getWPs()?.then((wps) => { - if (wps.length) { - this.workPackages = wps; - this._onDidChangeTreeData.fire(); - } - }); - } -} diff --git a/tsconfig.json b/tsconfig.json index 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 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",