Skip to content
Merged
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
84 changes: 84 additions & 0 deletions static/app/components/forms/useFormTypingAnimation.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {act, renderHook} from 'sentry-test/reactTestingLibrary';

import FormModel from 'sentry/components/forms/model';

import {useFormTypingAnimation} from './useFormTypingAnimation';

describe('useFormTypingAnimation', () => {
function useTestHook(props: {speed?: number}) {
return useFormTypingAnimation(props);
}

beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it('animates text into the target form field', () => {
const formModel = new FormModel();
const {result} = renderHook(useTestHook, {
initialProps: {speed: 80},
});

act(() => {
result.current.triggerFormTypingAnimation({
formModel,
fieldName: 'name',
text: 'Hello',
});
});

expect(formModel.getValue('name')).toBe('');

act(() => {
jest.advanceTimersByTime(48);
});

const intermediateValue = formModel.getValue<string>('name') ?? '';
expect(intermediateValue.length).toBeGreaterThan(0);
expect(intermediateValue.length).toBeLessThan('Hello'.length);

act(() => {
jest.runAllTimers();
});

expect(formModel.getValue('name')).toBe('Hello');
});

it('restarts animation when triggered again', () => {
const formModel = new FormModel();
const {result} = renderHook(useTestHook, {
initialProps: {speed: 10},
});

act(() => {
result.current.triggerFormTypingAnimation({
formModel,
fieldName: 'name',
text: 'First generated title',
});
});

act(() => {
jest.advanceTimersByTime(120);
});

act(() => {
result.current.triggerFormTypingAnimation({
formModel,
fieldName: 'name',
text: 'New title',
speed: 120,
});
});

act(() => {
jest.runAllTimers();
});

expect(formModel.getValue('name')).toBe('New title');
});
});
96 changes: 96 additions & 0 deletions static/app/components/forms/useFormTypingAnimation.ts
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the right place for this hook to live? Seems like something that should be in a common directory

Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {useCallback, useEffect, useRef} from 'react';

import type FormModel from 'sentry/components/forms/model';

interface TriggerFormTypingAnimationParams {
fieldName: string;
formModel: FormModel;
text: string;
quiet?: boolean;
speed?: number;
}

interface UseFormTypingAnimationOptions {
/**
* Typing speed in characters per second.
*/
speed?: number;
}

/**
* Animates text directly into a form field value.
*/
export function useFormTypingAnimation({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if we could use Framer to do this, but I can see how it would be difficult with the form. Maybe another thing to look at when we try to update the form components

speed: defaultSpeed = 70,
}: UseFormTypingAnimationOptions = {}) {
const animationFrameRef = useRef<number | null>(null);
const currentIndexRef = useRef(0);
const lastUpdateTimeRef = useRef(0);
const runIdRef = useRef(0);

const cancelFormTypingAnimation = useCallback(() => {
runIdRef.current += 1;
if (animationFrameRef.current !== null) {
window.cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
}, []);

useEffect(() => cancelFormTypingAnimation, [cancelFormTypingAnimation]);

const triggerFormTypingAnimation = useCallback(
({
formModel,
fieldName,
text,
speed = defaultSpeed,
}: TriggerFormTypingAnimationParams) => {
cancelFormTypingAnimation();

const runId = runIdRef.current;

if (!text.length) {
formModel.setValue(fieldName, '', {quiet: true});
return;
}

currentIndexRef.current = 0;
lastUpdateTimeRef.current = performance.now();
formModel.setValue(fieldName, '', {quiet: true});

const interval = 1000 / Math.max(1, speed);

const animate = (timestamp: number) => {
if (runIdRef.current !== runId) {
return;
}

const elapsed = timestamp - lastUpdateTimeRef.current;
const charsToAdd = Math.floor(elapsed / interval);

if (charsToAdd > 0) {
const nextIndex = Math.min(text.length, currentIndexRef.current + charsToAdd);
if (nextIndex > currentIndexRef.current) {
formModel.setValue(fieldName, text.slice(0, nextIndex), {quiet: true});
currentIndexRef.current = nextIndex;
lastUpdateTimeRef.current = timestamp;
}
}

if (currentIndexRef.current < text.length) {
animationFrameRef.current = window.requestAnimationFrame(animate);
return;
}

animationFrameRef.current = null;
// The last setValue is not quiet to trigger form validation
formModel.setValue(fieldName, text);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final setValue ignores quiet parameter after animation

Medium Severity

The final formModel.setValue(fieldName, text) on animation completion omits the {quiet} option, while all other setValue calls in the animation respect it. When quiet is true (the default, and how it's used in useGeneratedIssueViewName), the caller expects field callbacks and validation to be suppressed, but this final call triggers them anyway. In the current usage this happens to be needed to get validateFormCompletion to run (so the submit button enables), but the inconsistency means the quiet parameter is effectively a lie — it's quiet for everything except the most impactful call. A future developer reading the quiet docs could easily be misled.

Fix in Cursor Fix in Web

};

animationFrameRef.current = window.requestAnimationFrame(animate);
},
[cancelFormTypingAnimation, defaultSpeed]
);

return {triggerFormTypingAnimation, cancelFormTypingAnimation};
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {GroupSearchViewFixture} from 'sentry-fixture/groupSearchView';
import {OrganizationFixture} from 'sentry-fixture/organization';
import {ProjectFixture} from 'sentry-fixture/project';

import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';

import {
makeClosableHeader,
Expand Down Expand Up @@ -80,6 +81,7 @@ describe('CreateIssueViewModal', () => {
});

const nameInput = screen.getByRole('textbox', {name: 'Name'});
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'foo');

await userEvent.click(screen.getByRole('button', {name: 'Create View'}));
Expand Down Expand Up @@ -109,4 +111,101 @@ describe('CreateIssueViewModal', () => {
})
);
}, 10_000);

describe('AI name streaming animation', () => {
const aiOrganization = OrganizationFixture({
features: ['issue-view-ai-title'],
});

beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it('applies a generated title when name is empty', async () => {
const mockGenerateTitle = MockApiClient.addMockResponse({
url: '/organizations/org-slug/issue-view-title/generate/',
method: 'POST',
body: {title: 'Generated View Title'},
});

render(<CreateIssueViewModal {...defaultProps} />, {
organization: aiOrganization,
});

const nameInput = screen.getByRole('textbox', {name: 'Name'});

await waitFor(() => {
expect(mockGenerateTitle).toHaveBeenCalledWith(
'/organizations/org-slug/issue-view-title/generate/',
expect.objectContaining({
method: 'POST',
data: {query: 'is:unresolved foo'},
})
);
});

act(() => {
jest.runAllTimers();
});

expect(nameInput).toHaveValue('Generated View Title');
});

it('does not override a pre-filled name', () => {
const mockGenerateTitle = MockApiClient.addMockResponse({
url: '/organizations/org-slug/issue-view-title/generate/',
method: 'POST',
body: {title: 'Generated View Title'},
});

render(<CreateIssueViewModal {...defaultProps} name="My Custom Name" />, {
organization: aiOrganization,
});

const nameInput = screen.getByRole('textbox', {name: 'Name'});

act(() => {
jest.runAllTimers();
});

expect(nameInput).toHaveValue('My Custom Name');
expect(mockGenerateTitle).not.toHaveBeenCalled();
});

it('does not override user edits', async () => {
const mockGenerateTitle = MockApiClient.addMockResponse({
url: '/organizations/org-slug/issue-view-title/generate/',
method: 'POST',
body: {title: 'Generated View Title'},
});

render(<CreateIssueViewModal {...defaultProps} />, {
organization: aiOrganization,
});

const nameInput = screen.getByRole('textbox', {name: 'Name'});
const user = userEvent.setup({advanceTimers: jest.advanceTimersByTime});

await waitFor(() => {
expect(mockGenerateTitle).toHaveBeenCalled();
});

act(() => {
jest.advanceTimersByTime(80);
});

await user.clear(nameInput);
await user.type(nameInput, 'Custom Name');

act(() => {
jest.runAllTimers();
});

expect(nameInput).toHaveValue('Custom Name');
});
});
});
Loading
Loading