Skip to content

Commit 2a52b84

Browse files
authored
fix: Preview reload in AI Assistant (#17708)
1 parent b28c0cd commit 2a52b84

File tree

14 files changed

+332
-162
lines changed

14 files changed

+332
-162
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { screen } from '@testing-library/react';
2+
import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
3+
import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse';
4+
import type { ILayoutSettings } from 'app-shared/types/global';
5+
import { renderWithProviders } from '../../../test/mocks';
6+
import { Preview } from './Preview';
7+
import { app, org } from '@studio/testing/testids';
8+
9+
const layoutSetName = 'form-layout-set';
10+
const taskId = 'Task_1';
11+
const layoutName = 'first-page';
12+
const instanceId = 'mock-instance-id';
13+
14+
const defaultLayoutSets: LayoutSets = {
15+
sets: [{ id: layoutSetName, tasks: [taskId] }],
16+
};
17+
18+
const defaultLayoutSettings: ILayoutSettings = {
19+
pages: { order: [layoutName] },
20+
};
21+
22+
describe('Preview', () => {
23+
it('should show a loading spinner while data is pending', () => {
24+
renderPreview();
25+
26+
expect(screen.getByText('Loading preview...')).toBeInTheDocument();
27+
});
28+
29+
it('should show an error message when layout metadata fails', async () => {
30+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
31+
const errorMessage = 'Failed to load';
32+
33+
renderPreview({
34+
getLayoutSets: jest.fn().mockRejectedValue(new Error(errorMessage)),
35+
});
36+
37+
expect(await screen.findByText(errorMessage)).toBeInTheDocument();
38+
expect(screen.queryByText('Loading preview...')).not.toBeInTheDocument();
39+
40+
consoleErrorSpy.mockRestore();
41+
});
42+
43+
it('should render an iframe when all data is available', async () => {
44+
renderPreview({
45+
getLayoutSets: jest.fn().mockResolvedValue(defaultLayoutSets),
46+
getFormLayoutSettings: jest.fn().mockResolvedValue(defaultLayoutSettings),
47+
createPreviewInstance: jest.fn().mockResolvedValue({ id: instanceId }),
48+
});
49+
50+
expect(await screen.findByTitle('App Preview')).toBeInTheDocument();
51+
});
52+
53+
it('should include layout set and task in the preview URL', async () => {
54+
renderPreview({
55+
getLayoutSets: jest.fn().mockResolvedValue(defaultLayoutSets),
56+
getFormLayoutSettings: jest.fn().mockResolvedValue(defaultLayoutSettings),
57+
createPreviewInstance: jest.fn().mockResolvedValue({ id: instanceId }),
58+
});
59+
60+
const iframe = await screen.findByTitle('App Preview');
61+
const src = iframe.getAttribute('src');
62+
expect(src).toContain(org);
63+
expect(src).toContain(app);
64+
expect(src).toContain(layoutSetName);
65+
expect(src).toContain(taskId);
66+
expect(src).toContain(layoutName);
67+
});
68+
});
69+
70+
const renderPreview = (queries: Partial<ServicesContextProps> = {}) => {
71+
renderWithProviders(queries)(<Preview />);
72+
};

src/Designer/frontend/app-development/features/aiAssistant/components/Preview.tsx

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
import type { ReactElement } from 'react';
2-
import { useState, useEffect } from 'react';
2+
import { useEffect } from 'react';
33
import { previewPage } from 'app-shared/api/paths';
44
import { useCreatePreviewInstanceMutation } from 'app-shared/hooks/mutations/useCreatePreviewInstanceMutation';
55
import { useUserQuery } from 'app-shared/hooks/queries';
66
import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams';
7-
import { StudioCenter, StudioSpinner } from '@studio/components';
7+
import { useCurrentBranchQuery } from 'app-shared/hooks/queries/useCurrentBranchQuery';
8+
import { StudioAlert, StudioCenter, StudioSpinner } from '@studio/components';
89
import { usePreviewLayoutMetadata } from '../hooks/usePreviewLayoutMetadata/usePreviewLayoutMetadata';
910
import classes from './Preview.module.css';
1011

1112
export const Preview = (): ReactElement => {
1213
const { org, app } = useStudioEnvironmentParams();
13-
const { data: user, isPending: userPending } = useUserQuery();
14+
const { data: user } = useUserQuery();
15+
const { data: currentBranchInfo } = useCurrentBranchQuery(org, app);
1416

15-
const [iframeKey, setIframeKey] = useState(0);
1617
const {
1718
mutate: createInstance,
1819
data: instance,
@@ -42,39 +43,23 @@ export const Preview = (): ReactElement => {
4243
user,
4344
]);
4445

45-
// Listen for repository reset events to reload the preview
46-
useEffect(() => {
47-
const handleRepoReset = (): void => {
48-
setIframeKey((prev) => prev + 1);
49-
};
50-
51-
window.addEventListener('altinity-repo-reset', handleRepoReset as EventListener);
52-
53-
return (): void => {
54-
window.removeEventListener('altinity-repo-reset', handleRepoReset as EventListener);
55-
};
56-
}, []);
57-
5846
const { layoutSetName, layoutName, taskId } = layoutMetadata;
47+
5948
const previewError =
6049
layoutMetadataError || (createInstanceError ? 'Error loading preview' : undefined);
61-
const isLoading =
62-
userPending ||
63-
layoutMetadataPending ||
64-
!layoutSetName ||
65-
!layoutName ||
66-
!taskId ||
67-
createInstancePending ||
68-
!instance;
6950

70-
if (isLoading) {
51+
if (!instance && !previewError) {
52+
return (
53+
<StudioCenter>
54+
<StudioSpinner spinnerTitle='Loading preview...' aria-hidden='true' />
55+
</StudioCenter>
56+
);
57+
}
58+
59+
if (previewError) {
7160
return (
7261
<StudioCenter>
73-
{previewError ? (
74-
<div style={{ color: '#f44336' }}>{previewError}</div>
75-
) : (
76-
<StudioSpinner spinnerTitle='Loading preview...' aria-hidden='true' />
77-
)}
62+
<StudioAlert data-color='danger'>{previewError}</StudioAlert>
7863
</StudioCenter>
7964
);
8065
}
@@ -84,7 +69,7 @@ export const Preview = (): ReactElement => {
8469
return (
8570
<div className={classes.previewContainer}>
8671
<iframe
87-
key={iframeKey}
72+
key={currentBranchInfo?.commitSha}
8873
className={classes.previewIframe}
8974
title='App Preview'
9075
src={previewURL}

src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWorkflow/useAltinityWorkflow.ts

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { useState, useEffect, useRef, useCallback } from 'react';
2-
import { useQueryClient } from '@tanstack/react-query';
32
import type {
43
UserMessage,
54
AssistantMessage,
@@ -13,7 +12,8 @@ import type {
1312
import { MessageAuthor } from '@studio/assistant';
1413
import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams';
1514
import { useCurrentBranchQuery } from 'app-shared/hooks/queries/useCurrentBranchQuery';
16-
import { QueryKey } from 'app-shared/types/QueryKey';
15+
import { useResetRepositoryMutation } from 'app-shared/hooks/mutations/useResetRepositoryMutation';
16+
import { useCheckoutBranchMutation } from 'app-shared/hooks/mutations/useCheckoutBranchMutation';
1717
import { useAltinityWebSocket } from '../useAltinityWebSocket/useAltinityWebSocket';
1818
import type { AltinityThreadState } from '../useAltinityThreads/useAltinityThreads';
1919
import {
@@ -44,8 +44,9 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo
4444
onAgentMessage,
4545
} = useAltinityWebSocket();
4646
const { org, app } = useStudioEnvironmentParams();
47-
const queryClient = useQueryClient();
4847
const { data: currentBranchInfo } = useCurrentBranchQuery(org, app);
48+
const { mutate: resetRepository } = useResetRepositoryMutation(org, app);
49+
const { mutate: checkoutBranch } = useCheckoutBranchMutation(org, app);
4950
const currentBranch = currentBranchInfo?.branchName;
5051
const currentBranchRef = useRef<string>('main');
5152
const backendSessionIdRef = useRef<string | null>(backendSessionId);
@@ -93,28 +94,17 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo
9394
const resetRepoForSession = useCallback(
9495
(sessionId: string) => {
9596
const branch = buildSessionBranch(sessionId);
96-
const resetUrl = `/designer/api/repos/repo/${org}/${app}/reset${branch !== 'main' ? `?branch=${encodeURIComponent(branch)}` : ''}`;
97-
fetch(resetUrl, {
98-
method: 'GET',
99-
credentials: 'same-origin',
100-
})
101-
.then(() => {
102-
console.log('Repository reset completed, triggering preview reload');
103-
currentBranchRef.current = branch;
104-
queryClient.invalidateQueries({
105-
queryKey: [QueryKey.CurrentBranch, org, app],
97+
resetRepository(undefined, {
98+
onSuccess: () => {
99+
checkoutBranch(branch, {
100+
onSuccess: () => {
101+
currentBranchRef.current = branch;
102+
},
106103
});
107-
window.dispatchEvent(
108-
new CustomEvent('altinity-repo-reset', {
109-
detail: { branch, sessionId },
110-
}),
111-
);
112-
})
113-
.catch((error) => {
114-
console.warn('Failed to reset repository:', error);
115-
});
104+
},
105+
});
116106
},
117-
[app, org, queryClient],
107+
[resetRepository, checkoutBranch],
118108
);
119109

120110
const handleAssistantMessage = useCallback(
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { waitFor } from '@testing-library/react';
2+
import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
3+
import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse';
4+
import type { ILayoutSettings } from 'app-shared/types/global';
5+
import { renderHookWithProviders } from '../../../../test/mocks';
6+
import { usePreviewLayoutMetadata } from './usePreviewLayoutMetadata';
7+
import { app, org } from '@studio/testing/testids';
8+
9+
const layoutSetName = 'form-layout-set';
10+
const taskId = 'Task_1';
11+
const layoutName = 'first-page';
12+
13+
const layoutSetsWithEntry: LayoutSets = {
14+
sets: [{ id: layoutSetName, tasks: [taskId] }],
15+
};
16+
17+
const layoutSettingsWithPages: ILayoutSettings = {
18+
pages: { order: [layoutName, 'second-page'] },
19+
};
20+
21+
describe('usePreviewLayoutMetadata', () => {
22+
it('should return metadata when layout sets and settings are available', async () => {
23+
const { result } = renderHook({
24+
getLayoutSets: jest.fn().mockResolvedValue(layoutSetsWithEntry),
25+
getFormLayoutSettings: jest.fn().mockResolvedValue(layoutSettingsWithPages),
26+
});
27+
28+
await waitFor(() => expect(result.current.isPending).toBe(false));
29+
30+
expect(result.current.metadata).toEqual({
31+
layoutSetName,
32+
layoutName,
33+
taskId,
34+
});
35+
expect(result.current.error).toBeUndefined();
36+
});
37+
38+
it('should default taskId to Task_1 when layout set has no tasks', async () => {
39+
const layoutSetsWithoutTasks: LayoutSets = {
40+
sets: [{ id: layoutSetName }],
41+
};
42+
43+
const { result } = renderHook({
44+
getLayoutSets: jest.fn().mockResolvedValue(layoutSetsWithoutTasks),
45+
getFormLayoutSettings: jest.fn().mockResolvedValue(layoutSettingsWithPages),
46+
});
47+
48+
await waitFor(() => expect(result.current.isPending).toBe(false));
49+
50+
expect(result.current.metadata.taskId).toBe('Task_1');
51+
});
52+
53+
it('should return empty metadata when there are no layout sets', async () => {
54+
const emptyLayoutSets: LayoutSets = { sets: [] };
55+
56+
const { result } = renderHook({
57+
getLayoutSets: jest.fn().mockResolvedValue(emptyLayoutSets),
58+
});
59+
60+
await waitFor(() => expect(result.current.metadata).toEqual({}));
61+
});
62+
63+
it('should return empty metadata when layout settings has no pages', async () => {
64+
const emptyLayoutSettings: ILayoutSettings = {};
65+
66+
const { result } = renderHook({
67+
getLayoutSets: jest.fn().mockResolvedValue(layoutSetsWithEntry),
68+
getFormLayoutSettings: jest.fn().mockResolvedValue(emptyLayoutSettings),
69+
});
70+
71+
await waitFor(() => expect(result.current.isPending).toBe(false));
72+
73+
expect(result.current.metadata).toEqual({});
74+
});
75+
76+
it('should return error when getLayoutSets fails', async () => {
77+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
78+
const errorMessage = 'Failed to fetch layout sets';
79+
80+
const { result } = renderHook({
81+
getLayoutSets: jest.fn().mockRejectedValue(new Error(errorMessage)),
82+
});
83+
84+
await waitFor(() => expect(result.current.error).toBeDefined());
85+
86+
expect(result.current.error).toBe(errorMessage);
87+
expect(result.current.metadata).toEqual({});
88+
consoleErrorSpy.mockRestore();
89+
});
90+
91+
it('should return error when getFormLayoutSettings fails', async () => {
92+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
93+
const errorMessage = 'Failed to fetch layout settings';
94+
95+
const { result } = renderHook({
96+
getLayoutSets: jest.fn().mockResolvedValue(layoutSetsWithEntry),
97+
getFormLayoutSettings: jest.fn().mockRejectedValue(new Error(errorMessage)),
98+
});
99+
100+
await waitFor(() => expect(result.current.error).toBeDefined());
101+
102+
expect(result.current.error).toBe(errorMessage);
103+
expect(result.current.metadata).toEqual({});
104+
consoleErrorSpy.mockRestore();
105+
});
106+
});
107+
108+
const renderHook = (queries: Partial<ServicesContextProps> = {}) => {
109+
const { renderHookResult } = renderHookWithProviders(queries)(() =>
110+
usePreviewLayoutMetadata(org, app),
111+
);
112+
return renderHookResult;
113+
};

0 commit comments

Comments
 (0)