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 <noreply@anthropic.com>
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
91
packages/core-consent/src/react/consent-provider.test.tsx
Normal file
91
packages/core-consent/src/react/consent-provider.test.tsx
Normal file
@@ -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 <ConsentProvider value={consent}>{children}</ConsentProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
31
packages/core-consent/src/react/consent-provider.tsx
Normal file
31
packages/core-consent/src/react/consent-provider.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { IConsent } from "../consent.interface";
|
||||
|
||||
const ConsentContext = createContext<IConsent | null>(null);
|
||||
|
||||
export class ConsentContextError extends Error {
|
||||
constructor() {
|
||||
super("useConsent() must be called within a <ConsentProvider>.");
|
||||
this.name = "ConsentContextError";
|
||||
}
|
||||
}
|
||||
|
||||
export function ConsentProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: IConsent;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ConsentContext.Provider value={value}>{children}</ConsentContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConsent(): IConsent {
|
||||
const consent = useContext(ConsentContext);
|
||||
if (consent === null) {
|
||||
throw new ConsentContextError();
|
||||
}
|
||||
return consent;
|
||||
}
|
||||
5
packages/core-consent/src/react/index.ts
Normal file
5
packages/core-consent/src/react/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
ConsentProvider,
|
||||
useConsent,
|
||||
ConsentContextError,
|
||||
} from "./consent-provider";
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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") },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user