Skip to content

Commit 8b0cb12

Browse files
feat(lightspeed): add dedicated route for individual notebook view (#2934)
* feat(lightspeed): add dedicated route for individual notebook view Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> * updating the tests Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> * updating changeset Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> --------- Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com>
1 parent a5a1846 commit 8b0cb12

13 files changed

Lines changed: 511 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-lightspeed': minor
3+
---
4+
5+
Add dedicated route for individual notebook view (/lightspeed/notebooks/:notebookId).

workspaces/lightspeed/plugins/lightspeed/report.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const lightspeedPlugin: BackstagePlugin<
5252
PathParams<'/conversation/:conversationId'>
5353
>;
5454
lightspeedNotebooks: SubRouteRef<undefined>;
55+
lightspeedNotebookView: SubRouteRef<PathParams<'/notebooks/:notebookId'>>;
5556
},
5657
{},
5758
{}

workspaces/lightspeed/plugins/lightspeed/src/api/NotebooksApiClient.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,17 @@ export class NotebooksApiClient implements NotebooksAPI {
135135
return response?.sessions ?? [];
136136
}
137137

138+
async getSession(sessionId: string) {
139+
const baseUrl = await this.getBaseUrl();
140+
const response = await this.fetchJson<SessionResponse>(
141+
`${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}`,
142+
);
143+
if (!response.session) {
144+
throw new Error(response.error ?? 'Session not found');
145+
}
146+
return response.session;
147+
}
148+
138149
async renameSession(sessionId: string, name: string) {
139150
const baseUrl = await this.getBaseUrl();
140151
await this.fetchJson(
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { ConfigApi, FetchApi } from '@backstage/core-plugin-api';
18+
19+
import { NotebooksApiClient } from '../NotebooksApiClient';
20+
21+
describe('NotebooksApiClient', () => {
22+
let mockConfigApi: jest.Mocked<ConfigApi>;
23+
let mockFetchApi: jest.Mocked<FetchApi>;
24+
let client: NotebooksApiClient;
25+
26+
beforeEach(() => {
27+
mockConfigApi = {
28+
getString: jest.fn().mockReturnValue('http://localhost:7007'),
29+
} as unknown as jest.Mocked<ConfigApi>;
30+
31+
mockFetchApi = {
32+
fetch: jest.fn(),
33+
} as unknown as jest.Mocked<FetchApi>;
34+
35+
client = new NotebooksApiClient({
36+
configApi: mockConfigApi,
37+
fetchApi: mockFetchApi,
38+
});
39+
});
40+
41+
afterEach(() => {
42+
jest.clearAllMocks();
43+
});
44+
45+
describe('getBaseUrl', () => {
46+
it('should return the correct base URL', async () => {
47+
const baseUrl = await client.getBaseUrl();
48+
expect(baseUrl).toBe('http://localhost:7007/api/lightspeed/notebooks');
49+
expect(mockConfigApi.getString).toHaveBeenCalledWith('backend.baseUrl');
50+
});
51+
});
52+
53+
describe('getSession', () => {
54+
it('should return session data when API call succeeds', async () => {
55+
const mockSession = {
56+
session_id: 'vs_test-123',
57+
name: 'Test Notebook',
58+
metadata: { conversation_id: 'conv-456' },
59+
};
60+
61+
mockFetchApi.fetch.mockResolvedValue({
62+
ok: true,
63+
text: jest.fn().mockResolvedValue(
64+
JSON.stringify({
65+
session: mockSession,
66+
message: 'Session retrieved successfully',
67+
}),
68+
),
69+
} as unknown as Response);
70+
71+
const result = await client.getSession('vs_test-123');
72+
73+
expect(result).toEqual(mockSession);
74+
expect(mockFetchApi.fetch).toHaveBeenCalledWith(
75+
'http://localhost:7007/api/lightspeed/notebooks/v1/sessions/vs_test-123',
76+
expect.objectContaining({
77+
headers: { 'Content-Type': 'application/json' },
78+
}),
79+
);
80+
});
81+
82+
it('should encode special characters in session ID', async () => {
83+
const mockSession = {
84+
session_id: 'vs_test/special?chars',
85+
name: 'Test Notebook',
86+
};
87+
88+
mockFetchApi.fetch.mockResolvedValue({
89+
ok: true,
90+
text: jest
91+
.fn()
92+
.mockResolvedValue(JSON.stringify({ session: mockSession })),
93+
} as unknown as Response);
94+
95+
await client.getSession('vs_test/special?chars');
96+
97+
expect(mockFetchApi.fetch).toHaveBeenCalledWith(
98+
'http://localhost:7007/api/lightspeed/notebooks/v1/sessions/vs_test%2Fspecial%3Fchars',
99+
expect.any(Object),
100+
);
101+
});
102+
103+
it('should throw error when session is not found', async () => {
104+
mockFetchApi.fetch.mockResolvedValue({
105+
ok: false,
106+
status: 404,
107+
statusText: 'Not Found',
108+
text: jest
109+
.fn()
110+
.mockResolvedValue(JSON.stringify({ error: 'Session not found' })),
111+
} as unknown as Response);
112+
113+
await expect(client.getSession('vs_invalid')).rejects.toThrow(
114+
'Session not found',
115+
);
116+
});
117+
118+
it('should throw error when response has no session', async () => {
119+
mockFetchApi.fetch.mockResolvedValue({
120+
ok: true,
121+
text: jest
122+
.fn()
123+
.mockResolvedValue(
124+
JSON.stringify({ message: 'Success but no session' }),
125+
),
126+
} as unknown as Response);
127+
128+
await expect(client.getSession('vs_test-123')).rejects.toThrow(
129+
'Session not found',
130+
);
131+
});
132+
133+
it('should throw error with custom error message from response', async () => {
134+
mockFetchApi.fetch.mockResolvedValue({
135+
ok: true,
136+
text: jest
137+
.fn()
138+
.mockResolvedValue(JSON.stringify({ error: 'Custom error message' })),
139+
} as unknown as Response);
140+
141+
await expect(client.getSession('vs_test-123')).rejects.toThrow(
142+
'Custom error message',
143+
);
144+
});
145+
});
146+
147+
describe('listSessions', () => {
148+
it('should return sessions when API call succeeds', async () => {
149+
const mockSessions = [
150+
{ session_id: 'vs_1', name: 'Notebook 1' },
151+
{ session_id: 'vs_2', name: 'Notebook 2' },
152+
];
153+
154+
mockFetchApi.fetch.mockResolvedValue({
155+
ok: true,
156+
text: jest
157+
.fn()
158+
.mockResolvedValue(JSON.stringify({ sessions: mockSessions })),
159+
} as unknown as Response);
160+
161+
const result = await client.listSessions();
162+
163+
expect(result).toEqual(mockSessions);
164+
expect(mockFetchApi.fetch).toHaveBeenCalledWith(
165+
'http://localhost:7007/api/lightspeed/notebooks/v1/sessions',
166+
expect.any(Object),
167+
);
168+
});
169+
170+
it('should return empty array when sessions is undefined', async () => {
171+
mockFetchApi.fetch.mockResolvedValue({
172+
ok: true,
173+
text: jest.fn().mockResolvedValue(JSON.stringify({})),
174+
} as unknown as Response);
175+
176+
const result = await client.listSessions();
177+
expect(result).toEqual([]);
178+
});
179+
});
180+
181+
describe('createSession', () => {
182+
it('should create and return session', async () => {
183+
const mockSession = {
184+
session_id: 'vs_new-123',
185+
name: 'New Notebook',
186+
};
187+
188+
mockFetchApi.fetch.mockResolvedValue({
189+
ok: true,
190+
text: jest
191+
.fn()
192+
.mockResolvedValue(JSON.stringify({ session: mockSession })),
193+
} as unknown as Response);
194+
195+
const result = await client.createSession('New Notebook', 'Description');
196+
197+
expect(result).toEqual(mockSession);
198+
expect(mockFetchApi.fetch).toHaveBeenCalledWith(
199+
'http://localhost:7007/api/lightspeed/notebooks/v1/sessions',
200+
expect.objectContaining({
201+
method: 'POST',
202+
body: JSON.stringify({
203+
name: 'New Notebook',
204+
description: 'Description',
205+
}),
206+
}),
207+
);
208+
});
209+
210+
it('should throw error when creation fails', async () => {
211+
mockFetchApi.fetch.mockResolvedValue({
212+
ok: true,
213+
text: jest
214+
.fn()
215+
.mockResolvedValue(
216+
JSON.stringify({ error: 'Failed to create session' }),
217+
),
218+
} as unknown as Response);
219+
220+
await expect(client.createSession('New Notebook')).rejects.toThrow(
221+
'Failed to create session',
222+
);
223+
});
224+
});
225+
226+
describe('renameSession', () => {
227+
it('should rename session successfully', async () => {
228+
mockFetchApi.fetch.mockResolvedValue({
229+
ok: true,
230+
text: jest.fn().mockResolvedValue(''),
231+
} as unknown as Response);
232+
233+
await client.renameSession('vs_test-123', 'New Name');
234+
235+
expect(mockFetchApi.fetch).toHaveBeenCalledWith(
236+
'http://localhost:7007/api/lightspeed/notebooks/v1/sessions/vs_test-123',
237+
expect.objectContaining({
238+
method: 'PUT',
239+
body: JSON.stringify({ name: 'New Name' }),
240+
}),
241+
);
242+
});
243+
});
244+
245+
describe('deleteSession', () => {
246+
it('should delete session successfully', async () => {
247+
mockFetchApi.fetch.mockResolvedValue({
248+
ok: true,
249+
text: jest.fn().mockResolvedValue(''),
250+
} as unknown as Response);
251+
252+
await client.deleteSession('vs_test-123');
253+
254+
expect(mockFetchApi.fetch).toHaveBeenCalledWith(
255+
'http://localhost:7007/api/lightspeed/notebooks/v1/sessions/vs_test-123',
256+
expect.objectContaining({
257+
method: 'DELETE',
258+
}),
259+
);
260+
});
261+
});
262+
});

workspaces/lightspeed/plugins/lightspeed/src/api/notebooksApi.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export type NotebooksAPI = {
3333
description?: string,
3434
) => Promise<NotebookSession>;
3535
listSessions: () => Promise<NotebookSession[]>;
36+
getSession: (sessionId: string) => Promise<NotebookSession>;
3637
renameSession: (sessionId: string, name: string) => Promise<void>;
3738
deleteSession: (sessionId: string) => Promise<void>;
3839
uploadDocument: (

workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import {
8787
useLastOpenedConversation,
8888
useLightspeedDeletePermission,
8989
useLightspeedNotebooksPermission,
90+
useNotebookSession,
9091
useNotebookSessions,
9192
usePinnedChatsSettings,
9293
useSortSettings,
@@ -475,11 +476,13 @@ export const LightspeedChat = ({
475476
const { t } = useTranslation();
476477
const navigate = useNavigate();
477478
const notebooksRouteMatch = useMatch('/lightspeed/notebooks');
479+
const notebookViewRouteMatch = useMatch('/lightspeed/notebooks/:notebookId');
480+
const routeNotebookId = notebookViewRouteMatch?.params?.notebookId;
478481
const user = useBackstageUserIdentity();
479482
const [filterValue, setFilterValue] = useState<string>('');
480483
const [announcement, setAnnouncement] = useState<string>('');
481484
const [activeTab, setActiveTab] = useState<number>(
482-
notebooksRouteMatch ? 1 : 0,
485+
notebooksRouteMatch || notebookViewRouteMatch ? 1 : 0,
483486
);
484487
const { allowed: hasNotebooksAccess, loading: notebooksPermissionLoading } =
485488
useLightspeedNotebooksPermission();
@@ -496,6 +499,29 @@ export const LightspeedChat = ({
496499
const [activeNotebook, setActiveNotebook] = useState<NotebookSession | null>(
497500
null,
498501
);
502+
const {
503+
data: routeNotebook,
504+
isLoading: routeNotebookLoading,
505+
isError: routeNotebookError,
506+
} = useNotebookSession(routeNotebookId);
507+
508+
useEffect(() => {
509+
if (routeNotebookId && routeNotebook && !routeNotebookLoading) {
510+
setActiveNotebook(routeNotebook);
511+
} else if (routeNotebookId && routeNotebookError) {
512+
navigate('/lightspeed/notebooks', { replace: true });
513+
} else if (!routeNotebookId && notebooksRouteMatch) {
514+
setActiveNotebook(null);
515+
}
516+
}, [
517+
routeNotebookId,
518+
routeNotebook,
519+
routeNotebookLoading,
520+
routeNotebookError,
521+
notebooksRouteMatch,
522+
navigate,
523+
]);
524+
499525
const [notebookAlerts, setNotebookAlerts] = useState<Partial<AlertProps>[]>(
500526
[],
501527
);
@@ -560,16 +586,16 @@ export const LightspeedChat = ({
560586
{ name: UNTITLED_NOTEBOOK_NAME },
561587
{
562588
onSuccess: (session: NotebookSession) => {
563-
setActiveNotebook(session);
589+
navigate(`/lightspeed/notebooks/${session.session_id}`);
564590
},
565591
},
566592
);
567-
}, [createNotebookMutation]);
593+
}, [createNotebookMutation, navigate]);
568594

569595
const handleCloseNotebook = useCallback(() => {
570-
setActiveNotebook(null);
596+
navigate('/lightspeed/notebooks');
571597
refetchNotebooks();
572-
}, [refetchNotebooks]);
598+
}, [navigate, refetchNotebooks]);
573599

574600
const handleRemoveNotebookAlert = (key: React.Key) => {
575601
setNotebookAlerts(prevAlerts =>
@@ -1668,7 +1694,9 @@ export const LightspeedChat = ({
16681694
classes={classes}
16691695
openNotebookMenuId={openNotebookMenuId}
16701696
setOpenNotebookMenuId={setOpenNotebookMenuId}
1671-
onSelectNotebook={setActiveNotebook}
1697+
onSelectNotebook={(notebook: NotebookSession) =>
1698+
navigate(`/lightspeed/notebooks/${notebook.session_id}`)
1699+
}
16721700
onRename={setRenameNotebookId}
16731701
onDelete={setDeleteNotebookId}
16741702
onCreateNotebook={handleCreateNotebook}

workspaces/lightspeed/plugins/lightspeed/src/components/Router.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const Router = () => {
3030
element={<LightspeedPage />}
3131
/>
3232
<Route path="/notebooks" element={<LightspeedPage />} />
33+
<Route path="/notebooks/:notebookId" element={<LightspeedPage />} />
3334
</Routes>
3435
);
3536
};

0 commit comments

Comments
 (0)