),
+ useQuery: (opts: { queryFn?: () => unknown; enabled?: boolean }) => {
+ if (opts.enabled !== false && typeof opts.queryFn === 'function') {
+ try {
+ opts.queryFn();
+ } catch {
+ /* expected in mock */
+ }
+ }
+ return mockUseQuery();
+ },
+}));
+
+jest.mock('@/hooks/useCustomToast', () => ({
+ __esModule: true,
+ default: () => mockToast,
+}));
+
+jest.mock('@/hooks/useErrorToast', () => ({
+ useAPIErrorsToast: () => mockApiErrorToast,
+}));
+
+jest.mock('@/components/Loader', () => ({
+ __esModule: true,
+ default: () => Loading...
,
+}));
+
+jest.mock('../ScheduleForm', () => ({
+ __esModule: true,
+ default: () => ScheduleForm
,
+}));
+
+jest.mock('../../SyncForm/ConfigureSyncs/SelectStreams', () => ({
+ __esModule: true,
+ default: ({ onChange }: { onChange: (stream: { name: string }) => void }) => (
+
+
+
+ ),
+}));
+
+jest.mock('../../SyncForm/ConfigureSyncs/MapFields', () => ({
+ __esModule: true,
+ default: ({ handleOnConfigChange }: { handleOnConfigChange: (config: unknown[]) => void }) => (
+
+
+
+ ),
+}));
+
+jest.mock('../../SyncForm/ConfigureSyncs/MapCustomFields', () => ({
+ __esModule: true,
+ default: () => MapCustomFields
,
+}));
+
+jest.mock('@/components/FormFooter', () => ({
+ __esModule: true,
+ default: ({
+ ctaName,
+ extra,
+ isCtaDisabled,
+ }: {
+ ctaName: string;
+ extra?: React.ReactNode;
+ isCtaDisabled?: boolean;
+ }) => (
+
+ {ctaName}
+ {extra}
+
+ ),
+}));
+
+jest.mock('@/components/BaseButton', () => ({
+ __esModule: true,
+ default: ({ text, onClick }: { text: string; onClick: () => void }) => (
+
+ ),
+}));
+
+const mockedUseStore = useStore as jest.MockedFunction;
+
+const renderComponent = () => {
+ const queryClient = createQueryClient();
+ return render(
+
+
+
+
+
+
+ ,
+ );
+};
+
+describe('EditSync', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockStoreImplementation(mockedUseStore, { workspaceId: 1 });
+ // Mock useQuery to return destination data
+ mockUseQuery.mockReturnValue({
+ data: { data: { attributes: { name: 'Test Destination', id: '2' } } },
+ isLoading: false,
+ isError: false,
+ });
+ mockUseGetSyncById.mockReturnValue({
+ data: { data: { attributes: mockSyncData.attributes } },
+ isLoading: false,
+ isError: false,
+ });
+ mockUseEditSync.mockReturnValue({
+ handleSubmit: jest.fn(),
+ selectedSyncMode: 'full_refresh',
+ setSelectedSyncMode: jest.fn(),
+ cursorField: '',
+ setCursorField: jest.fn(),
+ });
+ mockUseManualSync.mockReturnValue({
+ isSubmitting: false,
+ runSyncNow: jest.fn(),
+ showCancelSync: false,
+ setShowCancelSync: jest.fn(),
+ });
+ mockUseSyncRuns.mockReturnValue({
+ data: { data: [] },
+ });
+ mockUseCatalogQueries.mockReturnValue({
+ catalogData: {
+ data: {
+ attributes: {
+ catalog: {
+ streams: [{ name: 'stream1' }],
+ },
+ },
+ },
+ },
+ handleRefreshCatalog: jest.fn(),
+ isRefreshingCatalog: false,
+ });
+ (
+ connectorsService.getConnectorInfo as jest.MockedFunction<
+ typeof connectorsService.getConnectorInfo
+ >
+ ).mockResolvedValue({
+ data: { id: '2' },
+ } as ConnectorInfoResponse);
+ });
+
+ it('renders loader when loading', () => {
+ mockUseGetSyncById.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ isError: false,
+ });
+ renderComponent();
+ expect(screen.getByTestId('loader')).toBeInTheDocument();
+ });
+
+ it('shows error toast on error', () => {
+ mockUseGetSyncById.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ });
+ renderComponent();
+ expect(mockToast).toHaveBeenCalled();
+ });
+
+ it('triggers syncFetchResponse effect with array configuration', () => {
+ mockUseQuery.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: false,
+ });
+ mockUseGetSyncById.mockReturnValue({
+ data: {
+ data: {
+ attributes: {
+ ...mockSyncData.attributes,
+ configuration: [{ from: 'a', to: 'b', mapping_type: 'standard' }],
+ },
+ },
+ },
+ isLoading: false,
+ isError: false,
+ });
+ renderComponent();
+ expect(screen.getByTestId('form-footer')).toBeInTheDocument();
+ });
+
+ it('triggers syncFetchResponse effect with object configuration', () => {
+ mockUseQuery.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: false,
+ });
+ mockUseGetSyncById.mockReturnValue({
+ data: {
+ data: {
+ attributes: {
+ ...mockSyncData.attributes,
+ configuration: { field1: 'value1', field2: 'value2' },
+ },
+ },
+ },
+ isLoading: false,
+ isError: false,
+ });
+ renderComponent();
+ expect(screen.getByTestId('form-footer')).toBeInTheDocument();
+ });
+
+ it('shows API errors toast when catalog has errors', () => {
+ mockUseQuery.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: false,
+ });
+ mockUseCatalogQueries.mockReturnValue({
+ catalogData: {
+ errors: [{ detail: 'Catalog error' }],
+ },
+ handleRefreshCatalog: jest.fn(),
+ isRefreshingCatalog: false,
+ });
+ renderComponent();
+ expect(mockApiErrorToast).toHaveBeenCalled();
+ });
+
+ it('loads streams from catalog data', () => {
+ mockUseQuery.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: false,
+ });
+ mockUseCatalogQueries.mockReturnValue({
+ catalogData: {
+ data: {
+ attributes: {
+ catalog: {
+ streams: [{ name: 'test_stream' }],
+ },
+ },
+ },
+ },
+ handleRefreshCatalog: jest.fn(),
+ isRefreshingCatalog: false,
+ });
+ renderComponent();
+ expect(screen.getByTestId('form-footer')).toBeInTheDocument();
+ });
+
+ it('sets showCancelSync when latest sync run is in progress', () => {
+ const mockSetShowCancelSync = jest.fn();
+ mockUseManualSync.mockReturnValue({
+ isSubmitting: false,
+ runSyncNow: jest.fn(),
+ showCancelSync: false,
+ setShowCancelSync: mockSetShowCancelSync,
+ });
+ mockUseSyncRuns.mockReturnValue({
+ data: {
+ data: [
+ {
+ attributes: { status: 'in_progress' },
+ },
+ ],
+ },
+ });
+ mockUseQuery.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: false,
+ });
+ renderComponent();
+ expect(mockSetShowCancelSync).toHaveBeenCalledWith(true);
+ });
+
+ it('does not set showCancelSync for completed sync runs', () => {
+ const mockSetShowCancelSync = jest.fn();
+ mockUseManualSync.mockReturnValue({
+ isSubmitting: false,
+ runSyncNow: jest.fn(),
+ showCancelSync: false,
+ setShowCancelSync: mockSetShowCancelSync,
+ });
+ mockUseSyncRuns.mockReturnValue({
+ data: {
+ data: [
+ {
+ attributes: { status: 'success' },
+ },
+ ],
+ },
+ });
+ mockUseQuery.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: false,
+ });
+ renderComponent();
+ expect(mockSetShowCancelSync).not.toHaveBeenCalled();
+ });
+
+ it('renders full component with sync data and destination', () => {
+ renderComponent();
+ expect(screen.getByTestId('select-streams')).toBeInTheDocument();
+ expect(screen.getByTestId('map-fields')).toBeInTheDocument();
+ expect(screen.getByTestId('schedule-form')).toBeInTheDocument();
+ });
+
+ it('renders MapCustomFields for schemaless mode', () => {
+ mockUseCatalogQueries.mockReturnValue({
+ catalogData: {
+ data: {
+ attributes: {
+ catalog: {
+ streams: [{ name: 'stream1' }],
+ schema_mode: 'schemaless',
+ },
+ },
+ },
+ },
+ handleRefreshCatalog: jest.fn(),
+ isRefreshingCatalog: false,
+ });
+ renderComponent();
+ expect(screen.getByTestId('map-custom-fields')).toBeInTheDocument();
+ });
+
+ it('renders manual schedule with Run Now button', () => {
+ mockUseGetSyncById.mockReturnValue({
+ data: {
+ data: {
+ attributes: {
+ ...mockSyncData.attributes,
+ schedule_type: 'manual',
+ sync_interval: 0,
+ sync_interval_unit: 'minutes',
+ },
+ },
+ },
+ isLoading: false,
+ isError: false,
+ });
+ renderComponent();
+ expect(screen.getByText('Run Now')).toBeInTheDocument();
+ });
+
+ it('renders Cancel Run button when showCancelSync is true', () => {
+ mockUseManualSync.mockReturnValue({
+ isSubmitting: false,
+ runSyncNow: jest.fn(),
+ showCancelSync: true,
+ setShowCancelSync: jest.fn(),
+ });
+ mockUseGetSyncById.mockReturnValue({
+ data: {
+ data: {
+ attributes: {
+ ...mockSyncData.attributes,
+ schedule_type: 'manual',
+ },
+ },
+ },
+ isLoading: false,
+ isError: false,
+ });
+ renderComponent();
+ expect(screen.getByText('Cancel Run')).toBeInTheDocument();
+ });
+
+ it('calls runSyncNow when Run Now button is clicked', async () => {
+ const mockRunSyncNow = jest.fn<() => Promise>().mockResolvedValue(undefined);
+ mockUseManualSync.mockReturnValue({
+ isSubmitting: false,
+ runSyncNow: mockRunSyncNow,
+ showCancelSync: false,
+ setShowCancelSync: jest.fn(),
+ });
+ mockUseGetSyncById.mockReturnValue({
+ data: {
+ data: {
+ attributes: {
+ ...mockSyncData.attributes,
+ schedule_type: 'manual',
+ },
+ },
+ },
+ isLoading: false,
+ isError: false,
+ });
+ renderComponent();
+ fireEvent.click(screen.getByText('Run Now'));
+ await waitFor(() => {
+ expect(mockRunSyncNow).toHaveBeenCalledWith('post');
+ });
+ });
+
+ it('calls handleOnConfigChange via MapFields Update Config button', () => {
+ renderComponent();
+ const updateButton = screen.getByText('Update Config');
+ fireEvent.click(updateButton);
+ expect(screen.getByTestId('map-fields')).toBeInTheDocument();
+ });
+
+ it('handles streams loaded from catalog with matching stream_name', () => {
+ mockUseCatalogQueries.mockReturnValue({
+ catalogData: {
+ data: {
+ attributes: {
+ catalog: {
+ streams: [{ name: mockSyncData.attributes.stream_name }],
+ },
+ },
+ },
+ },
+ handleRefreshCatalog: jest.fn(),
+ isRefreshingCatalog: false,
+ });
+ renderComponent();
+ expect(screen.getByTestId('select-streams')).toBeInTheDocument();
+ });
+
+ it('disables save when vector field has incomplete embedding config', () => {
+ mockUseGetSyncById.mockReturnValue({
+ data: {
+ data: {
+ attributes: {
+ ...mockSyncData.attributes,
+ configuration: [
+ {
+ from: 'a',
+ to: 'b',
+ mapping_type: 'standard',
+ field_type: 'vector',
+ hide_embedding: false,
+ embedding_config: { api_key: '', mode: '', model: '' },
+ },
+ ],
+ },
+ },
+ },
+ isLoading: false,
+ isError: false,
+ });
+ renderComponent();
+ expect(screen.getByTestId('form-footer')).toHaveAttribute('data-disabled', 'true');
+ });
+
+ it('does not disable save when vector field has hide_embedding set', () => {
+ mockUseGetSyncById.mockReturnValue({
+ data: {
+ data: {
+ attributes: {
+ ...mockSyncData.attributes,
+ configuration: [
+ {
+ from: 'a',
+ to: 'b',
+ mapping_type: 'standard',
+ field_type: 'vector',
+ hide_embedding: true,
+ },
+ ],
+ },
+ },
+ },
+ isLoading: false,
+ isError: false,
+ });
+ renderComponent();
+ expect(screen.getByTestId('form-footer')).not.toHaveAttribute('data-disabled', 'true');
+ });
+
+ it('uses fallback values when sync data attributes are null', () => {
+ mockUseGetSyncById.mockReturnValue({
+ data: {
+ data: {
+ attributes: {
+ ...mockSyncData.attributes,
+ sync_interval: null,
+ sync_interval_unit: null,
+ sync_mode: null,
+ schedule_type: null,
+ cron_expression: null,
+ configuration: [],
+ },
+ },
+ },
+ isLoading: false,
+ isError: false,
+ });
+ renderComponent();
+ expect(screen.getByTestId('form-footer')).toBeInTheDocument();
+ });
+
+ it('handles null cursor_field in sync data', () => {
+ mockUseGetSyncById.mockReturnValue({
+ data: {
+ data: {
+ attributes: {
+ ...mockSyncData.attributes,
+ cursor_field: null,
+ },
+ },
+ },
+ isLoading: false,
+ isError: false,
+ });
+ renderComponent();
+ expect(screen.getByTestId('form-footer')).toBeInTheDocument();
+ });
+
+ it('handles workspace ID of 0', () => {
+ mockStoreImplementation(mockedUseStore, { workspaceId: 0 });
+ renderComponent();
+ expect(screen.getByTestId('form-footer')).toBeInTheDocument();
+ });
+
+ it('shows loader when connector info is loading', () => {
+ mockUseQuery.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ isError: false,
+ });
+ renderComponent();
+ expect(screen.getByTestId('loader')).toBeInTheDocument();
+ });
+
+ it('handles null configuration with object fallback', () => {
+ mockUseGetSyncById.mockReturnValue({
+ data: {
+ data: {
+ attributes: {
+ ...mockSyncData.attributes,
+ configuration: null,
+ },
+ },
+ },
+ isLoading: false,
+ isError: false,
+ });
+ renderComponent();
+ expect(screen.getByTestId('form-footer')).toBeInTheDocument();
+ });
+
+ it('handles null syncList data', () => {
+ mockUseSyncRuns.mockReturnValue({
+ data: null,
+ });
+ renderComponent();
+ expect(screen.getByTestId('form-footer')).toBeInTheDocument();
+ });
+});
diff --git a/ui/src/views/Activate/Syncs/SyncForm/FinaliseSync/SyncScheduleOptionsContainer/ScheduleTypeSelector.tsx b/ui/src/views/Activate/Syncs/SyncForm/FinaliseSync/SyncScheduleOptionsContainer/ScheduleTypeSelector.tsx
new file mode 100644
index 000000000..40ac16242
--- /dev/null
+++ b/ui/src/views/Activate/Syncs/SyncForm/FinaliseSync/SyncScheduleOptionsContainer/ScheduleTypeSelector.tsx
@@ -0,0 +1,69 @@
+import { Box, Radio, RadioGroup, Stack, Text } from '@chakra-ui/react';
+import { FormikProps } from 'formik';
+
+type ScheduleTypeSelectorProps = {
+ formik: FormikProps;
+};
+
+const RenderRadio = ({
+ value,
+ label,
+ description,
+}: {
+ value: string;
+ label: string;
+ description: string;
+}) => (
+
+
+
+ {label}
+
+
+ {description}
+
+
+
+);
+
+const ScheduleTypeSelector = ({ formik }: ScheduleTypeSelectorProps) => (
+
+
+ Schedule type
+
+
+
+
+
+
+
+
+
+);
+
+export default ScheduleTypeSelector;