diff --git a/docs/user-guide/studio-commands.md b/docs/user-guide/studio-commands.md index 9614b81..f738495 100644 --- a/docs/user-guide/studio-commands.md +++ b/docs/user-guide/studio-commands.md @@ -208,3 +208,22 @@ that analysis. Use the ***--help*** flag to see all options for a specific comma content-cli pull analysis --help content-cli push analysis --help ``` + +## Pull and Push View Bookmarks + +Enable users to pull and push view (board) bookmarks using content-cli. For pulling view bookmarks +you can specify --type (SHARED/ALL/USER), and by default it fetches USER bookmarks: + +``` +// Pull view bookmarks +content-cli pull view-bookmarks --profile my-profile-name --id 73d39112-73ae-4bbe-8051-3c0f14e065ec --type SHARED +``` + +After you have pulled your view bookmarks, +it's time to push them inside a view in a different team. You can accomplish this using +the same command as with pushing other assets in Studio: + +``` +// Push view bookmarks to Studio +content-cli push view-bookmarks -p my-profile-name --id 73d39112-73ae-4bbe-8051-3c0f14e065ec --file studio_view_bookmarks_39c5bb7b-b486-4230-ab01-854a17ddbff2.json +``` diff --git a/src/commands/view/module.ts b/src/commands/view/module.ts new file mode 100644 index 0000000..64326c5 --- /dev/null +++ b/src/commands/view/module.ts @@ -0,0 +1,39 @@ +/** + * Commands related to the View feature. + */ + +import { Configurator, IModule } from "../../core/command/module-handler"; +import { Context } from "../../core/command/cli-context"; +import { Command, OptionValues } from "commander"; +import { ViewBookmarksCommandService } from "./view-bookmarks-command.service"; + +class Module extends IModule { + + public register(context: Context, configurator: Configurator): void { + const pullCommand = configurator.command("pull"); + pullCommand + .command("view-bookmarks") + .description("Command to pull view bookmarks") + .option("--type ", "Type of view bookmarks to pull: USER (default), SHARED, or ALL") + .requiredOption("--id ", "ID of the view (board) to pull bookmarks from") + .action(this.pullViewBookmarks); + + const pushCommand = configurator.command("push"); + pushCommand + .command("view-bookmarks") + .description("Command to push view bookmarks to a board") + .requiredOption("--id ", "ID of the view (board) to push bookmarks into") + .requiredOption("-f, --file ", "The file to push") + .action(this.pushViewBookmarks); + } + + private async pullViewBookmarks(context: Context, command: Command, options: OptionValues): Promise { + await new ViewBookmarksCommandService(context).pullViewBookmarks(options.id, options.type); + } + + private async pushViewBookmarks(context: Context, command: Command, options: OptionValues): Promise { + await new ViewBookmarksCommandService(context).pushViewBookmarks(options.id, options.file); + } +} + +export = Module; diff --git a/src/commands/view/view-bookmarks-command.service.ts b/src/commands/view/view-bookmarks-command.service.ts new file mode 100644 index 0000000..fae9b9b --- /dev/null +++ b/src/commands/view/view-bookmarks-command.service.ts @@ -0,0 +1,18 @@ +import { ViewBookmarksManagerFactory } from "./view-bookmarks.manager-factory"; +import { Context } from "../../core/command/cli-context"; + +export class ViewBookmarksCommandService { + private readonly viewBookmarksManagerFactory: ViewBookmarksManagerFactory; + + constructor(context: Context) { + this.viewBookmarksManagerFactory = new ViewBookmarksManagerFactory(context); + } + + public async pullViewBookmarks(boardId: string, type?: string): Promise { + await this.viewBookmarksManagerFactory.createViewBookmarksManager(null, boardId, type).pull(); + } + + public async pushViewBookmarks(boardId: string, filename: string): Promise { + await this.viewBookmarksManagerFactory.createViewBookmarksManager(filename, boardId).push(); + } +} diff --git a/src/commands/view/view-bookmarks.manager-factory.ts b/src/commands/view/view-bookmarks.manager-factory.ts new file mode 100644 index 0000000..8047768 --- /dev/null +++ b/src/commands/view/view-bookmarks.manager-factory.ts @@ -0,0 +1,32 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { ViewBookmarksManager } from "./view-bookmarks.manager"; +import { FatalError, logger } from "../../core/utils/logger"; +import { Context } from "../../core/command/cli-context"; + +export class ViewBookmarksManagerFactory { + private readonly context: Context; + + constructor(context: Context) { + this.context = context; + } + + public createViewBookmarksManager(filename: string, boardId: string, type?: string): ViewBookmarksManager { + const viewBookmarksManager = new ViewBookmarksManager(this.context); + viewBookmarksManager.boardId = boardId; + type = (type ?? "USER").toUpperCase(); + + viewBookmarksManager.type = type; + if (filename !== null) { + viewBookmarksManager.filePath = this.resolveFilePath(filename); + } + return viewBookmarksManager; + } + + private resolveFilePath(fileName: string): string { + if (!fs.existsSync(path.resolve(process.cwd(), fileName))) { + logger.error(new FatalError("The provided file does not exist")); + } + return path.resolve(process.cwd(), fileName); + } +} diff --git a/src/commands/view/view-bookmarks.manager.ts b/src/commands/view/view-bookmarks.manager.ts new file mode 100644 index 0000000..9f1c736 --- /dev/null +++ b/src/commands/view/view-bookmarks.manager.ts @@ -0,0 +1,63 @@ +import * as fs from "node:fs"; +import * as FormData from "form-data"; +import { Context } from "../../core/command/cli-context"; +import { BaseManager } from "../../core/http/http-shared/base.manager"; +import { ManagerConfig } from "../../core/http/http-shared/manager-config.interface"; + +export class ViewBookmarksManager extends BaseManager { + private static readonly BASE_URL = "/blueprint/api/bookmarks"; + private static readonly VIEW_BOOKMARKS_FILE_PREFIX = "studio_view_bookmarks_"; + + private _boardId: string; + private _filePath: string; + private _type: string; + + constructor(context: Context) { + super(context); + } + + public get filePath(): string { + return this._filePath; + } + + public set filePath(value: string) { + this._filePath = value; + } + + public get boardId(): string { + return this._boardId; + } + + public set boardId(value: string) { + this._boardId = value; + } + + public get type(): string { + return this._type; + } + + public set type(value: string) { + this._type = value; + } + + public getConfig(): ManagerConfig { + return { + pushUrl: `${ViewBookmarksManager.BASE_URL}/import?boardId=${encodeURIComponent(this.boardId)}`, + pullUrl: `${ViewBookmarksManager.BASE_URL}/export?boardId=${encodeURIComponent(this.boardId)}&type=${encodeURIComponent(this.type)}`, + exportFileName: `${ViewBookmarksManager.VIEW_BOOKMARKS_FILE_PREFIX}${this.boardId}.json`, + onPushSuccessMessage: (): string => { + return "View Bookmarks were pushed successfully."; + }, + }; + } + + public getBody(): any { + const formData = new FormData(); + formData.append("file", fs.createReadStream(this.filePath)); + return formData; + } + + protected getSerializedFileContent(data: any): string { + return JSON.stringify(data); + } +} diff --git a/tests/commands/view/view-bookmarks-module.spec.ts b/tests/commands/view/view-bookmarks-module.spec.ts new file mode 100644 index 0000000..0766def --- /dev/null +++ b/tests/commands/view/view-bookmarks-module.spec.ts @@ -0,0 +1,63 @@ +import Module = require("../../../src/commands/view/module"); +import { Command, OptionValues } from "commander"; +import { ViewBookmarksCommandService } from "../../../src/commands/view/view-bookmarks-command.service"; +import { testContext } from "../../utls/test-context"; +import { createMockConfigurator } from "../../utls/configurator-mock"; + +jest.mock("../../../src/commands/view/view-bookmarks-command.service"); + +describe("View Bookmarks Module", () => { + let module: Module; + let mockCommand: Command; + let mockService: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + module = new Module(); + mockCommand = {} as Command; + + mockService = { + pullViewBookmarks: jest.fn().mockResolvedValue(undefined), + pushViewBookmarks: jest.fn().mockResolvedValue(undefined), + } as any; + + (ViewBookmarksCommandService as jest.MockedClass) + .mockImplementation(() => mockService); + }); + + it("should call pullViewBookmarks with id and type", async () => { + const options: OptionValues = { id: "board-123", type: "SHARED" }; + await (module as any).pullViewBookmarks(testContext, mockCommand, options); + expect(mockService.pullViewBookmarks).toHaveBeenCalledWith("board-123", "SHARED"); + }); + + it("should call pushViewBookmarks with id and file", async () => { + const options: OptionValues = { id: "board-123", file: "bookmarks.json" }; + await (module as any).pushViewBookmarks(testContext, mockCommand, options); + expect(mockService.pushViewBookmarks).toHaveBeenCalledWith("board-123", "bookmarks.json"); + }); + + describe("register", () => { + it("registers the pull and push command groups without throwing", () => { + const mockConfigurator = createMockConfigurator(); + + expect(() => new Module().register(testContext, mockConfigurator)).not.toThrow(); + + expect(mockConfigurator.command).toHaveBeenCalledWith("pull"); + expect(mockConfigurator.command).toHaveBeenCalledWith("push"); + }); + + it("wires an action handler for every leaf subcommand", () => { + const mockConfigurator = createMockConfigurator(); + + new Module().register(testContext, mockConfigurator); + + // pull view-bookmarks + push view-bookmarks + const expectedLeafCommands = 2; + expect(mockConfigurator.action).toHaveBeenCalledTimes(expectedLeafCommands); + for (const call of mockConfigurator.action.mock.calls) { + expect(typeof call[0]).toBe("function"); + } + }); + }); +}); diff --git a/tests/commands/view/view-bookmarks.spec.ts b/tests/commands/view/view-bookmarks.spec.ts new file mode 100644 index 0000000..1767e11 --- /dev/null +++ b/tests/commands/view/view-bookmarks.spec.ts @@ -0,0 +1,76 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { mockAxiosGet, mockAxiosPost, mockedAxiosInstance } from "../../utls/http-requests-mock"; +import { mockCreateReadStream, mockExistsSync } from "../../utls/fs-mock-utils"; +import { ViewBookmarksCommandService } from "../../../src/commands/view/view-bookmarks-command.service"; +import { ViewBookmarksManagerFactory } from "../../../src/commands/view/view-bookmarks.manager-factory"; +import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup"; +import { testContext } from "../../utls/test-context"; + +describe("View bookmarks", () => { + + const boardId = "73d39112-73ae-4bbe-8051-3c0f14e065ec"; + const exportBaseUrl = `https://myTeam.celonis.cloud/blueprint/api/bookmarks/export?boardId=${boardId}`; + const importUrl = `https://myTeam.celonis.cloud/blueprint/api/bookmarks/import?boardId=${boardId}`; + const bookmarksResponse = [ + { + bookmark: { name: "My View Bookmark", ownerId: "user-1", userPreferenceId: "pref-1" }, + preference: { id: "pref-1", value: "{}" }, + }, + ]; + + describe("pull", () => { + it("Should call export API with the default USER type and write the response to a file", async () => { + mockAxiosGet(`${exportBaseUrl}&type=USER`, bookmarksResponse); + + await new ViewBookmarksCommandService(testContext).pullViewBookmarks(boardId, undefined); + + expect(mockedAxiosInstance.get).toHaveBeenCalledWith(`${exportBaseUrl}&type=USER`, expect.anything()); + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.resolve(process.cwd(), `studio_view_bookmarks_${boardId}.json`), + JSON.stringify(bookmarksResponse), + { encoding: "utf-8", mode: 0o600 } + ); + expect(loggingTestTransport.logMessages.length).toBe(1); + expect(loggingTestTransport.logMessages[0].message).toContain("File downloaded successfully. New filename: "); + }); + + it("Should call export API with the provided type", async () => { + mockAxiosGet(`${exportBaseUrl}&type=SHARED`, bookmarksResponse); + + await new ViewBookmarksCommandService(testContext).pullViewBookmarks(boardId, "SHARED"); + + expect(mockedAxiosInstance.get).toHaveBeenCalledWith(`${exportBaseUrl}&type=SHARED`, expect.anything()); + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.resolve(process.cwd(), `studio_view_bookmarks_${boardId}.json`), + JSON.stringify(bookmarksResponse), + { encoding: "utf-8", mode: 0o600 } + ); + }); + }); + + describe("push", () => { + it("Should call import API with the file as multipart body", async () => { + mockAxiosPost(importUrl, {}); + mockExistsSync(); + mockCreateReadStream(Buffer.from(JSON.stringify(bookmarksResponse))); + + await new ViewBookmarksCommandService(testContext).pushViewBookmarks(boardId, "bookmarks.json"); + + expect(mockedAxiosInstance.post).toHaveBeenCalledWith(importUrl, expect.anything(), expect.anything()); + expect(loggingTestTransport.logMessages.length).toBe(1); + expect(loggingTestTransport.logMessages[0].message).toContain("View Bookmarks were pushed successfully."); + }); + }); + + describe("manager factory", () => { + it("Should report a fatal error when the push file does not exist", () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + const exitSpy = jest.spyOn(process, "exit").mockImplementation((() => undefined) as never); + + new ViewBookmarksManagerFactory(testContext).createViewBookmarksManager("missing.json", boardId); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/tests/jest.setup.ts b/tests/jest.setup.ts index ae3002d..54cc894 100644 --- a/tests/jest.setup.ts +++ b/tests/jest.setup.ts @@ -6,6 +6,7 @@ import { logger } from "../src/core/utils/logger"; mockAxios(); jest.mock("fs"); +jest.mock("node:fs", () => require("fs")); const mockWriteFileSync = jest.fn(); (fs.writeFileSync as jest.Mock).mockImplementation(mockWriteFileSync);