diff --git a/src/pages/apps/[id]/environments/[env-id]/config/_components/RedirectsPlaygroundModal.spec.tsx b/src/pages/apps/[id]/environments/[env-id]/config/_components/RedirectsPlaygroundModal.spec.tsx
new file mode 100644
index 00000000..696e3fa2
--- /dev/null
+++ b/src/pages/apps/[id]/environments/[env-id]/config/_components/RedirectsPlaygroundModal.spec.tsx
@@ -0,0 +1,87 @@
+import { RenderResult, waitFor } from "@testing-library/react";
+import { fireEvent, render } from "@testing-library/react";
+import mockApp from "~/testing/data/mock_app";
+import mockEnvironments from "~/testing/data/mock_environments";
+import { mockPlayground } from "~/testing/nocks/nock_redirects_playground";
+import RedirectsPlaygroundModal from "./RedirectsPlaygroundModal";
+
+interface WrapperProps {
+ app?: App;
+ environment?: Environment;
+ setRefreshToken?: () => void;
+}
+
+jest.mock("@codemirror/lang-json", () => ({ json: jest.fn() }));
+jest.mock("@uiw/react-codemirror", () => ({ value }: { value: string }) => (
+ <>{value}>
+));
+
+describe("~/pages/apps/[id]/environments/[env-id]/config/_components/RedirectsPlaygroundModal.tsx", () => {
+ let wrapper: RenderResult;
+ let currentApp: App;
+ let currentEnv: Environment;
+ let closeSpy: jest.Func;
+
+ const createWrapper = ({ app, environment }: WrapperProps) => {
+ closeSpy = jest.fn();
+ currentApp = app || mockApp();
+ currentEnv = environment || mockEnvironments({ app: currentApp })[0];
+
+ wrapper = render(
+
+ );
+ };
+
+ test("should have a form", () => {
+ createWrapper({});
+
+ expect(wrapper.getByText("Redirects Playground")).toBeTruthy();
+
+ const requestUrlInput = wrapper.getByLabelText(
+ "Request URL"
+ ) as HTMLInputElement;
+
+ expect(requestUrlInput.value).toBe("https://app.stormkit.io");
+
+ expect(
+ wrapper.getByText("Provide a URL to test against the redirects.")
+ ).toBeTruthy();
+
+ expect(wrapper.getByText("docs").getAttribute("href")).toBe(
+ "https://stormkit.io/docs/features/redirects-and-path-rewrites"
+ );
+ });
+
+ test("should submit the form", async () => {
+ createWrapper({});
+
+ const scope = mockPlayground({
+ appId: currentApp.id,
+ envId: currentEnv.id!,
+ address: "https://app.stormkit.io",
+ redirects: [{ from: "/my-path", to: "/my-new-path", status: 302 }],
+ response: {
+ match: true,
+ proxy: false,
+ status: 302,
+ redirect: "https://app.stormkit.io/new-url",
+ rewrite: "",
+ },
+ });
+
+ fireEvent.submit(wrapper.getByTestId("redirects-playground-form"));
+
+ await waitFor(() => {
+ expect(scope.isDone()).toBe(true);
+ expect(wrapper.getByText("It's a match!"));
+ expect(wrapper.getByText("Redirect 302"));
+ expect(wrapper.getByText("https://app.stormkit.io/new-url"));
+ expect(wrapper.getByText("Should proxy?"));
+ expect(wrapper.getByText("No"));
+ });
+ });
+});
diff --git a/src/pages/apps/[id]/environments/[env-id]/config/_components/RedirectsPlaygroundModal.tsx b/src/pages/apps/[id]/environments/[env-id]/config/_components/RedirectsPlaygroundModal.tsx
new file mode 100644
index 00000000..62b8d6b9
--- /dev/null
+++ b/src/pages/apps/[id]/environments/[env-id]/config/_components/RedirectsPlaygroundModal.tsx
@@ -0,0 +1,198 @@
+import { FormEventHandler, useState } from "react";
+import CodeMirror from "@uiw/react-codemirror";
+import { json } from "@codemirror/lang-json";
+import Table from "@mui/material/Table";
+import TableRow from "@mui/material/TableRow";
+import TableCell from "@mui/material/TableCell";
+import TableBody from "@mui/material/TableBody";
+import Alert from "@mui/material/Alert";
+import AlertTitle from "@mui/material/AlertTitle";
+import Button from "@mui/lab/LoadingButton";
+import TextInput from "@mui/material/TextField";
+import Typography from "@mui/material/Typography";
+import Box from "@mui/material/Box";
+import Link from "@mui/material/Link";
+import Modal from "~/components/Modal";
+import Card from "~/components/Card";
+import CardHeader from "~/components/CardHeader";
+import CardFooter from "~/components/CardFooter";
+import api from "~/utils/api/Api";
+import { grey } from "@mui/material/colors";
+
+interface Props {
+ env: Environment;
+ appId: string;
+ onClose: () => void;
+}
+
+interface SubmitReturn {
+ match: boolean;
+ against: string;
+ pattern: string;
+ rewrite: string;
+ redirect: string;
+ proxy: boolean;
+ status: number;
+}
+
+const exampleRedirects = `[
+ { "from": "/my-path", "to": "/my-new-path", "status": 302 }
+]`;
+
+export default function RedirectsPlaygroundModal({
+ onClose,
+ appId,
+ env,
+}: Props) {
+ const [address, setAddress] = useState(env.preview);
+ const [redirects, setRedirects] = useState(exampleRedirects);
+ const [result, setResult] = useState();
+ const [error, setError] = useState();
+ const [loading, setLoading] = useState(false);
+
+ const submitHandler: FormEventHandler = e => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ let parsed: any;
+
+ try {
+ parsed = JSON.parse(redirects);
+ } catch {
+ setError("Invalid JSON format.");
+ return;
+ }
+
+ if (!address.trim()) {
+ setError("Request URL cannot be empty.");
+ return;
+ }
+
+ setLoading(true);
+
+ api
+ .post("/redirects/playground", {
+ appId: appId,
+ envId: env.id,
+ address,
+ redirects: parsed,
+ })
+ .then(match => {
+ setResult(match);
+ })
+ .finally(() => {
+ setError(undefined);
+ setLoading(false);
+ });
+ };
+
+ return (
+
+
+
+
+ setAddress(e.target.value)}
+ helperText={
+
+ Provide a URL to test against the redirects.
+
+ }
+ />
+
+
+ Redirects
+ setRedirects(value)}
+ theme="dark"
+ />
+
+ Check the{" "}
+
+ docs
+ {" "}
+ for more information.
+
+
+ {result &&
+ (result.match ? (
+ <>
+
+ It's a match!
+
+
+
+
+ Status
+
+ {result.status
+ ? `Redirect ${result.status}`
+ : "Path Rewrite"}
+
+
+
+ To
+ {result.redirect || result.rewrite}
+
+
+ Should proxy?
+ {result.proxy ? "Yes" : "No"}
+
+
+
+ >
+ ) : (
+
+ Not a match
+
+ Provided domain did not match any redirect rule.
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/apps/[id]/environments/[env-id]/config/_components/TabConfigRedirects.tsx b/src/pages/apps/[id]/environments/[env-id]/config/_components/TabConfigRedirects.tsx
index 59827351..0a85db44 100644
--- a/src/pages/apps/[id]/environments/[env-id]/config/_components/TabConfigRedirects.tsx
+++ b/src/pages/apps/[id]/environments/[env-id]/config/_components/TabConfigRedirects.tsx
@@ -1,3 +1,4 @@
+import { useState } from "react";
import Box from "@mui/material/Box";
import TextField from "@mui/material/TextField";
import Button from "@mui/lab/LoadingButton";
@@ -5,6 +6,7 @@ import Card from "~/components/Card";
import CardHeader from "~/components/CardHeader";
import CardFooter from "~/components/CardFooter";
import { useSubmitHandler } from "../actions";
+import RedirectsPlaygroundModal from "./RedirectsPlaygroundModal";
interface Props {
app: App;
@@ -17,6 +19,7 @@ export default function TabConfigGeneral({
app,
setRefreshToken,
}: Props) {
+ const [showModal, setShowModal] = useState(false);
const { submitHandler, error, success, isLoading } = useSubmitHandler({
app,
env,
@@ -39,6 +42,15 @@ export default function TabConfigGeneral({
setShowModal(true)}
+ >
+ Playground
+
+ }
/>
+ {showModal && (
+ {
+ setShowModal(false);
+ }}
+ />
+ )}
);
}
diff --git a/testing/nocks/nock_redirects_playground.ts b/testing/nocks/nock_redirects_playground.ts
new file mode 100644
index 00000000..34e171aa
--- /dev/null
+++ b/testing/nocks/nock_redirects_playground.ts
@@ -0,0 +1,30 @@
+import nock from "nock";
+
+const endpoint = process.env.API_DOMAIN || "";
+
+interface PlaygroundProps {
+ appId: string;
+ envId: string;
+ redirects: any;
+ address: string;
+ status?: number;
+ response: {
+ match: boolean;
+ status: number;
+ redirect: string;
+ rewrite: string;
+ proxy: boolean;
+ };
+}
+
+export const mockPlayground = ({
+ appId,
+ envId,
+ address,
+ redirects,
+ response,
+ status = 200,
+}: PlaygroundProps) =>
+ nock(endpoint)
+ .post("/redirects/playground", { appId, envId, redirects, address })
+ .reply(status, response);