From 9b6deac954646b90370c06d2545ae484693df311 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 19 May 2026 19:19:47 +0000 Subject: [PATCH] feat(core-consent): add ConsentProvider + useConsent() React subpath Adds the ./react subpath to @repo/core-consent following the same pattern as @repo/core-analytics/react: - ConsentProvider wraps IConsent in React context - useConsent() returns the injected IConsent or throws ConsentContextError - RTL tests cover context propagation, grant/withdraw delegation, isGranted state reflection, getCategories, and missing-provider error - package.json: ./react export + React optional peerDep + RTL devDeps - tsconfig: extend react-library.json, include .tsx - vitest.config: jsdom environment for .test.tsx + jsdom setup Co-Authored-By: Claude Sonnet 4.6 --- coverage/summary.json | 44 ++++----- packages/core-consent/package.json | 13 ++- .../src/react/consent-provider.test.tsx | 91 +++++++++++++++++++ .../src/react/consent-provider.tsx | 31 +++++++ packages/core-consent/src/react/index.ts | 5 + packages/core-consent/tsconfig.json | 4 +- packages/core-consent/vitest.config.ts | 20 ++-- pnpm-lock.yaml | 12 +++ 8 files changed, 188 insertions(+), 32 deletions(-) create mode 100644 packages/core-consent/src/react/consent-provider.test.tsx create mode 100644 packages/core-consent/src/react/consent-provider.tsx create mode 100644 packages/core-consent/src/react/index.ts diff --git a/coverage/summary.json b/coverage/summary.json index b266de8..19f9e25 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,18 +1,18 @@ { - "generatedAt": "2026-05-19T13:18:49.389Z", - "commit": "ae4e0f2", + "generatedAt": "2026-05-19T19:19:27.597Z", + "commit": "33e3c09", "repo": { - "statements": 96.72, - "branches": 92.06, - "functions": 96.83, - "lines": 96.72, + "statements": 96.74, + "branches": 92.11, + "functions": 96.86, + "lines": 96.74, "counts": { - "lf": 4607, - "lh": 4456, - "brf": 907, - "brh": 835, - "fnf": 284, - "fnh": 275 + "lf": 4632, + "lh": 4481, + "brf": 912, + "brh": 840, + "fnf": 287, + "fnh": 278 } }, "byPackage": { @@ -59,17 +59,17 @@ } }, "@repo/core-consent": { - "statements": 99.69, - "branches": 94.57, - "functions": 96.77, - "lines": 99.69, + "statements": 99.72, + "branches": 94.85, + "functions": 97.06, + "lines": 99.72, "counts": { - "lf": 326, - "lh": 325, - "brf": 92, - "brh": 87, - "fnf": 31, - "fnh": 30 + "lf": 351, + "lh": 350, + "brf": 97, + "brh": 92, + "fnf": 34, + "fnh": 33 } }, "@repo/core-shared": { diff --git a/packages/core-consent/package.json b/packages/core-consent/package.json index f499c80..3be1938 100644 --- a/packages/core-consent/package.json +++ b/packages/core-consent/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./di": "./src/di/bind-production.ts" + "./di": "./src/di/bind-production.ts", + "./react": "./src/react/index.ts" }, "scripts": { "build": "tsc --noEmit", @@ -19,19 +20,27 @@ "zod": "^3.24.0" }, "peerDependencies": { - "payload": "^3.0.0" + "payload": "^3.0.0", + "react": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "payload": { "optional": true + }, + "react": { + "optional": true } }, "devDependencies": { "@repo/core-eslint": "workspace:*", "@repo/core-testing": "workspace:*", "@repo/core-typescript": "workspace:*", + "@testing-library/react": "^16.0.0", + "@types/react": "^19.0.0", "@vitest/coverage-v8": "^3.0.0", + "jsdom": "^25.0.0", "payload": "^3.14.0", + "react": "^19.0.0", "typescript": "^5.8.0", "vitest": "^3.0.0" } diff --git a/packages/core-consent/src/react/consent-provider.test.tsx b/packages/core-consent/src/react/consent-provider.test.tsx new file mode 100644 index 0000000..b6bd374 --- /dev/null +++ b/packages/core-consent/src/react/consent-provider.test.tsx @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { RecordingConsent } from "@repo/core-testing"; +import { + ConsentContextError, + ConsentProvider, + useConsent, +} from "@/react/index"; + +function makeWrapper(consent: RecordingConsent) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + +describe("ConsentProvider / useConsent", () => { + it("returns the injected IConsent instance", () => { + const recording = new RecordingConsent(); + const { result } = renderHook(() => useConsent(), { + wrapper: makeWrapper(recording), + }); + expect(result.current).toBe(recording); + }); + + it("propagates grant() to the injected IConsent", async () => { + const recording = new RecordingConsent(); + const { result } = renderHook(() => useConsent(), { + wrapper: makeWrapper(recording), + }); + + await act(async () => { + await result.current.grant("analytics"); + }); + + expect(recording.grants).toContainEqual({ + category: "analytics", + meta: undefined, + }); + }); + + it("propagates withdraw() to the injected IConsent", async () => { + const recording = new RecordingConsent(); + await recording.grant("marketing"); + + const { result } = renderHook(() => useConsent(), { + wrapper: makeWrapper(recording), + }); + + await act(async () => { + await result.current.withdraw("marketing"); + }); + + expect(recording.withdrawals).toContain("marketing"); + }); + + it("isGranted() reflects the injected instance state", async () => { + const recording = new RecordingConsent(); + await recording.grant("functional"); + + const { result } = renderHook(() => useConsent(), { + wrapper: makeWrapper(recording), + }); + + expect(result.current.isGranted("functional")).toBe(true); + expect(result.current.isGranted("marketing")).toBe(false); + }); + + it("getCategories() returns categories from the injected IConsent", async () => { + const recording = new RecordingConsent(); + await recording.grant("necessary"); + + const { result } = renderHook(() => useConsent(), { + wrapper: makeWrapper(recording), + }); + + const cats = result.current.getCategories(); + expect(cats).toHaveLength(1); + expect(cats[0]).toMatchObject({ category: "necessary", state: "granted" }); + }); +}); + +describe("useConsent without provider", () => { + it("throws ConsentContextError when called outside a provider", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect(() => renderHook(() => useConsent())).toThrow(ConsentContextError); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/packages/core-consent/src/react/consent-provider.tsx b/packages/core-consent/src/react/consent-provider.tsx new file mode 100644 index 0000000..bc09e52 --- /dev/null +++ b/packages/core-consent/src/react/consent-provider.tsx @@ -0,0 +1,31 @@ +import { createContext, useContext, type ReactNode } from "react"; +import type { IConsent } from "../consent.interface"; + +const ConsentContext = createContext(null); + +export class ConsentContextError extends Error { + constructor() { + super("useConsent() must be called within a ."); + this.name = "ConsentContextError"; + } +} + +export function ConsentProvider({ + value, + children, +}: { + value: IConsent; + children: ReactNode; +}) { + return ( + {children} + ); +} + +export function useConsent(): IConsent { + const consent = useContext(ConsentContext); + if (consent === null) { + throw new ConsentContextError(); + } + return consent; +} diff --git a/packages/core-consent/src/react/index.ts b/packages/core-consent/src/react/index.ts new file mode 100644 index 0000000..39aa1d4 --- /dev/null +++ b/packages/core-consent/src/react/index.ts @@ -0,0 +1,5 @@ +export { + ConsentProvider, + useConsent, + ConsentContextError, +} from "./consent-provider"; diff --git a/packages/core-consent/tsconfig.json b/packages/core-consent/tsconfig.json index 8facef5..bfb5a39 100644 --- a/packages/core-consent/tsconfig.json +++ b/packages/core-consent/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@repo/core-typescript/base.json", + "extends": "@repo/core-typescript/react-library.json", "compilerOptions": { "outDir": "dist", "rootDir": ".", @@ -7,6 +7,6 @@ "@/*": ["./src/*"] } }, - "include": ["**/*.ts"], + "include": ["**/*.ts", "**/*.tsx"], "exclude": ["node_modules", "dist"] } diff --git a/packages/core-consent/vitest.config.ts b/packages/core-consent/vitest.config.ts index 2ee07c1..79a7ee6 100644 --- a/packages/core-consent/vitest.config.ts +++ b/packages/core-consent/vitest.config.ts @@ -1,9 +1,17 @@ import path from "node:path"; -import { mergeConfig } from "vitest/config"; +import { defineConfig, mergeConfig } from "vitest/config"; import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node"; -export default mergeConfig(nodeVitestConfig, { - resolve: { - alias: { "@": path.resolve(__dirname, "./src") }, - }, -}); +export default mergeConfig( + nodeVitestConfig, + defineConfig({ + test: { + include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"], + environmentMatchGlobs: [["**/*.test.tsx", "jsdom"]], + setupFiles: ["@repo/core-testing/setup/jsdom"], + }, + resolve: { + alias: { "@": path.resolve(__dirname, "./src") }, + }, + }), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c74a26..4e7e811 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -608,12 +608,24 @@ importers: "@repo/core-typescript": specifier: workspace:* version: link:../core-typescript + "@testing-library/react": + specifier: ^16.0.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + "@types/react": + specifier: ^19.0.0 + version: 19.2.14 "@vitest/coverage-v8": specifier: ^3.0.0 version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) + jsdom: + specifier: ^25.0.0 + version: 25.0.1 payload: specifier: ^3.14.0 version: 3.81.0(graphql@16.13.2)(typescript@5.9.3) + react: + specifier: ^19.0.0 + version: 19.2.4 typescript: specifier: ^5.8.0 version: 5.9.3