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
204 changes: 204 additions & 0 deletions static/app/views/projectInstall/createProject.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {TeamFixture} from 'sentry-fixture/team';

import {initializeOrg} from 'sentry-test/initializeOrg';
import {
act,
render,
renderGlobalModal,
screen,
Expand Down Expand Up @@ -191,6 +192,209 @@ describe('CreateProject', () => {
expect(screen.getByPlaceholderText('project-slug')).toHaveValue('another');
});

it('should not overwrite a user-entered project name when the name happens to match the current platform key', async () => {
// Regression test: previously, the check `projectName !== platform.key` would incorrectly
// treat the name as auto-generated if it matched the current platform slug, causing a
// platform switch to overwrite a name the user explicitly typed.
const {organization} = initializeOrg({
organization: {
access: ['project:read'],
features: ['team-roles'],
allowMemberProjectCreation: true,
},
});

render(<CreateProject />, {organization});

// User explicitly types a name that happens to match a platform id
await userEvent.type(screen.getByPlaceholderText('project-slug'), 'apple-ios');

// User then selects a different platform
await userEvent.click(screen.getByTestId('platform-ruby-rails'));

// The name they typed should be preserved, not replaced with 'ruby-rails'
expect(screen.getByPlaceholderText('project-slug')).toHaveValue('apple-ios');
});

it('should preserve user-entered project slug when filter bar auto-selects a platform', async () => {
// Regression test: PlatformPicker's debounceSearch captured setPlatform from the
// initial render. After the user typed a slug, handlePlatformChange was recreated with
// the new projectName — but debounceSearch still held the stale version with
// projectName='', so auto-selection via the filter bar would wipe the user's slug.
const {organization} = initializeOrg({
organization: {
access: ['project:read'],
features: ['team-roles'],
allowMemberProjectCreation: true,
},
});

jest.useFakeTimers();
render(<CreateProject />, {organization});

await userEvent.type(screen.getByPlaceholderText('project-slug'), 'my-custom-slug', {
delay: null,
});
expect(screen.getByPlaceholderText('project-slug')).toHaveValue('my-custom-slug');

// Type a platform name that exactly matches "Android" (triggers debounce auto-selection)
await userEvent.type(screen.getByPlaceholderText('Filter Platforms'), 'android', {
delay: null,
});

// Run all pending timers and flush React state updates
act(() => {
jest.runAllTimers();
});

jest.useRealTimers();

// The user's slug must be preserved, not replaced by the auto-selected platform id
expect(screen.getByPlaceholderText('project-slug')).toHaveValue('my-custom-slug');
});

it('should allow platform to fill the project name again after the user clears it', async () => {
const {organization} = initializeOrg({
organization: {
access: ['project:read'],
features: ['team-roles'],
allowMemberProjectCreation: true,
},
});

render(<CreateProject />, {organization});

// User types a name
await userEvent.type(screen.getByPlaceholderText('project-slug'), 'my-project');
expect(screen.getByPlaceholderText('project-slug')).toHaveValue('my-project');

// User clears the field (signals they want the platform to drive the name again)
await userEvent.clear(screen.getByPlaceholderText('project-slug'));
expect(screen.getByPlaceholderText('project-slug')).toHaveValue('');

// Now selecting a platform should fill the name
await userEvent.click(screen.getByTestId('platform-apple-ios'));
expect(screen.getByPlaceholderText('project-slug')).toHaveValue('apple-ios');
});

it('should preserve user-entered project slug when returning via auto-fill after delayed route replace', async () => {
// Regression test: when the user clicks "Back to Platform Selection", the browser
// POP navigation fires first (without query params), mounting the component with
// autoFill=false. Then router.replace adds ?referrer=getting-started&project=<id>,
// transitioning autoFill to true. useRef only initializes once, so without the
// useEffect sync, hasUserModifiedProjectName stays false and platform changes
// overwrite the user's custom slug.
const {organization} = initializeOrg({
organization: {
access: ['project:read'],
features: ['team-roles'],
allowMemberProjectCreation: true,
},
});

TeamStore.loadUserTeams([teamWithAccess]);

// Simulate a previously created project stored in localStorage
window.localStorage.setItem(
'created-project-context',
JSON.stringify({
id: '12345',
name: 'my-custom-name',
team: teamWithAccess.slug,
platform: {
key: 'javascript-angular',
name: 'Angular',
type: 'framework',
language: 'javascript',
category: 'popular',
},
wasNameManuallyModified: true,
})
);

// Step 1: Mount WITHOUT query params (simulates the browser POP navigation)
const {router} = render(<CreateProject />, {
organization,
initialRouterConfig: {
location: {
pathname: '/projects/new/',
},
},
});

// Step 2: Navigate WITH query params (simulates router.replace)
router.navigate('/projects/new/?referrer=getting-started&project=12345');

// The slug field should be auto-filled with the stored name
await waitFor(() => {
expect(screen.getByPlaceholderText('project-slug')).toHaveValue('my-custom-name');
});

// Step 3: Click a different platform
await userEvent.click(screen.getByTestId('platform-apple-ios'));

// The user's custom slug must be preserved, not replaced with 'apple-ios'
expect(screen.getByPlaceholderText('project-slug')).toHaveValue('my-custom-name');

// Clean up localStorage
window.localStorage.removeItem('created-project-context');
});

it('should update project slug on platform change when slug was not manually modified', async () => {
// When the user didn't manually type a slug (wasNameManuallyModified: false),
// changing the platform should update the slug to the new platform's ID.
const {organization} = initializeOrg({
organization: {
access: ['project:read'],
features: ['team-roles'],
allowMemberProjectCreation: true,
},
});

TeamStore.loadUserTeams([teamWithAccess]);

window.localStorage.setItem(
'created-project-context',
JSON.stringify({
id: '12345',
name: 'javascript-angular',
team: teamWithAccess.slug,
platform: {
key: 'javascript-angular',
name: 'Angular',
type: 'framework',
language: 'javascript',
category: 'popular',
},
wasNameManuallyModified: false,
})
);

const {router} = render(<CreateProject />, {
organization,
initialRouterConfig: {
location: {
pathname: '/projects/new/',
},
},
});

router.navigate('/projects/new/?referrer=getting-started&project=12345');

await waitFor(() => {
expect(screen.getByPlaceholderText('project-slug')).toHaveValue(
'javascript-angular'
);
});

// Click a different platform — slug should update since user didn't manually modify it
await userEvent.click(screen.getByTestId('platform-apple-ios'));

expect(screen.getByPlaceholderText('project-slug')).toHaveValue('apple-ios');

window.localStorage.removeItem('created-project-context');
});

it('should display success message on proj creation', async () => {
const {organization} = initializeOrg({
organization: {
Expand Down
47 changes: 35 additions & 12 deletions static/app/views/projectInstall/createProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ type CreatedProject = Pick<Project, 'name' | 'id'> & {
alertRule?: Partial<AlertRuleOptions>;
notificationRule?: IssueAlertRule;
team?: string;
wasNameManuallyModified?: boolean;
};

function getMissingValues({
Expand Down Expand Up @@ -191,6 +192,23 @@ export function CreateProject() {

const [formData, setFormData] = useState<FormData>(initialData);
const pickerKeyRef = useRef<'create-project' | 'auto-fill'>('create-project');
const hasUserModifiedProjectName = useRef(false);

// Sync the ref when autoFill data becomes available.
// useRef only initializes once, but the component may first mount without
// query params (e.g. browser back fires a POP before router.replace adds them),
// so autoFill can transition from false → true after the initial render.
// When that happens we also need to populate the projectName since useState
// would have initialized with the non-autoFill (empty) value.
useEffect(() => {
if (autoFill && createdProject?.name) {
hasUserModifiedProjectName.current = createdProject.wasNameManuallyModified ?? true;
setFormData(prev => ({
...prev,
projectName: createdProject.name ?? prev.projectName,
}));
}
}, [autoFill, createdProject?.name, createdProject?.wasNameManuallyModified]);

const canCreateTeam = organization.access.includes('project:admin');
const isOrgMemberWithNoAccess = accessTeams.length === 0 && !canCreateTeam;
Expand Down Expand Up @@ -244,6 +262,9 @@ export function CreateProject() {

useEffect(() => {
(Object.keys(initialData) as Array<keyof typeof initialData>).forEach(key => {
if (key === 'projectName' && hasUserModifiedProjectName.current) {
return;
}
updateFormData(key, initialData[key]);
});
}, [initialData, updateFormData]);
Expand Down Expand Up @@ -305,6 +326,7 @@ export function CreateProject() {
platform: selectedPlatform,
alertRule,
notificationRule,
wasNameManuallyModified: hasUserModifiedProjectName.current,
});

navigate(
Expand Down Expand Up @@ -436,18 +458,13 @@ export function CreateProject() {
return;
}

updateFormData('platform', {
...omit(value, 'id'),
key: value.id,
});

const userModifiedName =
!!formData.projectName && formData.projectName !== formData.platform?.key;
const newName = userModifiedName ? formData.projectName : value.id;

updateFormData('projectName', newName);
setFormData(prev => ({
...prev,
platform: {...omit(value, 'id'), key: value.id},
projectName: hasUserModifiedProjectName.current ? prev.projectName : value.id,
}));
},
[updateFormData, formData.projectName, formData.platform?.key, organization]
[updateFormData, organization]
);

const platform = formData.platform?.key;
Expand Down Expand Up @@ -519,7 +536,13 @@ export function CreateProject() {
placeholder={t('project-slug')}
autoComplete="off"
value={formData.projectName}
onChange={e => updateFormData('projectName', slugify(e.target.value))}
onChange={e => {
const slugified = slugify(e.target.value);
// Track whether the user has intentionally set a custom name.
// Reset if they clear the field so platform selection can fill it in again.
hasUserModifiedProjectName.current = slugified !== '';
updateFormData('projectName', slugified);
}}
/>
</ProjectNameInputWrap>
</div>
Expand Down
Loading