Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f92ccef
ref(dynamic-sampling): Migrate projectSampling to new TanStack form s…
JonasBa Feb 25, 2026
6d9c263
fix(dynamic-sampling): Align projectSampling form with new scraps API
priscilawebdev Mar 26, 2026
dc8b6c0
ref(dynamic-sampling): Use shared Zod validation for project rates
priscilawebdev Mar 26, 2026
ad4592b
fix(dynamic-sampling): Initialize form with project rates
priscilawebdev Mar 26, 2026
ab426cf
fix(dynamic-sampling): Add spacing between rate input and previous label
priscilawebdev Mar 26, 2026
284d171
ref(dynamic-sampling): Remove manual getProjectRateErrors
priscilawebdev Mar 26, 2026
a69e5cb
fix(dynamic-sampling): Show validation error on failed submit
priscilawebdev Mar 26, 2026
8efcdc5
fix(dynamic-sampling): Show per-row errors after failed submit
priscilawebdev Mar 26, 2026
122f41e
ref(dynamic-sampling): Reuse sampleRateField for per-row validation
priscilawebdev Mar 26, 2026
369a758
ref(dynamic-sampling): Use individual AppField per project rate
priscilawebdev Mar 26, 2026
c458487
ref(dynamic-sampling): Remove form prop from ProjectsTable
priscilawebdev Mar 26, 2026
48964e9
ref(dynamic-sampling): Replace type hack with explicit interface
priscilawebdev Mar 26, 2026
9e6c294
ref(dynamic-sampling): Decouple ProjectsEditTable from form system
priscilawebdev Mar 26, 2026
0865dc6
ref(dynamic-sampling): Remove canSubmit from submit buttons
priscilawebdev Mar 26, 2026
a4381d4
test(dynamic-sampling): Add tests for ProjectSampling
priscilawebdev Mar 26, 2026
88802e9
Merge branch 'master' into jb/forms/dynamic-sampling-project-sampling
priscilawebdev Mar 27, 2026
d3206e7
ref(dynamic-sampling): Add aria-label to project rate input
priscilawebdev Mar 27, 2026
bdf02c6
perf(dynamic-sampling): Batch bulk org rate updates
priscilawebdev Mar 27, 2026
87b64b9
fix(dynamic-sampling): Show 0% instead of blank estimated org rate
priscilawebdev Mar 30, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,20 @@ const UNSAVED_CHANGES_MESSAGE = t(
'You have unsaved changes, are you sure you want to leave?'
);

export const sampleRateField = z
.string()
.min(1, t('Please enter a valid number'))
.refine(val => !isNaN(Number(val)), {message: t('Please enter a valid number')})
.refine(
val => {
const n = Number(val);
return n >= 0 && n <= 100;
},
{message: t('Must be between 0% and 100%')}
);

export const targetSampleRateSchema = z.object({
targetSampleRate: z
.string()
.min(1, t('Please enter a valid number'))
.refine(val => !isNaN(Number(val)), {message: t('Please enter a valid number')})
.refine(
val => {
const n = Number(val);
return n >= 0 && n <= 100;
},
{message: t('Must be between 0% and 100%')}
),
targetSampleRate: sampleRateField,
});

export function OrganizationSampling() {
Expand Down Expand Up @@ -80,8 +82,8 @@ export function OrganizationSampling() {

return (
<form.AppForm form={form}>
<form.Subscribe selector={s => ({isDirty: s.isDirty, canSubmit: s.canSubmit})}>
{({isDirty, canSubmit}) => (
<form.Subscribe selector={s => ({isDirty: s.isDirty})}>
{({isDirty}) => (
<Fragment>
<OnRouteLeave
message={UNSAVED_CHANGES_MESSAGE}
Expand Down Expand Up @@ -121,10 +123,7 @@ export function OrganizationSampling() {
'You do not have permission to update these settings.'
)}
>
<form.SubmitButton
disabled={!hasAccess || !canSubmit}
formNoValidate
>
<form.SubmitButton disabled={!hasAccess} formNoValidate>
{t('Apply Changes')}
</form.SubmitButton>
</Tooltip>
Expand Down
222 changes: 222 additions & 0 deletions static/app/views/settings/dynamicSampling/projectSampling.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import {OrganizationFixture} from 'sentry-fixture/organization';
import {ProjectFixture} from 'sentry-fixture/project';

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

import {ProjectsStore} from 'sentry/stores/projectsStore';

import {ProjectSampling} from './projectSampling';

jest.mock('@tanstack/react-virtual', () => ({
useVirtualizer: jest.fn(({count}: {count: number}) => ({
getVirtualItems: jest.fn(() =>
Array.from({length: count}, (_, index) => ({
key: index,
index,
start: index * 63,
size: 63,
}))
),
getTotalSize: jest.fn(() => count * 63),
measure: jest.fn(),
})),
}));

describe('ProjectSampling', () => {
const project = ProjectFixture({id: '1', slug: 'project-slug'});
const organization = OrganizationFixture({
slug: 'org-slug',
access: ['org:write'],
samplingMode: 'project',
});

beforeEach(() => {
MockApiClient.clearMockResponses();
act(() => ProjectsStore.loadInitialData([project]));

MockApiClient.addMockResponse({
url: '/organizations/org-slug/sampling/project-root-counts/',
body: {
data: [
[
{
by: {project: 'project-slug', target_project_id: '1'},
totals: 1000,
series: [],
},
],
],
end: '',
intervals: [],
start: '',
},
});

MockApiClient.addMockResponse({
url: '/organizations/org-slug/sampling/project-rates/',
body: [{id: 1, sampleRate: 0.5}],
});
});

async function waitForProjectRateInput() {
return screen.findByRole('spinbutton', {
name: 'Sample rate for project-slug',
});
}

it('renders project rate inputs with initial values', async () => {
// The input briefly transitions from uncontrolled to controlled as form
// state initializes with the fetched project rates.
jest.spyOn(console, 'error').mockImplementation();

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

const input = await waitForProjectRateInput();
expect(input).toHaveValue(50);
});

it('enables Reset button after changing a project rate', async () => {
render(<ProjectSampling />, {organization});

const input = await waitForProjectRateInput();
expect(screen.getByRole('button', {name: 'Reset'})).toBeDisabled();

await userEvent.clear(input);
await userEvent.type(input, '30');

expect(screen.getByRole('button', {name: 'Reset'})).toBeEnabled();
});

it('resets the input back to the saved value when Reset is clicked', async () => {
render(<ProjectSampling />, {organization});

const input = await waitForProjectRateInput();
await userEvent.clear(input);
await userEvent.type(input, '30');

await userEvent.click(screen.getByRole('button', {name: 'Reset'}));

expect(input).toHaveValue(50);
});

it('shows validation error for empty value on submit', async () => {
render(<ProjectSampling />, {organization});

const input = await waitForProjectRateInput();
await userEvent.clear(input);
await userEvent.click(screen.getByRole('button', {name: 'Apply Changes'}));

expect(await screen.findByText('Please enter a valid number')).toBeInTheDocument();
});

it('calls the API with the correct payload on save', async () => {
const putMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/sampling/project-rates/',
method: 'PUT',
body: [{id: 1, sampleRate: 0.3}],
});

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

const input = await waitForProjectRateInput();
await userEvent.clear(input);
await userEvent.type(input, '30');
await userEvent.click(screen.getByRole('button', {name: 'Apply Changes'}));

await waitFor(() => {
expect(putMock).toHaveBeenCalledWith(
'/organizations/org-slug/sampling/project-rates/',
expect.objectContaining({data: [{id: 1, sampleRate: 0.3}]})
);
});
});

it('resets form to clean state after a successful save', async () => {
MockApiClient.addMockResponse({
url: '/organizations/org-slug/sampling/project-rates/',
method: 'PUT',
body: [{id: 1, sampleRate: 0.3}],
});

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

const input = await waitForProjectRateInput();
await userEvent.clear(input);
await userEvent.type(input, '30');
await userEvent.click(screen.getByRole('button', {name: 'Apply Changes'}));

await waitFor(() =>
expect(screen.getByRole('button', {name: 'Reset'})).toBeDisabled()
);
});

it('keeps form dirty after an API error', async () => {
MockApiClient.addMockResponse({
url: '/organizations/org-slug/sampling/project-rates/',
method: 'PUT',
statusCode: 500,
body: {detail: 'Internal Server Error'},
});

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

const input = await waitForProjectRateInput();
await userEvent.clear(input);
await userEvent.type(input, '30');
await userEvent.click(screen.getByRole('button', {name: 'Apply Changes'}));

await waitFor(() =>
expect(screen.getByRole('button', {name: 'Reset'})).toBeEnabled()
);
});

it('updates project rates atomically via bulk org rate edit', async () => {
const putMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/sampling/project-rates/',
method: 'PUT',
body: [{id: 1, sampleRate: 0.8}],
});

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

await waitForProjectRateInput();

// Activate bulk edit mode
await userEvent.click(
screen.getByRole('button', {name: 'Proportionally scale project rates'})
);

// Type a new org rate — this should update all project rates in one atomic call
const orgRateInput = screen.getAllByRole('spinbutton')[0]!;
await userEvent.clear(orgRateInput);
await userEvent.type(orgRateInput, '80');

// The project rate should have been scaled
const projectInput = screen.getByRole('spinbutton', {
name: 'Sample rate for project-slug',
});
expect(projectInput).toHaveValue(80);

// Submit and verify the API call
await userEvent.click(screen.getByRole('button', {name: 'Apply Changes'}));

await waitFor(() => {
expect(putMock).toHaveBeenCalledWith(
'/organizations/org-slug/sampling/project-rates/',
expect.objectContaining({data: [{id: 1, sampleRate: 0.8}]})
);
});
});

it('disables Apply Changes for users without org:write access', async () => {
const orgWithoutAccess = OrganizationFixture({
access: [],
samplingMode: 'project',
});

render(<ProjectSampling />, {organization: orgWithoutAccess});

await waitForProjectRateInput();
expect(screen.getByRole('button', {name: 'Apply Changes'})).toBeDisabled();
});
});
Loading
Loading