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
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import {OrganizationFixture} from 'sentry-fixture/organization';

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

import {OrganizationSampling} from 'sentry/views/settings/dynamicSampling/organizationSampling';

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('OrganizationSampling', () => {
const organization = OrganizationFixture({
slug: 'org-slug',
access: ['org:write'],
targetSampleRate: 0.5,
samplingMode: 'organization',
});

beforeEach(() => {
MockApiClient.clearMockResponses();
MockApiClient.addMockResponse({
url: '/organizations/org-slug/sampling/project-root-counts/',
body: {data: [], end: '', intervals: [], start: ''},
});
});

it('renders initial state with correct input value and button states', () => {
render(<OrganizationSampling />, {organization});

expect(screen.getByRole('spinbutton')).toHaveValue(50);
expect(screen.getByRole('button', {name: 'Apply Changes'})).toBeEnabled();
expect(screen.getByRole('button', {name: 'Reset'})).toBeDisabled();
});

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

await userEvent.clear(screen.getByRole('spinbutton'));
await userEvent.type(screen.getByRole('spinbutton'), '30');

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

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

expect(screen.getByRole('spinbutton')).toHaveValue(50);
});

it('does not call the API when value is out of range', async () => {
const putMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/',
method: 'PUT',
body: OrganizationFixture(),
});

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

await userEvent.clear(screen.getByRole('spinbutton'));
await userEvent.type(screen.getByRole('spinbutton'), '150');
await userEvent.click(screen.getByRole('button', {name: 'Apply Changes'}));

// jsdom doesn't support formNoValidate, so browser-native max={100}
// blocks the submit before Zod can run. The error message can't be
// asserted here — it works correctly in real browsers.
expect(putMock).not.toHaveBeenCalled();
});

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

await userEvent.clear(screen.getByRole('spinbutton'));
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/',
method: 'PUT',
body: OrganizationFixture({targetSampleRate: 0.3}),
});

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

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

await waitFor(() => {
expect(putMock).toHaveBeenCalledWith(
'/organizations/org-slug/',
expect.objectContaining({data: {targetSampleRate: 0.3}})
);
});
});

it('resets form to clean state after a successful save', async () => {
MockApiClient.addMockResponse({
url: '/organizations/org-slug/',
method: 'PUT',
body: OrganizationFixture({targetSampleRate: 0.3}),
});

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

await userEvent.clear(screen.getByRole('spinbutton'));
await userEvent.type(screen.getByRole('spinbutton'), '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/',
method: 'PUT',
statusCode: 500,
body: {detail: 'Internal Server Error'},
});

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

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

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

it('disables Apply Changes and input for users without org:write access', () => {
const orgWithoutAccess = OrganizationFixture({
access: [],
targetSampleRate: 0.5,
samplingMode: 'organization',
});

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

expect(screen.getByRole('button', {name: 'Apply Changes'})).toBeDisabled();
expect(screen.getByRole('spinbutton')).toBeDisabled();
});
});
183 changes: 109 additions & 74 deletions static/app/views/settings/dynamicSampling/organizationSampling.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {Fragment, useState} from 'react';
import styled from '@emotion/styled';
import {z} from 'zod';

import {Button} from '@sentry/scraps/button';
import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form';
import {Tooltip} from '@sentry/scraps/tooltip';

import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
Expand All @@ -13,104 +15,137 @@ import {ProjectionPeriodControl} from 'sentry/views/settings/dynamicSampling/pro
import {ProjectsPreviewTable} from 'sentry/views/settings/dynamicSampling/projectsPreviewTable';
import {SamplingModeSwitch} from 'sentry/views/settings/dynamicSampling/samplingModeSwitch';
import {useHasDynamicSamplingWriteAccess} from 'sentry/views/settings/dynamicSampling/utils/access';
import {organizationSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/organizationSamplingForm';
import {parsePercent} from 'sentry/views/settings/dynamicSampling/utils/parsePercent';
import {
useProjectSampleCounts,
type ProjectionSamplePeriod,
} from 'sentry/views/settings/dynamicSampling/utils/useProjectSampleCounts';
import {useUpdateOrganization} from 'sentry/views/settings/dynamicSampling/utils/useUpdateOrganization';

const {useFormState, FormProvider} = organizationSamplingForm;
const UNSAVED_CHANGES_MESSAGE = t(
'You have unsaved changes, are you sure you want to leave?'
);

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%')}
),
});

export function OrganizationSampling() {
const organization = useOrganization();
const hasAccess = useHasDynamicSamplingWriteAccess();
const [period, setPeriod] = useState<ProjectionSamplePeriod>('24h');

const formState = useFormState({
initialValues: {
targetSampleRate: ((organization.targetSampleRate ?? 1) * 100)?.toString(),
},
});

const sampleCountsQuery = useProjectSampleCounts({period});
const initialTargetSampleRate = (
(organization.targetSampleRate ?? 1) * 100
)?.toString();
const [savedTargetSampleRate, setSavedTargetSampleRate] = useState(
initialTargetSampleRate
);

const {mutate: updateOrganization, isPending} = useUpdateOrganization();
const {mutateAsync: updateOrganization, isPending} = useUpdateOrganization();

const handleSubmit = () => {
updateOrganization(
{
targetSampleRate: parsePercent(formState.fields.targetSampleRate.value),
},
{
onSuccess: () => {
addSuccessMessage(t('Changes applied.'));
formState.save();
},
onError: () => {
addErrorMessage(t('Unable to save changes. Please try again.'));
},
const form = useScrapsForm({
...defaultFormOptions,
defaultValues: {
targetSampleRate: initialTargetSampleRate,
},
validators: {
onDynamic: targetSampleRateSchema,
},
onSubmit: async ({value, formApi}) => {
try {
await updateOrganization({
targetSampleRate: parsePercent(value.targetSampleRate),
});
addSuccessMessage(t('Changes applied.'));
setSavedTargetSampleRate(value.targetSampleRate);
formApi.reset(value);
} catch {
addErrorMessage(t('Unable to save changes. Please try again.'));
}
);
};
},
});

const handleReset = () => {
formState.reset();
};
const sampleCountsQuery = useProjectSampleCounts({period});

return (
<FormProvider formState={formState}>
<OnRouteLeave
message={UNSAVED_CHANGES_MESSAGE}
when={locationChange =>
locationChange.currentLocation.pathname !==
locationChange.nextLocation.pathname && formState.hasChanged
}
/>
<HeadingRow>
<ProjectionPeriodControl period={period} onChange={setPeriod} />
<SamplingModeSwitch />
</HeadingRow>
{sampleCountsQuery.isError ? (
<LoadingError onRetry={sampleCountsQuery.refetch} />
) : (
<ProjectsPreviewTable
sampleCounts={sampleCountsQuery.data}
isLoading={sampleCountsQuery.isPending}
period={period}
actions={
<Fragment>
<Button disabled={!formState.hasChanged || isPending} onClick={handleReset}>
{t('Reset')}
</Button>
<Tooltip
disabled={hasAccess}
title={t('You do not have permission to update these settings.')}
>
<Button
priority="primary"
disabled={
!hasAccess || !formState.isValid || !formState.hasChanged || isPending
}
onClick={handleSubmit}
>
{t('Save changes')}
</Button>
</Tooltip>
</Fragment>
}
/>
)}
<SubTextParagraph>
{t('Inactive projects are not listed and will be sampled at 100% initially.')}
</SubTextParagraph>
</FormProvider>
<form.AppForm form={form}>
<form.Subscribe selector={s => ({isDirty: s.isDirty, canSubmit: s.canSubmit})}>
{({isDirty, canSubmit}) => (
<Fragment>
<OnRouteLeave
message={UNSAVED_CHANGES_MESSAGE}
when={locationChange =>
locationChange.currentLocation.pathname !==
locationChange.nextLocation.pathname && isDirty
}
/>
<HeadingRow>
<ProjectionPeriodControl period={period} onChange={setPeriod} />
<SamplingModeSwitch />
</HeadingRow>
{sampleCountsQuery.isError ? (
<LoadingError onRetry={sampleCountsQuery.refetch} />
) : (
<form.AppField name="targetSampleRate">
{field => (
<ProjectsPreviewTable
sampleCounts={sampleCountsQuery.data}
isLoading={sampleCountsQuery.isPending}
period={period}
targetSampleRate={field.state.value}
savedTargetSampleRate={savedTargetSampleRate}
onTargetSampleRateChange={field.handleChange}
targetSampleRateError={field.state.meta.errors[0]?.message}
actions={
<Fragment>
<Button
disabled={!isDirty || isPending}
onClick={() => form.reset()}
>
{t('Reset')}
</Button>
<Tooltip
disabled={hasAccess}
title={t(
'You do not have permission to update these settings.'
)}
>
<form.SubmitButton
disabled={!hasAccess || !canSubmit}
formNoValidate
>
{t('Apply Changes')}
</form.SubmitButton>
</Tooltip>
</Fragment>
}
/>
)}
</form.AppField>
)}
<SubTextParagraph>
{t(
'Inactive projects are not listed and will be sampled at 100% initially.'
)}
</SubTextParagraph>
</Fragment>
)}
</form.Subscribe>
</form.AppForm>
);
}

const HeadingRow = styled('div')`
display: flex;
align-items: center;
Expand Down
2 changes: 1 addition & 1 deletion static/app/views/settings/dynamicSampling/percentInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function PercentInput({ref, ...props}: InputProps) {
width: 120px;
`}
>
<InputGroup.Input ref={ref} type="number" min={0} max={100} {...props} />
<InputGroup.Input ref={ref} type="number" step="any" min={0} max={100} {...props} />
<InputGroup.TrailingItems>
<TrailingPercent>%</TrailingPercent>
</InputGroup.TrailingItems>
Expand Down
Loading
Loading