Skip to content
19 changes: 19 additions & 0 deletions docs/user-guide/studio-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
manjindersingh98 marked this conversation as resolved.

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
```
39 changes: 39 additions & 0 deletions src/commands/view/module.ts
Original file line number Diff line number Diff line change
@@ -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>", "Type of view bookmarks to pull: USER (default), SHARED, or ALL")
.requiredOption("--id <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>", "ID of the view (board) to push bookmarks into")
.requiredOption("-f, --file <file>", "The file to push")
.action(this.pushViewBookmarks);
}

private async pullViewBookmarks(context: Context, command: Command, options: OptionValues): Promise<void> {
await new ViewBookmarksCommandService(context).pullViewBookmarks(options.id, options.type);
}

private async pushViewBookmarks(context: Context, command: Command, options: OptionValues): Promise<void> {
await new ViewBookmarksCommandService(context).pushViewBookmarks(options.id, options.file);
}
}

export = Module;
25 changes: 25 additions & 0 deletions src/commands/view/view-bookmarks-command.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ViewBookmarksManagerFactory } from "./view-bookmarks.manager-factory";
import { Context } from "../../core/command/cli-context";
import { FatalError, logger } from "../../core/utils/logger";

const ALLOWED_VIEW_BOOKMARK_TYPES = ["USER", "SHARED", "ALL"];

export class ViewBookmarksCommandService {
private readonly viewBookmarksManagerFactory: ViewBookmarksManagerFactory;

constructor(context: Context) {
this.viewBookmarksManagerFactory = new ViewBookmarksManagerFactory(context);
}

public async pullViewBookmarks(boardId: string, type?: string): Promise<void> {
if (type !== undefined && !ALLOWED_VIEW_BOOKMARK_TYPES.includes(type.toUpperCase())) {
logger.error(new FatalError(`Invalid type "${type}". Allowed values are: ${ALLOWED_VIEW_BOOKMARK_TYPES.join(", ")}.`));
return;
}
await this.viewBookmarksManagerFactory.createViewBookmarksManager(null, boardId, type).pull();
Comment thread
cursor[bot] marked this conversation as resolved.
}

public async pushViewBookmarks(boardId: string, filename: string): Promise<void> {
await this.viewBookmarksManagerFactory.createViewBookmarksManager(filename, boardId).push();
}
}
32 changes: 32 additions & 0 deletions src/commands/view/view-bookmarks.manager-factory.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
63 changes: 63 additions & 0 deletions src/commands/view/view-bookmarks.manager.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
63 changes: 63 additions & 0 deletions tests/commands/view/view-bookmarks-module.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ViewBookmarksCommandService>;

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<typeof ViewBookmarksCommandService>)
.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");
}
});
});
});
76 changes: 76 additions & 0 deletions tests/commands/view/view-bookmarks.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
1 change: 1 addition & 0 deletions tests/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { logger } from "../src/core/utils/logger";

mockAxios();
jest.mock("fs");
jest.mock("node:fs", () => require("fs"));
Comment thread
manjindersingh98 marked this conversation as resolved.

const mockWriteFileSync = jest.fn();
(fs.writeFileSync as jest.Mock).mockImplementation(mockWriteFileSync);
Expand Down
Loading