Skip to content

Commit 5be8f5e

Browse files
BINFR-6408: Add pull/push view-bookmarks commands (#372)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1c15350 commit 5be8f5e

8 files changed

Lines changed: 318 additions & 0 deletions

File tree

docs/user-guide/studio-commands.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,22 @@ that analysis. Use the ***--help*** flag to see all options for a specific comma
208208
content-cli pull analysis --help
209209
content-cli push analysis --help
210210
```
211+
212+
## Pull and Push View Bookmarks
213+
214+
Enable users to pull and push view (board) bookmarks using content-cli. For pulling view bookmarks
215+
you can specify --type (SHARED/ALL/USER), and by default it fetches USER bookmarks:
216+
217+
```
218+
// Pull view bookmarks
219+
content-cli pull view-bookmarks --profile my-profile-name --id 73d39112-73ae-4bbe-8051-3c0f14e065ec --type SHARED
220+
```
221+
222+
After you have pulled your view bookmarks,
223+
it's time to push them inside a view in a different team. You can accomplish this using
224+
the same command as with pushing other assets in Studio:
225+
226+
```
227+
// Push view bookmarks to Studio
228+
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
229+
```

src/commands/view/module.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Commands related to the View feature.
3+
*/
4+
5+
import { Configurator, IModule } from "../../core/command/module-handler";
6+
import { Context } from "../../core/command/cli-context";
7+
import { Command, OptionValues } from "commander";
8+
import { ViewBookmarksCommandService } from "./view-bookmarks-command.service";
9+
10+
class Module extends IModule {
11+
12+
public register(context: Context, configurator: Configurator): void {
13+
const pullCommand = configurator.command("pull");
14+
pullCommand
15+
.command("view-bookmarks")
16+
.description("Command to pull view bookmarks")
17+
.option("--type <type>", "Type of view bookmarks to pull: USER (default), SHARED, or ALL")
18+
.requiredOption("--id <id>", "ID of the view (board) to pull bookmarks from")
19+
.action(this.pullViewBookmarks);
20+
21+
const pushCommand = configurator.command("push");
22+
pushCommand
23+
.command("view-bookmarks")
24+
.description("Command to push view bookmarks to a board")
25+
.requiredOption("--id <id>", "ID of the view (board) to push bookmarks into")
26+
.requiredOption("-f, --file <file>", "The file to push")
27+
.action(this.pushViewBookmarks);
28+
}
29+
30+
private async pullViewBookmarks(context: Context, command: Command, options: OptionValues): Promise<void> {
31+
await new ViewBookmarksCommandService(context).pullViewBookmarks(options.id, options.type);
32+
}
33+
34+
private async pushViewBookmarks(context: Context, command: Command, options: OptionValues): Promise<void> {
35+
await new ViewBookmarksCommandService(context).pushViewBookmarks(options.id, options.file);
36+
}
37+
}
38+
39+
export = Module;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { ViewBookmarksManagerFactory } from "./view-bookmarks.manager-factory";
2+
import { Context } from "../../core/command/cli-context";
3+
import { FatalError, logger } from "../../core/utils/logger";
4+
5+
const ALLOWED_VIEW_BOOKMARK_TYPES = ["USER", "SHARED", "ALL"];
6+
7+
export class ViewBookmarksCommandService {
8+
private readonly viewBookmarksManagerFactory: ViewBookmarksManagerFactory;
9+
10+
constructor(context: Context) {
11+
this.viewBookmarksManagerFactory = new ViewBookmarksManagerFactory(context);
12+
}
13+
14+
public async pullViewBookmarks(boardId: string, type?: string): Promise<void> {
15+
if (type !== undefined && !ALLOWED_VIEW_BOOKMARK_TYPES.includes(type.toUpperCase())) {
16+
logger.error(new FatalError(`Invalid type "${type}". Allowed values are: ${ALLOWED_VIEW_BOOKMARK_TYPES.join(", ")}.`));
17+
return;
18+
}
19+
await this.viewBookmarksManagerFactory.createViewBookmarksManager(null, boardId, type).pull();
20+
}
21+
22+
public async pushViewBookmarks(boardId: string, filename: string): Promise<void> {
23+
await this.viewBookmarksManagerFactory.createViewBookmarksManager(filename, boardId).push();
24+
}
25+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import * as fs from "node:fs";
2+
import * as path from "node:path";
3+
import { ViewBookmarksManager } from "./view-bookmarks.manager";
4+
import { FatalError, logger } from "../../core/utils/logger";
5+
import { Context } from "../../core/command/cli-context";
6+
7+
export class ViewBookmarksManagerFactory {
8+
private readonly context: Context;
9+
10+
constructor(context: Context) {
11+
this.context = context;
12+
}
13+
14+
public createViewBookmarksManager(filename: string, boardId: string, type?: string): ViewBookmarksManager {
15+
const viewBookmarksManager = new ViewBookmarksManager(this.context);
16+
viewBookmarksManager.boardId = boardId;
17+
type = (type ?? "USER").toUpperCase();
18+
19+
viewBookmarksManager.type = type;
20+
if (filename !== null) {
21+
viewBookmarksManager.filePath = this.resolveFilePath(filename);
22+
}
23+
return viewBookmarksManager;
24+
}
25+
26+
private resolveFilePath(fileName: string): string {
27+
if (!fs.existsSync(path.resolve(process.cwd(), fileName))) {
28+
logger.error(new FatalError("The provided file does not exist"));
29+
}
30+
return path.resolve(process.cwd(), fileName);
31+
}
32+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as fs from "node:fs";
2+
import * as FormData from "form-data";
3+
import { Context } from "../../core/command/cli-context";
4+
import { BaseManager } from "../../core/http/http-shared/base.manager";
5+
import { ManagerConfig } from "../../core/http/http-shared/manager-config.interface";
6+
7+
export class ViewBookmarksManager extends BaseManager {
8+
private static readonly BASE_URL = "/blueprint/api/bookmarks";
9+
private static readonly VIEW_BOOKMARKS_FILE_PREFIX = "studio_view_bookmarks_";
10+
11+
private _boardId: string;
12+
private _filePath: string;
13+
private _type: string;
14+
15+
constructor(context: Context) {
16+
super(context);
17+
}
18+
19+
public get filePath(): string {
20+
return this._filePath;
21+
}
22+
23+
public set filePath(value: string) {
24+
this._filePath = value;
25+
}
26+
27+
public get boardId(): string {
28+
return this._boardId;
29+
}
30+
31+
public set boardId(value: string) {
32+
this._boardId = value;
33+
}
34+
35+
public get type(): string {
36+
return this._type;
37+
}
38+
39+
public set type(value: string) {
40+
this._type = value;
41+
}
42+
43+
public getConfig(): ManagerConfig {
44+
return {
45+
pushUrl: `${ViewBookmarksManager.BASE_URL}/import?boardId=${encodeURIComponent(this.boardId)}`,
46+
pullUrl: `${ViewBookmarksManager.BASE_URL}/export?boardId=${encodeURIComponent(this.boardId)}&type=${encodeURIComponent(this.type)}`,
47+
exportFileName: `${ViewBookmarksManager.VIEW_BOOKMARKS_FILE_PREFIX}${this.boardId}.json`,
48+
onPushSuccessMessage: (): string => {
49+
return "View Bookmarks were pushed successfully.";
50+
},
51+
};
52+
}
53+
54+
public getBody(): any {
55+
const formData = new FormData();
56+
formData.append("file", fs.createReadStream(this.filePath));
57+
return formData;
58+
}
59+
60+
protected getSerializedFileContent(data: any): string {
61+
return JSON.stringify(data);
62+
}
63+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import Module = require("../../../src/commands/view/module");
2+
import { Command, OptionValues } from "commander";
3+
import { ViewBookmarksCommandService } from "../../../src/commands/view/view-bookmarks-command.service";
4+
import { testContext } from "../../utls/test-context";
5+
import { createMockConfigurator } from "../../utls/configurator-mock";
6+
7+
jest.mock("../../../src/commands/view/view-bookmarks-command.service");
8+
9+
describe("View Bookmarks Module", () => {
10+
let module: Module;
11+
let mockCommand: Command;
12+
let mockService: jest.Mocked<ViewBookmarksCommandService>;
13+
14+
beforeEach(() => {
15+
jest.clearAllMocks();
16+
module = new Module();
17+
mockCommand = {} as Command;
18+
19+
mockService = {
20+
pullViewBookmarks: jest.fn().mockResolvedValue(undefined),
21+
pushViewBookmarks: jest.fn().mockResolvedValue(undefined),
22+
} as any;
23+
24+
(ViewBookmarksCommandService as jest.MockedClass<typeof ViewBookmarksCommandService>)
25+
.mockImplementation(() => mockService);
26+
});
27+
28+
it("should call pullViewBookmarks with id and type", async () => {
29+
const options: OptionValues = { id: "board-123", type: "SHARED" };
30+
await (module as any).pullViewBookmarks(testContext, mockCommand, options);
31+
expect(mockService.pullViewBookmarks).toHaveBeenCalledWith("board-123", "SHARED");
32+
});
33+
34+
it("should call pushViewBookmarks with id and file", async () => {
35+
const options: OptionValues = { id: "board-123", file: "bookmarks.json" };
36+
await (module as any).pushViewBookmarks(testContext, mockCommand, options);
37+
expect(mockService.pushViewBookmarks).toHaveBeenCalledWith("board-123", "bookmarks.json");
38+
});
39+
40+
describe("register", () => {
41+
it("registers the pull and push command groups without throwing", () => {
42+
const mockConfigurator = createMockConfigurator();
43+
44+
expect(() => new Module().register(testContext, mockConfigurator)).not.toThrow();
45+
46+
expect(mockConfigurator.command).toHaveBeenCalledWith("pull");
47+
expect(mockConfigurator.command).toHaveBeenCalledWith("push");
48+
});
49+
50+
it("wires an action handler for every leaf subcommand", () => {
51+
const mockConfigurator = createMockConfigurator();
52+
53+
new Module().register(testContext, mockConfigurator);
54+
55+
// pull view-bookmarks + push view-bookmarks
56+
const expectedLeafCommands = 2;
57+
expect(mockConfigurator.action).toHaveBeenCalledTimes(expectedLeafCommands);
58+
for (const call of mockConfigurator.action.mock.calls) {
59+
expect(typeof call[0]).toBe("function");
60+
}
61+
});
62+
});
63+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import * as fs from "node:fs";
2+
import * as path from "node:path";
3+
import { mockAxiosGet, mockAxiosPost, mockedAxiosInstance } from "../../utls/http-requests-mock";
4+
import { mockCreateReadStream, mockExistsSync } from "../../utls/fs-mock-utils";
5+
import { ViewBookmarksCommandService } from "../../../src/commands/view/view-bookmarks-command.service";
6+
import { ViewBookmarksManagerFactory } from "../../../src/commands/view/view-bookmarks.manager-factory";
7+
import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup";
8+
import { testContext } from "../../utls/test-context";
9+
10+
describe("View bookmarks", () => {
11+
12+
const boardId = "73d39112-73ae-4bbe-8051-3c0f14e065ec";
13+
const exportBaseUrl = `https://myTeam.celonis.cloud/blueprint/api/bookmarks/export?boardId=${boardId}`;
14+
const importUrl = `https://myTeam.celonis.cloud/blueprint/api/bookmarks/import?boardId=${boardId}`;
15+
const bookmarksResponse = [
16+
{
17+
bookmark: { name: "My View Bookmark", ownerId: "user-1", userPreferenceId: "pref-1" },
18+
preference: { id: "pref-1", value: "{}" },
19+
},
20+
];
21+
22+
describe("pull", () => {
23+
it("Should call export API with the default USER type and write the response to a file", async () => {
24+
mockAxiosGet(`${exportBaseUrl}&type=USER`, bookmarksResponse);
25+
26+
await new ViewBookmarksCommandService(testContext).pullViewBookmarks(boardId, undefined);
27+
28+
expect(mockedAxiosInstance.get).toHaveBeenCalledWith(`${exportBaseUrl}&type=USER`, expect.anything());
29+
expect(mockWriteFileSync).toHaveBeenCalledWith(
30+
path.resolve(process.cwd(), `studio_view_bookmarks_${boardId}.json`),
31+
JSON.stringify(bookmarksResponse),
32+
{ encoding: "utf-8", mode: 0o600 }
33+
);
34+
expect(loggingTestTransport.logMessages.length).toBe(1);
35+
expect(loggingTestTransport.logMessages[0].message).toContain("File downloaded successfully. New filename: ");
36+
});
37+
38+
it("Should call export API with the provided type", async () => {
39+
mockAxiosGet(`${exportBaseUrl}&type=SHARED`, bookmarksResponse);
40+
41+
await new ViewBookmarksCommandService(testContext).pullViewBookmarks(boardId, "SHARED");
42+
43+
expect(mockedAxiosInstance.get).toHaveBeenCalledWith(`${exportBaseUrl}&type=SHARED`, expect.anything());
44+
expect(mockWriteFileSync).toHaveBeenCalledWith(
45+
path.resolve(process.cwd(), `studio_view_bookmarks_${boardId}.json`),
46+
JSON.stringify(bookmarksResponse),
47+
{ encoding: "utf-8", mode: 0o600 }
48+
);
49+
});
50+
});
51+
52+
describe("push", () => {
53+
it("Should call import API with the file as multipart body", async () => {
54+
mockAxiosPost(importUrl, {});
55+
mockExistsSync();
56+
mockCreateReadStream(Buffer.from(JSON.stringify(bookmarksResponse)));
57+
58+
await new ViewBookmarksCommandService(testContext).pushViewBookmarks(boardId, "bookmarks.json");
59+
60+
expect(mockedAxiosInstance.post).toHaveBeenCalledWith(importUrl, expect.anything(), expect.anything());
61+
expect(loggingTestTransport.logMessages.length).toBe(1);
62+
expect(loggingTestTransport.logMessages[0].message).toContain("View Bookmarks were pushed successfully.");
63+
});
64+
});
65+
66+
describe("manager factory", () => {
67+
it("Should report a fatal error when the push file does not exist", () => {
68+
(fs.existsSync as jest.Mock).mockReturnValue(false);
69+
const exitSpy = jest.spyOn(process, "exit").mockImplementation((() => undefined) as never);
70+
71+
new ViewBookmarksManagerFactory(testContext).createViewBookmarksManager("missing.json", boardId);
72+
73+
expect(exitSpy).toHaveBeenCalledWith(1);
74+
});
75+
});
76+
});

tests/jest.setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { logger } from "../src/core/utils/logger";
66

77
mockAxios();
88
jest.mock("fs");
9+
jest.mock("node:fs", () => require("fs"));
910

1011
const mockWriteFileSync = jest.fn();
1112
(fs.writeFileSync as jest.Mock).mockImplementation(mockWriteFileSync);

0 commit comments

Comments
 (0)