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
6 changes: 6 additions & 0 deletions .github/workflows/vitest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,11 @@ jobs:
- name: "Install dependencies"
run: yarn workspaces focus gcforms

- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium

- name: Vitest Tests
run: yarn test:vitest

- name: Vitest Browser Tests
run: yarn test:vitest:browser
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
"use client";

import Link from "next/link";
import { useTranslation } from "react-i18next";
import { useResponsesContext } from "./context/ResponsesContext";
import { useRouter } from "next/navigation";
import { useResponsesApp } from "./context";
import { disableResponsesPilotMode } from "../responses/actions";

export const ContentWrapper = ({ children }: { children: React.ReactNode }) => {
const { formId } = useResponsesContext();
const { t, i18n } = useTranslation(["form-builder-responses"]);
const router = useRouter();
const { t, i18n, router } = useResponsesApp();

const handleSwitchBack = async (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ export const Responses = ({ actions }: { actions?: React.ReactNode }) => {

if (newFormSubmissions === null) {
return (
<div className="mb-8 rounded-2xl border-2 border-gray-300 bg-white p-8">
<div
className="mb-8 rounded-2xl border-2 border-gray-300 bg-white p-8"
data-testid="responses-loading"
>
<div className="flex items-center justify-between">
<div className="w-2/3">
<Skeleton className="mb-6 h-10 w-3/4" />
Expand All @@ -24,9 +27,11 @@ export const Responses = ({ actions }: { actions?: React.ReactNode }) => {
}

return newFormSubmissions && newFormSubmissions.length > 0 ? (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between" data-testid="responses-available">
<div>
<h2 className="mb-8">{t("loadKeyPage.newResponsesAvailable")}</h2>
<h2 className="mb-8" data-testid="new-responses-heading">
{t("loadKeyPage.newResponsesAvailable")}
</h2>
{actions}
</div>
<div>
Expand All @@ -39,9 +44,11 @@ export const Responses = ({ actions }: { actions?: React.ReactNode }) => {
</div>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between" data-testid="no-responses">
<div>
<h2 className="mb-8">{t("loadKeyPage.noNewResponsesAvailable")}</h2>
<h2 className="mb-8" data-testid="no-responses-heading">
{t("loadKeyPage.noNewResponsesAvailable")}
</h2>
{actions}
</div>
<div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"use client";

import { createContext, useContext, ReactNode } from "react";
import { showOpenFilePicker } from "native-file-system-adapter";
import { getAccessTokenFromApiKey } from "../lib/utils";
import i18next from "i18next";
// Import to trigger i18next initialization
import "@root/i18n/client";

interface ResponsesAppContextType {
// Navigation
router: {
push: (href: string) => void;
replace: (href: string) => void;
back: () => void;
forward: () => void;
refresh: () => void;
prefetch: (href: string) => Promise<void>;
};
searchParams: URLSearchParams;

// i18n
t: (key: string, options?: Record<string, unknown>) => string;
i18n: {
language: string;
changeLanguage: (lang: string) => Promise<void>;
};

// File system
showOpenFilePicker: typeof showOpenFilePicker;

// API utilities
getAccessTokenFromApiKey: typeof getAccessTokenFromApiKey;

// Environment
apiUrl: string;
isDevelopment: boolean;
}

const BrowserResponsesAppContext = createContext<ResponsesAppContextType | null>(null);

interface BrowserResponsesAppProviderProps {
children: ReactNode;
overrides?: Partial<ResponsesAppContextType>;
}

/**
* Browser-only version of ResponsesAppProvider that uses real i18n.
* Use this for Vitest browser mode testing.
*/
export const BrowserResponsesAppProvider = ({
children,
overrides = {},
}: BrowserResponsesAppProviderProps) => {
// Create a translation function that uses response-api namespace
const t = (key: string, options?: Record<string, unknown>) => {
return i18next.t(key, { ...options, ns: "response-api" });
};

// Default mock implementations for browser testing
const defaultRouter = {
push: () => {},
replace: () => {},
back: () => {},
forward: () => {},
refresh: () => {},
prefetch: () => Promise.resolve(),
};

const defaultSearchParams = new URLSearchParams();

// Create i18n object
const wrappedI18n = {
language: i18next.language || "en",
changeLanguage: async (lang: string) => {
await i18next.changeLanguage(lang);
},
};

const value: ResponsesAppContextType = {
router: defaultRouter,
searchParams: defaultSearchParams,
t,
i18n: wrappedI18n,
showOpenFilePicker,
getAccessTokenFromApiKey,
apiUrl: "http://localhost:3000/api",
isDevelopment: true,
...overrides, // Allow custom overrides for specific test needs
};

return (
<BrowserResponsesAppContext.Provider value={value}>
{children}
</BrowserResponsesAppContext.Provider>
);
};

/**
* Hook to access the browser responses app context.
* Must be used within BrowserResponsesAppProvider.
*/
export const useBrowserResponsesApp = () => {
const context = useContext(BrowserResponsesAppContext);
if (!context) {
throw new Error("useBrowserResponsesApp must be used within BrowserResponsesAppProvider");
}
return context;
};

// Also export as useResponsesApp for compatibility with components expecting that name
export const useResponsesApp = useBrowserResponsesApp;
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"use client";

import { createContext, useContext, ReactNode } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "@i18n/client";
import { showOpenFilePicker } from "native-file-system-adapter";
import { getAccessTokenFromApiKey } from "../lib/utils";

interface ResponsesAppContextType {
// Navigation
router: ReturnType<typeof useRouter>;
searchParams: ReturnType<typeof useSearchParams>;

// i18n
t: ReturnType<typeof useTranslation>["t"];
i18n: ReturnType<typeof useTranslation>["i18n"];

// File system
showOpenFilePicker: typeof showOpenFilePicker;

// API utilities
getAccessTokenFromApiKey: typeof getAccessTokenFromApiKey;

// Environment
apiUrl: string;
isDevelopment: boolean;
}

const ResponsesAppContext = createContext<ResponsesAppContextType | null>(null);

interface ResponsesAppProviderProps {
children: ReactNode;
_locale: string; // Passed for future use, currently unused
namespace?: string;
// Optional overrides for testing
overrides?: Partial<ResponsesAppContextType>;
}

export const ResponsesAppProvider = ({
children,
_locale,
namespace = "response-api",
overrides = {},
}: ResponsesAppProviderProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t, i18n } = useTranslation(namespace);

const value: ResponsesAppContextType = {
router,
searchParams,
t,
i18n,
showOpenFilePicker,
getAccessTokenFromApiKey,
apiUrl: process.env.NEXT_PUBLIC_API_URL ?? "",
isDevelopment: process.env.NODE_ENV === "development",
...overrides, // Allow test overrides
};

return <ResponsesAppContext.Provider value={value}>{children}</ResponsesAppContext.Provider>;
};

export const useResponsesApp = () => {
const context = useContext(ResponsesAppContext);
if (!context) {
throw new Error("useResponsesApp must be used within ResponsesAppProvider");
}
return context;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Barrel export for ResponsesApp context
* In Vitest browser test environments, exports the Browser version
* In production, exports the real ResponsesAppProvider with Next.js hooks
*/

// Check if running in Vitest browser mode
const isBrowserTest = typeof process !== "undefined" && process.env.VITEST_BROWSER === "true";

// Import from both
import {
useResponsesApp as useResponsesAppProd,
ResponsesAppProvider as ProdProvider,
} from "./ResponsesAppProvider";
import {
useResponsesApp as useResponsesAppBrowser,
BrowserResponsesAppProvider,
} from "./BrowserResponsesAppProvider";

// Export the appropriate version based on environment
export const useResponsesApp = isBrowserTest ? useResponsesAppBrowser : useResponsesAppProd;
export const ResponsesAppProvider = isBrowserTest ? BrowserResponsesAppProvider : ProdProvider;
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { FeatureFlags } from "@lib/cache/types";
import { featureFlagAllowedForUser } from "@lib/userFeatureFlags";
import { redirect } from "next/navigation";
import { ResponsesProvider } from "./context/ResponsesContext";
import { ResponsesAppProvider } from "./context/ResponsesAppProvider";
import { ContentWrapper } from "./ContentWrapper";
import { PilotBadge } from "@clientComponents/globals/PilotBadge";
import { CompatibilityGuard } from "./guards/CompatibilityGuard";
Expand Down Expand Up @@ -43,12 +44,14 @@ export default async function ResponsesLayout(props: {
}

return (
<ResponsesProvider locale={locale} formId={id}>
<CompatibilityGuard>
<h1 className="mb-4">{t("section-title")}</h1>
<PilotBadge className="mb-8" />
<ContentWrapper>{props.children}</ContentWrapper>
</CompatibilityGuard>
</ResponsesProvider>
<ResponsesAppProvider _locale={locale}>
<ResponsesProvider locale={locale} formId={id}>
<CompatibilityGuard>
<h1 className="mb-4">{t("section-title")}</h1>
<PilotBadge className="mb-8" />
<ContentWrapper>{props.children}</ContentWrapper>
</CompatibilityGuard>
</ResponsesProvider>
</ResponsesAppProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const LoadKey = ({ onLoadKey }: LoadKeyProps) => {
<Button
theme="secondary"
className="mb-4"
data-testid="load-api-key-button"
onClick={async () => {
// null or undefined return means user aborted - do nothing
const result = await onLoadKey();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ export const LostKeyLink = () => {
const { t } = useTranslation("response-api");
return (
<p className="relative mt-2 block">
<button popoverTarget="api-key-popover" className="gc-lost-key-button">
<button
popoverTarget="api-key-popover"
className="gc-lost-key-button"
data-testid="lost-key-link"
>
<span className="underline">{t("loadKeyPage.lostKey.link")}</span>
</button>
</p>
Expand All @@ -29,8 +33,11 @@ export const LostKeyPopover = ({ locale, id }: { locale: string; id: string }) =
id="api-key-popover"
popover="auto"
className="gc-lost-key-popover-content rounded-xl border-1 border-gray-500 p-10"
data-testid="lost-key-popover"
>
<h2 className="mb-8">{t("loadKeyPage.lostKey.lostKeyTip.title")}</h2>
<h2 className="mb-8" data-testid="lost-key-popover-title">
{t("loadKeyPage.lostKey.lostKeyTip.title")}
</h2>
<p className="mb-2 font-bold">{t("loadKeyPage.lostKey.lostKeyTip.text1")}</p>
<p>
{t("loadKeyPage.lostKey.lostKeyTip.text2")}{" "}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { Button } from "@clientComponents/globals";
import { useRouter } from "next/navigation";

import { useTranslation } from "@i18n/client";
import { useResponsesApp } from "../context";
import { useResponsesContext } from "../context/ResponsesContext";
import { CheckForResponsesButton } from "../components/CheckForResponsesButton";

export const ResponseActionButtons = () => {
const { t } = useTranslation(["response-api", "common"]);
const router = useRouter();
const { t, router } = useResponsesApp();
const { apiClient, newFormSubmissions, resetState, locale, formId } = useResponsesContext();

const handleBack = () => {
Expand All @@ -22,7 +19,7 @@ export const ResponseActionButtons = () => {

return (
<div className="mt-8 flex flex-row gap-4">
<Button theme="secondary" onClick={handleBack}>
<Button theme="secondary" onClick={handleBack} data-testid="back-to-start-button">
{t("backToStart")}
</Button>

Expand All @@ -33,6 +30,7 @@ export const ResponseActionButtons = () => {
theme="primary"
disabled={Boolean(!apiClient || (newFormSubmissions && newFormSubmissions.length === 0))}
onClick={handleNext}
data-testid="continue-button"
>
{t("continueButton")}
</Button>
Expand Down
Loading
Loading