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
Expand Up @@ -41,6 +41,10 @@ export function EditWebhookTriggerContent({
// Build URLs based on context
const routes = getWebhookRoutes(organizationId);

const { data: capabilities, isPending: isLoadingCapabilities } = useQuery(
trpc.webhookTriggers.capabilities.queryOptions({ organizationId })
);

// Fetch trigger configuration
const {
data: triggerData,
Expand Down Expand Up @@ -110,6 +114,7 @@ export function EditWebhookTriggerContent({
mode: (triggerData.mode ?? 'code') as AgentMode,
model: triggerData.model ?? '',
variant: triggerData.variant ?? undefined,
sandboxAllocation: triggerData.sandboxAllocation ?? undefined,
promptTemplate: triggerData.promptTemplate,
profileId: triggerData.profileId ?? undefined,
autoCommit: triggerData.autoCommit ?? undefined,
Expand Down Expand Up @@ -162,6 +167,7 @@ export function EditWebhookTriggerContent({
mode: formData.mode,
model: formData.model,
variant: formData.variant,
sandboxAllocation: formData.sandboxAllocation,
promptTemplate: formData.promptTemplate,
profileId: formData.profileId,
autoCommit: formData.autoCommit ?? null,
Expand Down Expand Up @@ -381,6 +387,8 @@ export function EditWebhookTriggerContent({
repositoriesError={repoError?.message}
models={modelOptions}
isLoadingModels={isLoadingModels}
canSetSandboxAllocation={capabilities?.canSetSandboxAllocation ?? false}
isLoadingCapabilities={isLoadingCapabilities}
onSubmit={handleSubmit}
onCancel={handleCancel}
onDelete={handleDelete}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export function CreateWebhookTriggerContent({ organizationId }: CreateWebhookTri
? `/organizations/${organizationId}/integrations`
: '/integrations';

// Fetch eligibility to check if user can create webhook triggers (requires credits)
const { data: capabilities, isPending: isLoadingCapabilities } = useQuery(
trpc.webhookTriggers.capabilities.queryOptions({ organizationId })
);

// Fetch GitHub repositories
const {
Expand Down Expand Up @@ -118,6 +120,7 @@ export function CreateWebhookTriggerContent({ organizationId }: CreateWebhookTri
mode: formData.mode,
model: formData.model,
variant: formData.variant ?? undefined,
sandboxAllocation: formData.sandboxAllocation ?? undefined,
promptTemplate: formData.promptTemplate,
profileId: formData.profileId,
autoCommit: formData.autoCommit,
Expand Down Expand Up @@ -203,6 +206,8 @@ export function CreateWebhookTriggerContent({ organizationId }: CreateWebhookTri
repositoriesError={repoError?.message}
models={modelOptions}
isLoadingModels={isLoadingModels}
canSetSandboxAllocation={capabilities?.canSetSandboxAllocation ?? false}
isLoadingCapabilities={isLoadingCapabilities}
onSubmit={handleSubmit}
onCancel={handleCancel}
isLoading={isCreatePending}
Expand Down
229 changes: 229 additions & 0 deletions apps/web/src/components/webhook-triggers/TriggerForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,74 @@ jest.mock('@/components/ui/label', () => ({
}));
jest.mock('@/components/ui/switch', () => ({ Switch: () => null }));
jest.mock('@/components/ui/checkbox', () => ({ Checkbox: () => null }));
type SelectInjectedProps = {
onValueChange?: (value: string) => void;
selectDisabled?: boolean;
};

jest.mock('@/components/ui/select', () => ({
Select: ({
children,
value,
onValueChange,
disabled,
}: {
children: React.ReactNode;
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
}) =>
createElement(
'div',
{ 'data-container-allocation': value, 'data-disabled': String(disabled) },
React.Children.map(children, child => {
if (!React.isValidElement<SelectInjectedProps>(child)) return child;
return React.cloneElement(child, { onValueChange, selectDisabled: disabled });
})
),
SelectTrigger: ({ children }: { children: React.ReactNode }) =>
createElement(React.Fragment, {}, children),
SelectValue: () => null,
SelectContent: ({
children,
onValueChange,
selectDisabled,
}: {
children: React.ReactNode;
onValueChange?: (value: string) => void;
selectDisabled?: boolean;
}) =>
createElement(
React.Fragment,
{},
React.Children.map(children, child => {
if (!React.isValidElement<SelectInjectedProps>(child)) return child;
return React.cloneElement(child, { onValueChange, selectDisabled });
})
),
SelectItem: ({
children,
value,
disabled,
onValueChange,
selectDisabled,
}: {
children: React.ReactNode;
value: string;
disabled?: boolean;
onValueChange?: (value: string) => void;
selectDisabled?: boolean;
}) =>
createElement(
'button',
{
type: 'button',
disabled: disabled || selectDisabled,
onClick: () => onValueChange?.(value),
},
children
),
}));
jest.mock('@/components/ui/inline-delete-confirmation', () => ({
InlineDeleteConfirmation: () => null,
}));
Expand Down Expand Up @@ -192,6 +260,21 @@ function expectSubmittedVariantToBeOmitted(onSubmit: jest.Mock<TriggerFormProps[
expect(Object.hasOwn(submission, 'variant')).toBe(false);
}

function selectContainerAllocation(
container: HTMLElement,
value: 'automatic' | 'isolated-standard'
) {
click(container, value === 'automatic' ? 'Automatic' : 'Dedicated Standard');
}

function expectSubmittedSandboxAllocationToBeOmitted(
onSubmit: jest.Mock<TriggerFormProps['onSubmit']>
) {
const [submission] = onSubmit.mock.calls.at(-1) ?? [];
if (!submission) throw new Error('form submission missing');
expect(Object.hasOwn(submission, 'sandboxAllocation')).toBe(false);
}

let TriggerForm!: typeof TriggerFormComponent;

beforeAll(async () => {
Expand Down Expand Up @@ -335,3 +418,149 @@ describe('TriggerForm variants', () => {
).toHaveProperty('disabled', true);
});
});

describe('TriggerForm container allocation', () => {
let root: Root | undefined;
let cleanup: (() => void) | undefined;

afterEach(() => {
if (root) act(() => root?.unmount());
root = undefined;
cleanup?.();
cleanup = undefined;
});

function render(props: Partial<TriggerFormProps>) {
const dom = installDom();
cleanup = dom.cleanup;
root = createRoot(dom.container);
const onSubmit = jest.fn<TriggerFormProps['onSubmit']>();
onSubmit.mockResolvedValue(undefined);
const allProps: TriggerFormProps = {
mode: 'edit',
initialData: initialData(),
repositories: [],
models,
onSubmit,
canSetSandboxAllocation: true,
...props,
};
act(() => root?.render(createElement(TriggerForm, allProps)));
return { container: dom.container, onSubmit, allProps };
}

it('omits Automatic and submits Dedicated Standard in create mode', async () => {
const mounted = render({ mode: 'create', initialData: initialData() });
submit(mounted.container);
await act(async () => Promise.resolve());
expectSubmittedSandboxAllocationToBeOmitted(mounted.onSubmit);

selectContainerAllocation(mounted.container, 'isolated-standard');
submit(mounted.container);
await act(async () => Promise.resolve());
expect(mounted.onSubmit).toHaveBeenLastCalledWith(
expect.objectContaining({ sandboxAllocation: 'isolated-standard' })
);
});

it('omits unchanged edit allocation and supports set, clear, and restoring the saved value', async () => {
const unset = render({ initialData: initialData() });
submit(unset.container);
await act(async () => Promise.resolve());
expectSubmittedSandboxAllocationToBeOmitted(unset.onSubmit);

selectContainerAllocation(unset.container, 'isolated-standard');
submit(unset.container);
await act(async () => Promise.resolve());
expect(unset.onSubmit).toHaveBeenLastCalledWith(
expect.objectContaining({ sandboxAllocation: 'isolated-standard' })
);

act(() => root?.unmount());
const saved = { ...initialData(), sandboxAllocation: 'isolated-standard' as const };
const mounted = render({ initialData: saved });
submit(mounted.container);
await act(async () => Promise.resolve());
expectSubmittedSandboxAllocationToBeOmitted(mounted.onSubmit);

selectContainerAllocation(mounted.container, 'automatic');
submit(mounted.container);
await act(async () => Promise.resolve());
expect(mounted.onSubmit).toHaveBeenLastCalledWith(
expect.objectContaining({ sandboxAllocation: null })
);

selectContainerAllocation(mounted.container, 'isolated-standard');
submit(mounted.container);
await act(async () => Promise.resolve());
expectSubmittedSandboxAllocationToBeOmitted(mounted.onSubmit);
});

it('hides fresh allocation from ineligible users but preserves a saved allocation and clearing it', async () => {
const fresh = render({ canSetSandboxAllocation: false });
expect(fresh.container.querySelector('[data-container-allocation]')).toBeNull();

act(() => root?.unmount());
const saved = render({
canSetSandboxAllocation: false,
initialData: { ...initialData(), sandboxAllocation: 'isolated-standard' },
});
expect(saved.container.querySelector('[data-container-allocation]')).not.toBeNull();
selectContainerAllocation(saved.container, 'automatic');
submit(saved.container);
await act(async () => Promise.resolve());
expect(saved.onSubmit).toHaveBeenLastCalledWith(
expect.objectContaining({ sandboxAllocation: null })
);
});

it('blocks a pending allocation selection after eligibility is revoked until Automatic is restored', async () => {
const mounted = render({ mode: 'create' });
selectContainerAllocation(mounted.container, 'isolated-standard');
act(() =>
root?.render(
createElement(TriggerForm, { ...mounted.allProps, canSetSandboxAllocation: false })
)
);
submit(mounted.container);
expect(mounted.onSubmit).not.toHaveBeenCalled();

expect(mounted.container.querySelector('[data-container-allocation]')).not.toBeNull();
selectContainerAllocation(mounted.container, 'automatic');
submit(mounted.container);
await act(async () => Promise.resolve());
expectSubmittedSandboxAllocationToBeOmitted(mounted.onSubmit);
});

it('blocks a new Dedicated Standard selection while capabilities are loading', () => {
const mounted = render({ mode: 'create', isLoadingCapabilities: true });
act(() =>
root?.render(
createElement(TriggerForm, { ...mounted.allProps, isLoadingCapabilities: false })
)
);
selectContainerAllocation(mounted.container, 'isolated-standard');
act(() =>
root?.render(createElement(TriggerForm, { ...mounted.allProps, isLoadingCapabilities: true }))
);
submit(mounted.container);
expect(mounted.onSubmit).not.toHaveBeenCalled();
});

it('resets from refreshed initial data and disables while capabilities load', () => {
const mounted = render({});
selectContainerAllocation(mounted.container, 'isolated-standard');
act(() =>
root?.render(
createElement(TriggerForm, {
...mounted.allProps,
initialData: { ...initialData(), sandboxAllocation: null },
isLoadingCapabilities: true,
})
)
);
const select = mounted.container.querySelector('[data-container-allocation]');
expect(select?.getAttribute('data-container-allocation')).toBe('automatic');
expect(select?.getAttribute('data-disabled')).toBe('true');
});
});
Loading