Skip to content
Draft
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,193 @@
import React, { useState } from "react";

import {
Button,
ChakraBox as Box,
ChakraCode as Code,
ChakraSpinner as Spinner,
ChakraText as Text,
Tabs,
} from "fidesui";

import FormModal from "~/features/common/modals/FormModal";
import { useGetDatastoreConnectionByKeyQuery } from "~/features/datastore-connections";

import {
useGetConnectorTemplateVersionConfigQuery,
useGetConnectorTemplateVersionDatasetQuery,
} from "./connector-template.slice";

interface SaaSVersionContentProps {
connectorType: string;
version: string;
}

const SaaSVersionContent = ({
connectorType,
version,
}: SaaSVersionContentProps) => {
const {
data: configYaml,
isLoading: configLoading,
isError: configError,
} = useGetConnectorTemplateVersionConfigQuery({ connectorType, version });

const {
data: datasetYaml,
isLoading: datasetLoading,
isError: datasetError,
} = useGetConnectorTemplateVersionDatasetQuery({ connectorType, version });

if (configLoading) {
return (
<Box display="flex" justifyContent="center" py={8}>
<Spinner />
</Box>
);
}

if (configError) {
return (
<Text color="red.500" fontSize="sm">
Could not load version config.
</Text>
);
}

const tabItems = [
{
key: "config",
label: "Config",
children: (
<Code
display="block"
whiteSpace="pre"
overflowX="auto"
fontSize="xs"
p={3}
borderRadius="md"
backgroundColor="gray.50"
maxH="60vh"
overflowY="auto"
>
{configYaml}
</Code>
),
},
{
key: "dataset",
label: "Dataset",
children: datasetLoading ? (
<Box display="flex" justifyContent="center" py={4}>
<Spinner size="sm" />
</Box>
) : datasetError ? (
<Text color="gray.500" fontSize="sm">
No dataset available for this version.
</Text>
) : (
<Code
display="block"
whiteSpace="pre"
overflowX="auto"
fontSize="xs"
p={3}
borderRadius="md"
backgroundColor="gray.50"
maxH="60vh"
overflowY="auto"
>
{datasetYaml}
</Code>
),
},
];

return <Tabs items={tabItems} />;
};

interface SaaSVersionModalProps {
isOpen: boolean;
onClose: () => void;
connectorType: string;
version: string;
}

const SaaSVersionModal = ({
isOpen,
onClose,
connectorType,
version,
}: SaaSVersionModalProps) => (
<FormModal
title={`${connectorType} — v${version}`}
isOpen={isOpen}
onClose={onClose}
showCloseButton
size="3xl"
modalContentProps={{ maxW: "800px" }}
footer={
<Button onClick={onClose} data-testid="version-modal-close-btn">
Close
</Button>
}
>
<SaaSVersionContent connectorType={connectorType} version={version} />
</FormModal>
);

interface PendingModalState {
connectionKey: string;
version: string;
}

interface ActiveModalState {
connectorType: string;
version: string;
}

/**
* Hook providing a version detail modal keyed by connection key + version string.
* Resolves connector_type via the connection config before opening the modal.
*/
export const useSaaSVersionModal = () => {
const [pending, setPending] = useState<PendingModalState | null>(null);
const [active, setActive] = useState<ActiveModalState | null>(null);

const { data: connection } = useGetDatastoreConnectionByKeyQuery(
pending?.connectionKey ?? "",
{ skip: !pending?.connectionKey },
);

// Once the connection resolves, promote pending to active so the modal opens.
// connectorType is captured into active so the modal doesn't depend on the
// query after pending is cleared (skip: true returns undefined data).
// If the connection has no saas_config.type (non-SaaS), bail out silently
// so pending doesn't stay set indefinitely.
React.useEffect(() => {
if (!pending || !connection) return;
if (connection.saas_config?.type) {
setActive({ connectorType: connection.saas_config.type, version: pending.version });
}
setPending(null);
}, [pending, connection]);

const openVersionModal = (connectionKey: string, version: string) => {
setPending({ connectionKey, version });
};

const handleClose = () => setActive(null);

const modal = active ? (
<SaaSVersionModal
isOpen
onClose={handleClose}
connectorType={active.connectorType}
version={active.version}
/>
) : null;

return { openVersionModal, modal };
};

export default SaaSVersionModal;
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// Mock ESM-only packages — must be before imports (Jest hoists these)
jest.mock("query-string", () => ({
__esModule: true,
default: { stringify: jest.fn(), parse: jest.fn() },
}));
jest.mock("react-dnd", () => ({
useDrag: jest.fn(() => [{}, jest.fn()]),
useDrop: jest.fn(() => [{}, jest.fn()]),
DndProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// eslint-disable-next-line global-require
jest.mock("nuqs", () => require("../../../../__tests__/utils/nuqs-mock").nuqsMock);

// RTK Query hook mocks
jest.mock("~/features/connector-templates/connector-template.slice", () => ({
useGetConnectorTemplateVersionConfigQuery: jest.fn(),
useGetConnectorTemplateVersionDatasetQuery: jest.fn(),
}));

jest.mock("~/features/datastore-connections", () => ({
useGetDatastoreConnectionByKeyQuery: jest.fn(),
// Store imports datastoreConnectionSlice from this module — provide a minimal stub
datastoreConnectionSlice: {
name: "datastoreConnection",
reducer: (state = {}) => state,
},
}));

import { fireEvent, screen, waitFor } from "@testing-library/react";
import React from "react";

import { render } from "~/../__tests__/utils/test-utils";
import SaaSVersionModal, {
useSaaSVersionModal,
} from "~/features/connector-templates/SaaSVersionModal";
import {
useGetConnectorTemplateVersionConfigQuery,
useGetConnectorTemplateVersionDatasetQuery,
} from "~/features/connector-templates/connector-template.slice";
import { useGetDatastoreConnectionByKeyQuery } from "~/features/datastore-connections";

// ── Typed mocks ────────────────────────────────────────────────────────────────

const mockUseConfig = useGetConnectorTemplateVersionConfigQuery as jest.Mock;
const mockUseDataset = useGetConnectorTemplateVersionDatasetQuery as jest.Mock;
const mockUseConnection = useGetDatastoreConnectionByKeyQuery as jest.Mock;

// ── Fixtures ───────────────────────────────────────────────────────────────────

const STRIPE_CONFIG_YAML = `connector_type: stripe\nversion: "0.0.11"\n`;
const STRIPE_DATASET_YAML = `dataset:\n - name: stripe_dataset\n`;

function setupDefaultMocks() {
mockUseConfig.mockReturnValue({
data: STRIPE_CONFIG_YAML,
isLoading: false,
isError: false,
});
mockUseDataset.mockReturnValue({
data: STRIPE_DATASET_YAML,
isLoading: false,
isError: false,
});
mockUseConnection.mockReturnValue({ data: null });
}

// ── SaaSVersionModal (direct usage) ───────────────────────────────────────────

describe("SaaSVersionModal", () => {
beforeEach(setupDefaultMocks);

it("shows a loading spinner while config is fetching", () => {
mockUseConfig.mockReturnValue({ data: undefined, isLoading: true, isError: false });

render(
<SaaSVersionModal isOpen onClose={jest.fn()} connectorType="stripe" version="0.0.11" />,
);

// Chakra Spinner renders as a div with class chakra-spinner (no ARIA role in JSDOM)
expect(document.querySelector(".chakra-spinner")).toBeInTheDocument();
});

it("renders the modal title with connector type and version", () => {
render(
<SaaSVersionModal isOpen onClose={jest.fn()} connectorType="stripe" version="0.0.11" />,
);

expect(screen.getByText("stripe — v0.0.11")).toBeInTheDocument();
});

it("calls the config query with the correct connector type and version", () => {
render(
<SaaSVersionModal isOpen onClose={jest.fn()} connectorType="stripe" version="0.0.11" />,
);

expect(mockUseConfig).toHaveBeenCalledWith({ connectorType: "stripe", version: "0.0.11" });
});

it("calls the dataset query with the correct connector type and version", () => {
render(
<SaaSVersionModal isOpen onClose={jest.fn()} connectorType="stripe" version="0.0.11" />,
);

expect(mockUseDataset).toHaveBeenCalledWith({ connectorType: "stripe", version: "0.0.11" });
});

it("shows 'No dataset available' in the Dataset tab when the dataset endpoint errors", () => {
mockUseDataset.mockReturnValue({ data: undefined, isLoading: false, isError: true });

render(
<SaaSVersionModal isOpen onClose={jest.fn()} connectorType="stripe" version="0.0.11" />,
);

// Activate the Dataset tab, then assert the fallback message
fireEvent.click(screen.getByText("Dataset"));
expect(screen.getByText("No dataset available for this version.")).toBeInTheDocument();
});

it("shows an error message when config fails to load", () => {
mockUseConfig.mockReturnValue({ data: undefined, isLoading: false, isError: true });

render(
<SaaSVersionModal isOpen onClose={jest.fn()} connectorType="stripe" version="0.0.11" />,
);

expect(screen.getByText("Could not load version config.")).toBeInTheDocument();
});

it("calls onClose when the Close button is clicked", () => {
const onClose = jest.fn();

render(
<SaaSVersionModal isOpen onClose={onClose} connectorType="stripe" version="0.0.11" />,
);

fireEvent.click(screen.getByTestId("version-modal-close-btn"));
expect(onClose).toHaveBeenCalledTimes(1);
});

it("does not render when isOpen is false", () => {
render(
<SaaSVersionModal isOpen={false} onClose={jest.fn()} connectorType="stripe" version="0.0.11" />,
);

expect(screen.queryByText("stripe — v0.0.11")).not.toBeInTheDocument();
});
});

// ── useSaaSVersionModal hook ───────────────────────────────────────────────────

const HookConsumer = ({
connectionKey,
version,
}: {
connectionKey: string;
version: string;
}) => {
const { openVersionModal, modal } = useSaaSVersionModal();
return (
<>
{modal}
<button
type="button"
data-testid="trigger"
onClick={() => openVersionModal(connectionKey, version)}
>
Open
</button>
</>
);
};

describe("useSaaSVersionModal", () => {
beforeEach(setupDefaultMocks);

it("opens the modal once the connection resolves a connector type", async () => {
mockUseConnection.mockReturnValue({
data: { saas_config: { type: "stripe" } },
});

render(<HookConsumer connectionKey="stripe_conn" version="0.0.11" />);

fireEvent.click(screen.getByTestId("trigger"));

await waitFor(() => {
expect(screen.getByText("stripe — v0.0.11")).toBeInTheDocument();
});
});

it("does not open if the connection has no saas_config type", async () => {
mockUseConnection.mockReturnValue({ data: { saas_config: null } });

render(<HookConsumer connectionKey="plain_conn" version="0.0.11" />);

fireEvent.click(screen.getByTestId("trigger"));

await waitFor(() => {
expect(screen.queryByText(/— v0\.0\.11/)).not.toBeInTheDocument();
});
});
});
Loading