diff --git a/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModule.kt b/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModule.kt index ef2857a..59b0099 100644 --- a/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModule.kt +++ b/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModule.kt @@ -64,7 +64,7 @@ internal class RNUsercentricsModule( val bannerSettings = options?.bannerSettingsFromMap(context) val activity = reactContextProvider.activity()!! - usercentricsProxy.showSecondLayer(activity, bannerSettings, promise) + usercentricsProxy.showSecondLayer(activity, bannerSettings, options.secondLayerTab(), promise) } catch (e: Exception) { promise.reject(e) } diff --git a/android/src/main/java/com/usercentrics/reactnative/api/UsercentricsProxy.kt b/android/src/main/java/com/usercentrics/reactnative/api/UsercentricsProxy.kt index 1f21f54..7e9f9e1 100644 --- a/android/src/main/java/com/usercentrics/reactnative/api/UsercentricsProxy.kt +++ b/android/src/main/java/com/usercentrics/reactnative/api/UsercentricsProxy.kt @@ -6,6 +6,7 @@ import com.facebook.react.bridge.Promise import com.usercentrics.reactnative.extensions.toWritableMap import com.usercentrics.sdk.* import com.usercentrics.sdk.errors.UsercentricsError +import com.usercentrics.sdk.models.settings.SecondLayerTab interface UsercentricsProxy { @@ -21,7 +22,7 @@ interface UsercentricsProxy { onSubscribeClicked: (String?) -> Unit, promise: Promise, ) - fun showSecondLayer(activity: Activity, bannerSettings: BannerSettings?, promise: Promise) + fun showSecondLayer(activity: Activity, bannerSettings: BannerSettings?, tab: SecondLayerTab?, promise: Promise) } internal class UsercentricsProxyImpl : UsercentricsProxy { @@ -59,9 +60,10 @@ internal class UsercentricsProxyImpl : UsercentricsProxy { override fun showSecondLayer( activity: Activity, bannerSettings: BannerSettings?, + tab: SecondLayerTab?, promise: Promise, ) { - UsercentricsBanner(activity, bannerSettings).showSecondLayer { + UsercentricsBanner(activity, bannerSettings).showSecondLayer(tab) { promise.resolve(it?.toWritableMap()) } } diff --git a/android/src/main/java/com/usercentrics/reactnative/extensions/SecondLayerTabExtensions.kt b/android/src/main/java/com/usercentrics/reactnative/extensions/SecondLayerTabExtensions.kt new file mode 100644 index 0000000..e45ba8d --- /dev/null +++ b/android/src/main/java/com/usercentrics/reactnative/extensions/SecondLayerTabExtensions.kt @@ -0,0 +1,15 @@ +package com.usercentrics.reactnative.extensions + +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import com.usercentrics.sdk.models.settings.SecondLayerTab + +// The tab travels inside the options map. A missing, null, non-string or unknown value falls back +// to the default tab, like a tab the layout does not have. +internal fun ReadableMap?.secondLayerTab(): SecondLayerTab? { + if (this == null || !hasKey("tab") || getType("tab") != ReadableType.String) { + return null + } + val value = getString("tab") ?: return null + return runCatching { SecondLayerTab.valueOf(value) }.getOrNull() +} diff --git a/android/src/test/java/com/usercentrics/reactnative/extensions/SecondLayerTabExtensionsTest.kt b/android/src/test/java/com/usercentrics/reactnative/extensions/SecondLayerTabExtensionsTest.kt new file mode 100644 index 0000000..72d106e --- /dev/null +++ b/android/src/test/java/com/usercentrics/reactnative/extensions/SecondLayerTabExtensionsTest.kt @@ -0,0 +1,51 @@ +package com.usercentrics.reactnative.extensions + +import com.facebook.react.bridge.JavaOnlyMap +import com.usercentrics.sdk.models.settings.SecondLayerTab +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SecondLayerTabExtensionsTest { + + @Test + fun `secondLayerTab maps SERVICES and PARTNERS`() { + assertEquals(SecondLayerTab.SERVICES, JavaOnlyMap().apply { putString("tab", "SERVICES") }.secondLayerTab()) + assertEquals(SecondLayerTab.PARTNERS, JavaOnlyMap().apply { putString("tab", "PARTNERS") }.secondLayerTab()) + } + + @Test + fun `secondLayerTab keeps the banner settings keys independent of the tab`() { + val map = JavaOnlyMap().apply { + putString("variant", "A") + putString("tab", "PARTNERS") + } + + assertEquals(SecondLayerTab.PARTNERS, map.secondLayerTab()) + } + + @Test + fun `secondLayerTab falls back to the default tab for a null map`() { + val map: JavaOnlyMap? = null + + assertNull(map.secondLayerTab()) + } + + @Test + fun `secondLayerTab falls back to the default tab for a missing key`() { + assertNull(JavaOnlyMap().secondLayerTab()) + } + + @Test + fun `secondLayerTab falls back to the default tab for a null value`() { + assertNull(JavaOnlyMap().apply { putNull("tab") }.secondLayerTab()) + } + + @Test + fun `secondLayerTab falls back to the default tab for an unknown or non-string value`() { + assertNull(JavaOnlyMap().apply { putString("tab", "UNKNOWN") }.secondLayerTab()) + assertNull(JavaOnlyMap().apply { putString("tab", "services") }.secondLayerTab()) + assertNull(JavaOnlyMap().apply { putInt("tab", 1) }.secondLayerTab()) + assertNull(JavaOnlyMap().apply { putBoolean("tab", true) }.secondLayerTab()) + } +} diff --git a/ios/Extensions/SecondLayerTab+Dict.swift b/ios/Extensions/SecondLayerTab+Dict.swift new file mode 100644 index 0000000..9977dce --- /dev/null +++ b/ios/Extensions/SecondLayerTab+Dict.swift @@ -0,0 +1,16 @@ +import Foundation +import Usercentrics + +extension SecondLayerTab { + // An unknown or missing value falls back to the default tab, like a tab the layout does not have. + static func from(enumString: String?) -> SecondLayerTab? { + switch enumString { + case "SERVICES": + return SecondLayerTab.services + case "PARTNERS": + return SecondLayerTab.partners + default: + return nil + } + } +} diff --git a/ios/Manager/UsercentricsManager.swift b/ios/Manager/UsercentricsManager.swift index b18cdca..9d766fa 100644 --- a/ios/Manager/UsercentricsManager.swift +++ b/ios/Manager/UsercentricsManager.swift @@ -18,6 +18,7 @@ public protocol UsercentricsManager { func notifySubscriptionLapsed(onSuccess: @escaping (() -> Void), onError: @escaping ((Error) -> Void)) func showSecondLayer(bannerSettings: BannerSettings?, + tab: SecondLayerTab?, dismissViewHandler: @escaping (UsercentricsConsentUserResponse) -> Void) func getControllerId() -> String @@ -87,8 +88,9 @@ final class UsercentricsManagerImplementation: UsercentricsManager { } func showSecondLayer(bannerSettings: BannerSettings?, + tab: SecondLayerTab?, dismissViewHandler: @escaping (UsercentricsConsentUserResponse) -> Void) { - UsercentricsBanner(bannerSettings: bannerSettings).showSecondLayer(completionHandler: dismissViewHandler) + UsercentricsBanner(bannerSettings: bannerSettings).showSecondLayer(tab: tab, completionHandler: dismissViewHandler) } func restoreUserSession(controllerId: String, onSuccess: @escaping ((UsercentricsReadyStatus) -> Void), onFailure: @escaping ((Error) -> Void)) { diff --git a/ios/RNUsercentricsModule.swift b/ios/RNUsercentricsModule.swift index f341d1d..83bdf9e 100644 --- a/ios/RNUsercentricsModule.swift +++ b/ios/RNUsercentricsModule.swift @@ -98,7 +98,8 @@ class RNUsercentricsModule: RCTEventEmitter { return } - self.usercentricsManager.showSecondLayer(bannerSettings: BannerSettings(from: dict)) { response in + self.usercentricsManager.showSecondLayer(bannerSettings: BannerSettings(from: dict), + tab: SecondLayerTab.from(enumString: dict["tab"] as? String)) { response in resolve(response.toDictionary()) } } diff --git a/sample/ios/sampleTests/Fake/FakeUsercentricsManager.swift b/sample/ios/sampleTests/Fake/FakeUsercentricsManager.swift index 0f5d251..7b61664 100644 --- a/sample/ios/sampleTests/Fake/FakeUsercentricsManager.swift +++ b/sample/ios/sampleTests/Fake/FakeUsercentricsManager.swift @@ -234,8 +234,10 @@ final class FakeUsercentricsManager: UsercentricsManager { } var showSecondLayerBannerSettings: BannerSettings? - func showSecondLayer(bannerSettings: BannerSettings?, dismissViewHandler: @escaping (UsercentricsConsentUserResponse) -> Void) { + var showSecondLayerTab: SecondLayerTab? + func showSecondLayer(bannerSettings: BannerSettings?, tab: SecondLayerTab?, dismissViewHandler: @escaping (UsercentricsConsentUserResponse) -> Void) { self.showSecondLayerBannerSettings = bannerSettings + self.showSecondLayerTab = tab dismissViewHandler(UsercentricsConsentUserResponse(consents: [], controllerId: "", userInteraction: .acceptAll)) } diff --git a/sample/ios/sampleTests/RNUsercentricsModuleTests.swift b/sample/ios/sampleTests/RNUsercentricsModuleTests.swift index 687178e..8c93597 100644 --- a/sample/ios/sampleTests/RNUsercentricsModuleTests.swift +++ b/sample/ios/sampleTests/RNUsercentricsModuleTests.swift @@ -516,11 +516,45 @@ class RNUsercentricsModuleTests: XCTestCase { module.showSecondLayer(emptyDictionary) { result in XCTAssertNil(self.fakeUsercentrics.showSecondLayerBannerSettings?.secondLayerStyleSettings) + XCTAssertNil(self.fakeUsercentrics.showSecondLayerTab) } reject: { _, _, _ in XCTFail("Should not go here") } } + func testShowSecondLayerWithTab() { + let cases: [(String, SecondLayerTab)] = [ + ("SERVICES", SecondLayerTab.services), + ("PARTNERS", SecondLayerTab.partners), + ] + for (value, expectedTab) in cases { + var resolved = false + module.showSecondLayer(["tab": value]) { _ in + resolved = true + } reject: { _, _, _ in + XCTFail("Should not go here") + } + + XCTAssertTrue(resolved, "tab \(value)") + XCTAssertEqual(expectedTab, self.fakeUsercentrics.showSecondLayerTab, "tab \(value)") + } + } + + func testShowSecondLayerWithUnknownTabFallsBackToDefault() { + let values: [Any] = ["UNKNOWN", "services", 1, NSNull()] + for value in values { + var resolved = false + module.showSecondLayer(["tab": value]) { _ in + resolved = true + } reject: { _, _, _ in + XCTFail("Should not go here") + } + + XCTAssertTrue(resolved, "tab \(value)") + XCTAssertNil(self.fakeUsercentrics.showSecondLayerTab, "tab \(value)") + } + } + func testTrack() { module.track(1) XCTAssertEqual(1, fakeUsercentrics.trackCalls.count) diff --git a/src/NativeUsercentrics.ts b/src/NativeUsercentrics.ts index ff4e7bc..6903ea9 100644 --- a/src/NativeUsercentrics.ts +++ b/src/NativeUsercentrics.ts @@ -14,6 +14,7 @@ import type { TCFUserDecisions, TCFUserDecisionOnPurpose, TCFUserDecisionOnVendor, + SecondLayerTab, } from './models'; export interface Spec extends TurboModule { @@ -23,7 +24,7 @@ export interface Spec extends TurboModule { // UI Methods showFirstLayer(options?: BannerSettings): Promise; - showSecondLayer(options?: BannerSettings): Promise; + showSecondLayer(options?: BannerSettings & { tab?: SecondLayerTab }): Promise; // Session Management restoreUserSession(controllerId: string): Promise; diff --git a/src/Usercentrics.tsx b/src/Usercentrics.tsx index ffe5837..8941bf5 100644 --- a/src/Usercentrics.tsx +++ b/src/Usercentrics.tsx @@ -5,6 +5,7 @@ import { CCPAData, GppData, GppSectionChangePayload, + SecondLayerTab, TCFData, TCFDecisionUILayer, TCFUserDecisionOnPurpose, @@ -40,9 +41,15 @@ export const Usercentrics = { return RNUsercentricsModule.showFirstLayer(options); }, - showSecondLayer: async (options?: BannerSettings): Promise => { + /** + * Shows the second layer, optionally opened directly on [tab]: `SecondLayerTab.services` for + * GDPR/CCPA layouts, `SecondLayerTab.partners` for TCF layouts. A tab the layout does not have, + * or no tab, opens the default tab. + */ + showSecondLayer: async (options?: BannerSettings, tab?: SecondLayerTab): Promise => { await RNUsercentricsModule.isReady(); - return RNUsercentricsModule.showSecondLayer(options); + // The tab travels inside the options map, so the native module signatures stay unchanged. + return RNUsercentricsModule.showSecondLayer(tab ? {...options, tab} : options); }, restoreUserSession: async (controllerId: string): Promise => { diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index be46961..884da48 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -4,6 +4,7 @@ import { BannerSettings, NetworkMode, PurposeListStyle, + SecondLayerTab, TCFData, TCFDecisionUILayer, UsercentricsConsentType, @@ -41,6 +42,7 @@ jest.mock("react-native", () => { isReady: jest.fn(), showCMP: jest.fn(), showFirstLayer: jest.fn(), + showSecondLayer: jest.fn(), restoreUserSession: jest.fn(), getControllerId: jest.fn(), getABTestingVariant: jest.fn(), @@ -272,6 +274,74 @@ describe('Test Usercentrics Module', () => { expect(call.initCustomization).toBe(initCustomization); }) + test('testShowSecondLayerWithoutTabPassesOptionsUnchanged', async () => { + const response = new UsercentricsConsentUserResponse( + "abc", + UsercentricsUserInteraction.acceptAll, + [] + ) + + RNUsercentricsModule.showSecondLayer.mockImplementationOnce( + (): Promise => Promise.resolve(response) + ) + + const bannerSettings: BannerSettings = { + secondLayerStyleSettings: { + showCloseButton: true + } + } + + const data = await Usercentrics.showSecondLayer(bannerSettings) + expect(data).toBe(response); + + const calls = RNUsercentricsModule.showSecondLayer.mock.calls; + const call = calls[calls.length - 1][0]; + expect(call).toBe(bannerSettings); + expect(call.tab).toBeUndefined(); + }) + + test('testShowSecondLayerWithTab', async () => { + const response = new UsercentricsConsentUserResponse( + "abc", + UsercentricsUserInteraction.acceptAll, + [] + ) + + const bannerSettings: BannerSettings = { + secondLayerStyleSettings: { + showCloseButton: true + } + } + + for (const [tab, expected] of [ + [SecondLayerTab.services, "SERVICES"], + [SecondLayerTab.partners, "PARTNERS"] + ] as const) { + RNUsercentricsModule.showSecondLayer.mockImplementationOnce( + (): Promise => Promise.resolve(response) + ) + + const data = await Usercentrics.showSecondLayer(bannerSettings, tab) + expect(data).toBe(response); + + const calls = RNUsercentricsModule.showSecondLayer.mock.calls; + expect(calls[calls.length - 1][0]).toEqual({...bannerSettings, tab: expected}); + } + }) + + test('testShowSecondLayerWithTabAndNoOptions', async () => { + RNUsercentricsModule.showSecondLayer.mockImplementationOnce( + (): Promise => Promise.resolve( + new UsercentricsConsentUserResponse("abc", UsercentricsUserInteraction.acceptAll, []) + ) + ) + + await Usercentrics.showSecondLayer(undefined, SecondLayerTab.partners) + + const calls = RNUsercentricsModule.showSecondLayer.mock.calls; + expect(calls[calls.length - 1][0]).toEqual({tab: "PARTNERS"}); + }) + test('testRestoreUserSession', async () => { const readyStatus = new UsercentricsReadyStatus( true, diff --git a/src/models/SecondLayerTab.tsx b/src/models/SecondLayerTab.tsx new file mode 100644 index 0000000..f319fb4 --- /dev/null +++ b/src/models/SecondLayerTab.tsx @@ -0,0 +1,13 @@ +/** + * A tab of the second layer that can be opened directly. + * + * - `services`: the Services tab of GDPR and CCPA layouts. + * - `partners`: the Vendors tab of TCF layouts. + * + * A tab the current layout does not have, or no tab at all, opens the default tab. + */ +export enum SecondLayerTab { + + services = "SERVICES", + partners = "PARTNERS" +} diff --git a/src/models/index.tsx b/src/models/index.tsx index 4b30ad3..d1f8a60 100644 --- a/src/models/index.tsx +++ b/src/models/index.tsx @@ -4,6 +4,7 @@ export * from './CCPAData'; export * from './FirstLayerSettings'; export * from './NetworkMode'; export * from './SecondLayerSettings'; +export * from './SecondLayerTab'; export * from './TCFData'; export * from './TCFDecisionUILayer'; export * from './TCFUserDecisions';