Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
18 changes: 18 additions & 0 deletions src/commands/view/view-bookmarks-command.service.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await this.viewBookmarksManagerFactory.createViewBookmarksManager(null, boardId, type).pull();
}

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