diff --git a/packages/react-native/src/components/formbricks.tsx b/packages/react-native/src/components/formbricks.tsx index 1a6efa6..e363f38 100644 --- a/packages/react-native/src/components/formbricks.tsx +++ b/packages/react-native/src/components/formbricks.tsx @@ -6,10 +6,17 @@ import { Logger } from "@/lib/common/logger"; import { setup } from "@/lib/common/setup"; import { SurveyStore } from "@/lib/survey/store"; -interface FormbricksProps { - appUrl: string; - environmentId: string; -} +type FormbricksProps = { appUrl: string } & ( + | { + workspaceId: string; + environmentId?: never; + } + | { + /** @deprecated Use `workspaceId` instead. Still works as a backward-compatible alias. */ + environmentId: string; + workspaceId?: never; + } +); const surveyStore = SurveyStore.getInstance(); const logger = Logger.getInstance(); @@ -17,12 +24,14 @@ const logger = Logger.getInstance(); export function Formbricks({ appUrl, environmentId, + workspaceId, }: FormbricksProps): React.JSX.Element | null { // initializes sdk useEffect(() => { const setupFormbricks = async (): Promise => { try { await setup({ + workspaceId, environmentId, appUrl, }); @@ -34,7 +43,7 @@ export function Formbricks({ setupFormbricks().catch(() => { logger.debug("Initialization error"); }); - }, [environmentId, appUrl]); + }, [environmentId, workspaceId, appUrl]); const subscribe = useCallback((callback: () => void) => { const unsubscribe = surveyStore.subscribe(callback); diff --git a/packages/react-native/src/components/survey-web-view.tsx b/packages/react-native/src/components/survey-web-view.tsx index 445ffe1..b5777cf 100644 --- a/packages/react-native/src/components/survey-web-view.tsx +++ b/packages/react-native/src/components/survey-web-view.tsx @@ -93,19 +93,18 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null { return null; } - const project = appConfig.get().environment.data.project; - const styling = getStyling(project, props.survey); - const isBrandingEnabled = project.inAppSurveyBranding; + const settings = appConfig.get().workspace.data.settings; + const styling = getStyling(settings, props.survey); + const isBrandingEnabled = settings.inAppSurveyBranding; const onCloseSurvey = (): void => { - const { environment: environmentState, user: personState } = - appConfig.get(); - const filteredSurveys = filterSurveys(environmentState, personState); + const { workspace, user: userState } = appConfig.get(); + const filteredSurveys = filterSurveys(workspace, userState); appConfig.update({ ...appConfig.get(), - environment: environmentState, - user: personState, + workspace, + user: userState, filteredSurveys, }); @@ -114,11 +113,11 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null { }; const surveyPlacement = - props.survey.projectOverwrites?.placement ?? project.placement; + props.survey.projectOverwrites?.placement ?? settings.placement; const clickOutside = props.survey.projectOverwrites?.clickOutsideClose ?? - project.clickOutsideClose; - const overlay = props.survey.projectOverwrites?.overlay ?? project.overlay; + settings.clickOutsideClose; + const overlay = props.survey.projectOverwrites?.overlay ?? settings.overlay; const appUrl = appConfig.get().appUrl; return ( @@ -141,7 +140,7 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null { originWhitelist={["https://*", "http://*"]} source={{ html: renderHtml({ - environmentId: appConfig.get().environmentId, + workspaceId: appConfig.get().workspaceId, contactId: appConfig.get().user.data.contactId ?? undefined, survey: props.survey, isBrandingEnabled, @@ -214,7 +213,7 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null { const displays = [...existingDisplays, newDisplay]; const previousConfig = appConfig.get(); - const updatedPersonState = { + const updatedUserState = { ...previousConfig.user, data: { ...previousConfig.user.data, @@ -224,14 +223,14 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null { }; const filteredSurveys = filterSurveys( - previousConfig.environment, - updatedPersonState, + previousConfig.workspace, + updatedUserState, ); appConfig.update({ ...previousConfig, - environment: previousConfig.environment, - user: updatedPersonState, + workspace: previousConfig.workspace, + user: updatedUserState, filteredSurveys, }); } @@ -246,13 +245,13 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null { }; const filteredSurveys = filterSurveys( - appConfig.get().environment, + appConfig.get().workspace, newPersonState, ); appConfig.update({ ...appConfig.get(), - environment: appConfig.get().environment, + workspace: appConfig.get().workspace, user: newPersonState, filteredSurveys, }); @@ -380,7 +379,7 @@ const renderHtml = ( onResponseCreated, onClose, }; - + window.formbricksSurveys.renderSurvey(surveyProps); } diff --git a/packages/react-native/src/lib/common/api.ts b/packages/react-native/src/lib/common/api.ts index c121a36..6269dae 100644 --- a/packages/react-native/src/lib/common/api.ts +++ b/packages/react-native/src/lib/common/api.ts @@ -4,7 +4,7 @@ import type { ApiSuccessResponse, CreateOrUpdateUserResponse, } from "@/types/api"; -import type { TEnvironmentState } from "@/types/config"; +import type { TWorkspaceState } from "@/types/config"; import { type ApiErrorResponse, err, ok, type Result } from "@/types/error"; export const makeRequest = async ( @@ -57,20 +57,20 @@ export const makeRequest = async ( // Simple API client using fetch export class ApiClient { private readonly appUrl: string; - private readonly environmentId: string; + private readonly workspaceId: string; private readonly isDebug: boolean; constructor({ appUrl, - environmentId, + workspaceId, isDebug = false, }: { appUrl: string; - environmentId: string; + workspaceId: string; isDebug: boolean; }) { this.appUrl = appUrl; - this.environmentId = environmentId; + this.workspaceId = workspaceId; this.isDebug = isDebug; } @@ -82,7 +82,7 @@ export class ApiClient { // The backend will use the JS type to determine the attribute data type return makeRequest( this.appUrl, - `/api/v2/client/${this.environmentId}/user`, + `/api/v2/client/${this.workspaceId}/user`, "POST", { userId: userUpdateInput.userId, @@ -92,12 +92,12 @@ export class ApiClient { ); } - async getEnvironmentState(): Promise< - Result + async getWorkspaceState(): Promise< + Result > { return makeRequest( this.appUrl, - `/api/v1/client/${this.environmentId}/environment`, + `/api/v1/client/${this.workspaceId}/environment`, "GET", undefined, this.isDebug, diff --git a/packages/react-native/src/lib/common/config.ts b/packages/react-native/src/lib/common/config.ts index e0cf792..8f55b4a 100644 --- a/packages/react-native/src/lib/common/config.ts +++ b/packages/react-native/src/lib/common/config.ts @@ -1,11 +1,53 @@ /* eslint-disable no-console -- Required for error logging */ import { AsyncStorage } from "@/lib/common/storage"; import { wrapThrowsAsync } from "@/lib/common/utils"; -import type { TConfig, TConfigUpdateInput } from "@/types/config"; +import type { + TConfig, + TConfigUpdateInput, + TLegacyConfig, +} from "@/types/config"; import { err, ok, type Result } from "@/types/error"; export const RN_ASYNC_STORAGE_KEY = "formbricks-react-native"; +/** + * Migrate AsyncStorage config from the pre-workspace rename shape: + * - `environmentId` → `workspaceId` + * - `environment` → `workspace` + * - `environment.data.project` (or legacy `workspace`) → `workspace.data.settings` + */ +const migrateLegacyConfig = (parsed: TLegacyConfig): TConfig => { + // Already in the new shape + if (parsed.workspace && parsed.workspaceId) { + return parsed; + } + + const legacyEnvironment = parsed.environment; + const migratedWorkspace = legacyEnvironment + ? (() => { + const envData = legacyEnvironment.data; + const settings = envData.settings ?? envData.project; + return { + expiresAt: legacyEnvironment.expiresAt, + data: { + surveys: envData.surveys, + actionClasses: envData.actionClasses, + settings: settings as TConfig["workspace"]["data"]["settings"], + }, + } as TConfig["workspace"]; + })() + : undefined; + + const { environmentId, environment, ...rest } = parsed; + + return { + ...(rest as unknown as TConfig), + workspaceId: + (rest as unknown as TConfig).workspaceId ?? (environmentId as string), + ...(migratedWorkspace ? { workspace: migratedWorkspace } : {}), + } as TConfig; +}; + export class RNConfig { private static instance: RNConfig | null = null; @@ -57,13 +99,15 @@ export class RNConfig { try { const savedConfig = await AsyncStorage.getItem(RN_ASYNC_STORAGE_KEY); if (savedConfig) { - const parsedConfig = JSON.parse(savedConfig) as TConfig; + const parsedConfig = migrateLegacyConfig( + JSON.parse(savedConfig) as TLegacyConfig, + ); // check if the config has expired if ( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- need to check if expiresAt is set - parsedConfig.environment.expiresAt && - new Date(parsedConfig.environment.expiresAt) <= new Date() + parsedConfig.workspace.expiresAt && + new Date(parsedConfig.workspace.expiresAt) <= new Date() ) { return err(new Error("Config in local storage has expired")); } diff --git a/packages/react-native/src/lib/common/event-listeners.ts b/packages/react-native/src/lib/common/event-listeners.ts index 3d49b4c..ee15fd4 100644 --- a/packages/react-native/src/lib/common/event-listeners.ts +++ b/packages/react-native/src/lib/common/event-listeners.ts @@ -1,35 +1,35 @@ -import { - addEnvironmentStateExpiryCheckListener, - clearEnvironmentStateExpiryCheckListener, -} from "@/lib/environment/state"; import { addUserStateExpiryCheckListener, clearUserStateExpiryCheckListener, } from "@/lib/user/state"; +import { + addWorkspaceStateExpiryCheckListener, + clearWorkspaceStateExpiryCheckListener, +} from "@/lib/workspace/state"; let areRemoveEventListenersAdded = false; export const addEventListeners = (): void => { - void addEnvironmentStateExpiryCheckListener(); + void addWorkspaceStateExpiryCheckListener(); void addUserStateExpiryCheckListener(); }; export const addCleanupEventListeners = (): void => { if (areRemoveEventListenersAdded) return; - clearEnvironmentStateExpiryCheckListener(); + clearWorkspaceStateExpiryCheckListener(); clearUserStateExpiryCheckListener(); areRemoveEventListenersAdded = true; }; export const removeCleanupEventListeners = (): void => { if (!areRemoveEventListenersAdded) return; - clearEnvironmentStateExpiryCheckListener(); + clearWorkspaceStateExpiryCheckListener(); clearUserStateExpiryCheckListener(); areRemoveEventListenersAdded = false; }; export const removeAllEventListeners = (): void => { - clearEnvironmentStateExpiryCheckListener(); + clearWorkspaceStateExpiryCheckListener(); clearUserStateExpiryCheckListener(); removeCleanupEventListeners(); }; diff --git a/packages/react-native/src/lib/common/setup.ts b/packages/react-native/src/lib/common/setup.ts index c49fd24..b23681d 100644 --- a/packages/react-native/src/lib/common/setup.ts +++ b/packages/react-native/src/lib/common/setup.ts @@ -11,14 +11,14 @@ import { isNowExpired, wrapThrowsAsync, } from "@/lib/common/utils"; -import { fetchEnvironmentState } from "@/lib/environment/state"; import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state"; import { sendUpdatesToBackend } from "@/lib/user/update"; +import { fetchWorkspaceState } from "@/lib/workspace/state"; import type { TConfig, TConfigInput, - TEnvironmentState, TUserState, + TWorkspaceState, } from "@/types/config"; import { err, @@ -47,45 +47,45 @@ function handleMissingField(field: string): Result { } as const); } -// Helper: Sync environment state if expired -async function syncEnvironmentStateIfExpired( - configInput: TConfigInput, +// Helper: Sync workspace state if expired +async function syncWorkspaceStateIfExpired( + configInput: { appUrl: string; workspaceId: string }, logger: ReturnType, existingConfig?: TConfig, -): Promise> { - if (existingConfig && !isNowExpired(existingConfig.environment.expiresAt)) { - return ok(existingConfig.environment); +): Promise> { + if (existingConfig && !isNowExpired(existingConfig.workspace.expiresAt)) { + return ok(existingConfig.workspace); } - logger.debug("Environment state expired. Syncing."); + logger.debug("Workspace state expired. Syncing."); - const environmentStateResponse = await fetchEnvironmentState({ + const workspaceResponse = await fetchWorkspaceState({ appUrl: configInput.appUrl, - environmentId: configInput.environmentId, + workspaceId: configInput.workspaceId, }); - if (environmentStateResponse.ok) { - return ok(environmentStateResponse.data); + if (workspaceResponse.ok) { + return ok(workspaceResponse.data); } logger.error( - `Error fetching environment state: ${environmentStateResponse.error.code} - ${environmentStateResponse.error.responseMessage ?? ""}`, + `Error fetching workspace state: ${workspaceResponse.error.code} - ${workspaceResponse.error.responseMessage ?? ""}`, ); return err({ code: "network_error", - message: "Error fetching environment state", + message: "Error fetching workspace state", status: 500, url: new URL( - `${configInput.appUrl}/api/v1/client/${configInput.environmentId}/environment`, + `${configInput.appUrl}/api/v1/client/${configInput.workspaceId}/environment`, ), - responseMessage: environmentStateResponse.error.message, + responseMessage: workspaceResponse.error.message, }); } // Helper: Sync user state if expired async function syncUserStateIfExpired( - configInput: TConfigInput, + configInput: { appUrl: string; workspaceId: string }, logger: ReturnType, existingConfig?: TConfig, ): Promise> { @@ -103,7 +103,7 @@ async function syncUserStateIfExpired( if (userState?.data.userId) { const updatesResponse = await sendUpdatesToBackend({ appUrl: configInput.appUrl, - environmentId: configInput.environmentId, + workspaceId: configInput.workspaceId, updates: { userId: userState.data.userId, }, @@ -120,7 +120,7 @@ async function syncUserStateIfExpired( message: "Error updating user state", status: 500, url: new URL( - `${configInput.appUrl}/api/v1/client/${configInput.environmentId}/update/contacts/${userState.data.userId}`, + `${configInput.appUrl}/api/v2/client/${configInput.workspaceId}/user`, ), responseMessage: "Unknown error", } as const); @@ -132,7 +132,7 @@ async function syncUserStateIfExpired( // Helper: Update app config with synced states const updateAppConfigWithSyncedStates = ( appConfig: RNConfig, - environmentState: TEnvironmentState, + workspace: TWorkspaceState, userState: TUserState, logger: ReturnType, existingConfig?: TConfig, @@ -141,11 +141,11 @@ const updateAppConfigWithSyncedStates = ( return; } - const filteredSurveys = filterSurveys(environmentState, userState); + const filteredSurveys = filterSurveys(workspace, userState); appConfig.update({ ...existingConfig, - environment: environmentState, + workspace, user: userState, filteredSurveys, }); @@ -159,7 +159,7 @@ const updateAppConfigWithSyncedStates = ( // Helper: Create new config and sync const createNewConfigAndSync = async ( appConfig: RNConfig, - configInput: TConfigInput, + configInput: { appUrl: string; workspaceId: string }, logger: ReturnType, ): Promise => { logger.debug( @@ -170,30 +170,30 @@ const createNewConfigAndSync = async ( logger.debug("Syncing."); try { - const environmentStateResponse = await fetchEnvironmentState({ + const workspaceResponse = await fetchWorkspaceState({ appUrl: configInput.appUrl, - environmentId: configInput.environmentId, + workspaceId: configInput.workspaceId, }); - if (environmentStateResponse.ok) { + if (workspaceResponse.ok) { const personState = DEFAULT_USER_STATE_NO_USER_ID; - const environmentState = environmentStateResponse.data; - const filteredSurveys = filterSurveys(environmentState, personState); + const workspace = workspaceResponse.data; + const filteredSurveys = filterSurveys(workspace, personState); appConfig.update({ appUrl: configInput.appUrl, - environmentId: configInput.environmentId, + workspaceId: configInput.workspaceId, user: personState, - environment: environmentState, + workspace, filteredSurveys, }); return; } await handleErrorOnFirstSetup({ - code: environmentStateResponse.error.code, + code: workspaceResponse.error.code, responseMessage: - environmentStateResponse.error.responseMessage ?? - environmentStateResponse.error.message, + workspaceResponse.error.responseMessage ?? + workspaceResponse.error.message, }); } catch (e: unknown) { const setupError = normalizeSetupError(e); @@ -208,11 +208,11 @@ const createNewConfigAndSync = async ( // Helper: Should sync config const shouldSyncConfig = ( existingConfig: TConfig | undefined, - configInput: TConfigInput, + configInput: { appUrl: string; workspaceId: string }, ): boolean => { return Boolean( - existingConfig?.environment && - existingConfig.environmentId === configInput.environmentId && + existingConfig?.workspace && + existingConfig.workspaceId === configInput.workspaceId && existingConfig.appUrl === configInput.appUrl, ); }; @@ -281,34 +281,48 @@ export const setup = async ( logger.debug("Start setup"); - if (!configInput.environmentId) { - return handleMissingField("environmentId"); + // Resolve effective ID: prefer workspaceId, fall back to environmentId + const effectiveId = configInput.workspaceId ?? configInput.environmentId; + + if (!effectiveId) { + return handleMissingField("workspaceId"); + } + + if (configInput.environmentId && !configInput.workspaceId) { + logger.debug( + "environmentId is deprecated and will be removed in a future version. Please use workspaceId instead.", + ); } if (!configInput.appUrl) { return handleMissingField("appUrl"); } - if (shouldSyncConfig(existingConfig, configInput)) { + const resolvedInput = { + appUrl: configInput.appUrl, + workspaceId: effectiveId, + }; + + if (shouldSyncConfig(existingConfig, resolvedInput)) { logger.debug("Configuration fits setup parameters."); - let environmentState: TEnvironmentState | undefined; + let workspace: TWorkspaceState | undefined; let userState: TUserState | undefined; try { - const environmentStateResult = await syncEnvironmentStateIfExpired( - configInput, + const workspaceResult = await syncWorkspaceStateIfExpired( + resolvedInput, logger, existingConfig, ); - if (environmentStateResult.ok) { - environmentState = environmentStateResult.data; + if (workspaceResult.ok) { + workspace = workspaceResult.data; } else { - return err(environmentStateResult.error); + return err(workspaceResult.error); } const userStateResult = await syncUserStateIfExpired( - configInput, + resolvedInput, logger, existingConfig, ); @@ -321,7 +335,7 @@ export const setup = async ( updateAppConfigWithSyncedStates( appConfig, - environmentState, + workspace, userState, logger, existingConfig, @@ -330,7 +344,7 @@ export const setup = async ( logger.debug("Error during sync. Please try again."); } } else { - await createNewConfigAndSync(appConfig, configInput, logger); + await createNewConfigAndSync(appConfig, resolvedInput, logger); } finalizeSetup(); return okVoid(); @@ -355,10 +369,10 @@ export const tearDown = async (): Promise => { logger.debug("Setting user state to default"); - const { environment } = appConfig.get(); + const { workspace } = appConfig.get(); const filteredSurveys = filterSurveys( - environment, + workspace, DEFAULT_USER_STATE_NO_USER_ID, ); diff --git a/packages/react-native/src/lib/common/tests/__mocks__/config.mock.ts b/packages/react-native/src/lib/common/tests/__mocks__/config.mock.ts index 30b098d..3b7f585 100644 --- a/packages/react-native/src/lib/common/tests/__mocks__/config.mock.ts +++ b/packages/react-native/src/lib/common/tests/__mocks__/config.mock.ts @@ -1,16 +1,18 @@ import type { TConfig } from "@/types/config"; // ids -export const mockEnvironmentId = "ggskhsue85p2xrxrc7x3qagg"; +export const mockWorkspaceId = "ggskhsue85p2xrxrc7x3qagg"; +/** @deprecated Kept for existing test imports — maps to `mockWorkspaceId`. */ +export const mockEnvironmentId = mockWorkspaceId; export const mockProjectId = "f5kptre0saxmltl7ram364qt"; export const mockLanguageId = "n4ts6u7wy5lbn4q3jovikqot"; export const mockSurveyId = "lz5m554yqh1i3moa3y230wei"; export const mockActionClassId = "wypzu5qw7adgy66vq8s77tso"; export const mockConfig: TConfig = { - environmentId: mockEnvironmentId, + workspaceId: mockWorkspaceId, appUrl: "https://myapp.example", - environment: { + workspace: { expiresAt: "2999-12-31T23:59:59Z", data: { surveys: [ @@ -58,7 +60,7 @@ export const mockConfig: TConfig = { name: "Manual Trigger", createdAt: "2025-01-01T10:00:00Z", updatedAt: "2025-01-01T10:00:00Z", - environmentId: mockEnvironmentId, + environmentId: mockWorkspaceId, description: "Manual Trigger", noCodeConfig: {}, }, @@ -82,8 +84,7 @@ export const mockConfig: TConfig = { noCodeConfig: {}, }, ], - project: { - id: mockProjectId, + settings: { recontactDays: 14, clickOutsideClose: true, darkOverlay: false, diff --git a/packages/react-native/src/lib/common/tests/api.test.ts b/packages/react-native/src/lib/common/tests/api.test.ts index a974285..afcf85d 100644 --- a/packages/react-native/src/lib/common/tests/api.test.ts +++ b/packages/react-native/src/lib/common/tests/api.test.ts @@ -1,7 +1,7 @@ // api.test.ts import { beforeEach, describe, expect, test, vi } from "vitest"; import { ApiClient, makeRequest } from "@/lib/common/api"; -import type { TEnvironmentState } from "@/types/config"; +import type { TWorkspaceState } from "@/types/config"; // Mock fetch const mockFetch = vi.fn(); @@ -210,7 +210,7 @@ describe("api.ts", () => { beforeEach(() => { apiClient = new ApiClient({ appUrl: "https://example.com", - environmentId: "env123", + workspaceId: "env123", isDebug: false, }); }); @@ -286,14 +286,13 @@ describe("api.ts", () => { } }); - test("gets environment state successfully", async () => { - const mockEnvironmentState: TEnvironmentState = { + test("gets workspace state successfully", async () => { + const mockWorkspaceState: TWorkspaceState = { expiresAt: new Date("2023-01-01"), data: { surveys: [], actionClasses: [], - project: { - id: "project123", + settings: { recontactDays: 30, clickOutsideClose: true, overlay: "none", @@ -309,12 +308,12 @@ describe("api.ts", () => { const mockResponse = { ok: true, json: vi.fn().mockResolvedValue({ - data: mockEnvironmentState, + data: mockWorkspaceState, }), }; mockFetch.mockResolvedValue(mockResponse); - const result = await apiClient.getEnvironmentState(); + const result = await apiClient.getWorkspaceState(); expect(mockFetch).toHaveBeenCalledWith( "https://example.com/api/v1/client/env123/environment", @@ -328,27 +327,27 @@ describe("api.ts", () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.data).toEqual(mockEnvironmentState); + expect(result.data).toEqual(mockWorkspaceState); } }); - test("gets environment state with error", async () => { + test("gets workspace state with error", async () => { const mockResponse = { ok: false, status: 404, json: vi.fn().mockResolvedValue({ code: "not_found", - message: "Environment not found", + message: "Workspace not found", }), }; mockFetch.mockResolvedValue(mockResponse); - const result = await apiClient.getEnvironmentState(); + const result = await apiClient.getWorkspaceState(); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("network_error"); - expect(result.error.message).toBe("Environment not found"); + expect(result.error.message).toBe("Workspace not found"); } }); }); diff --git a/packages/react-native/src/lib/common/tests/config.test.ts b/packages/react-native/src/lib/common/tests/config.test.ts index c1e9658..2110f16 100644 --- a/packages/react-native/src/lib/common/tests/config.test.ts +++ b/packages/react-native/src/lib/common/tests/config.test.ts @@ -58,8 +58,8 @@ describe("RNConfig", () => { test("loadFromStorage() returns err if config is expired", async () => { const expiredConfig = { ...mockConfig, - environment: { - ...mockConfig.environment, + workspace: { + ...mockConfig.workspace, expiresAt: new Date("2000-01-01T00:00:00Z"), }, }; @@ -75,6 +75,63 @@ describe("RNConfig", () => { } }); + test("loadFromStorage() migrates legacy { environmentId, environment } shape", async () => { + const { workspaceId, workspace, ...rest } = mockConfig; + const legacyStored = { + ...rest, + environmentId: workspaceId, + environment: workspace, + }; + + vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce( + JSON.stringify(legacyStored), + ); + + const result = await configInstance.loadFromStorage(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.workspaceId).toBe(workspaceId); + expect(result.data.workspace.data.settings).toEqual( + workspace.data.settings, + ); + expect(result.data.workspace.data.surveys).toEqual( + workspace.data.surveys, + ); + expect( + (result.data as unknown as Record).environmentId, + ).toBeUndefined(); + expect( + (result.data as unknown as Record).environment, + ).toBeUndefined(); + } + }); + + test("loadFromStorage() migrates legacy environment.data.project to workspace.data.settings", async () => { + const { workspaceId, workspace, ...rest } = mockConfig; + const { settings, ...envDataWithoutSettings } = workspace.data; + const legacyStored = { + ...rest, + environmentId: workspaceId, + environment: { + expiresAt: workspace.expiresAt, + data: { + ...envDataWithoutSettings, + project: settings, + }, + }, + }; + + vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce( + JSON.stringify(legacyStored), + ); + + const result = await configInstance.loadFromStorage(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.workspace.data.settings).toEqual(settings); + } + }); + test("loadFromStorage() returns err if no or invalid config in storage", async () => { // Simulate no data vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(null); diff --git a/packages/react-native/src/lib/common/tests/event-listeners.test.ts b/packages/react-native/src/lib/common/tests/event-listeners.test.ts index 7f901a5..e13922f 100644 --- a/packages/react-native/src/lib/common/tests/event-listeners.test.ts +++ b/packages/react-native/src/lib/common/tests/event-listeners.test.ts @@ -5,18 +5,18 @@ import { removeAllEventListeners, removeCleanupEventListeners, } from "@/lib/common/event-listeners"; -import { - addEnvironmentStateExpiryCheckListener, - clearEnvironmentStateExpiryCheckListener, -} from "@/lib/environment/state"; import { addUserStateExpiryCheckListener, clearUserStateExpiryCheckListener, } from "@/lib/user/state"; +import { + addWorkspaceStateExpiryCheckListener, + clearWorkspaceStateExpiryCheckListener, +} from "@/lib/workspace/state"; -vi.mock("@/lib/environment/state", () => ({ - addEnvironmentStateExpiryCheckListener: vi.fn(), - clearEnvironmentStateExpiryCheckListener: vi.fn(), +vi.mock("@/lib/workspace/state", () => ({ + addWorkspaceStateExpiryCheckListener: vi.fn(), + clearWorkspaceStateExpiryCheckListener: vi.fn(), })); vi.mock("@/lib/user/state", () => ({ @@ -30,10 +30,10 @@ describe("event-listeners.ts", () => { vi.clearAllMocks(); }); - test("adds environment and user expiry listeners", () => { + test("adds workspace and user expiry listeners", () => { addEventListeners(); - expect(addEnvironmentStateExpiryCheckListener).toHaveBeenCalledTimes(1); + expect(addWorkspaceStateExpiryCheckListener).toHaveBeenCalledTimes(1); expect(addUserStateExpiryCheckListener).toHaveBeenCalledTimes(1); }); @@ -41,14 +41,14 @@ describe("event-listeners.ts", () => { addCleanupEventListeners(); addCleanupEventListeners(); - expect(clearEnvironmentStateExpiryCheckListener).toHaveBeenCalledTimes(1); + expect(clearWorkspaceStateExpiryCheckListener).toHaveBeenCalledTimes(1); expect(clearUserStateExpiryCheckListener).toHaveBeenCalledTimes(1); }); test("does nothing when cleanup listeners were not added", () => { removeCleanupEventListeners(); - expect(clearEnvironmentStateExpiryCheckListener).not.toHaveBeenCalled(); + expect(clearWorkspaceStateExpiryCheckListener).not.toHaveBeenCalled(); expect(clearUserStateExpiryCheckListener).not.toHaveBeenCalled(); }); @@ -57,7 +57,7 @@ describe("event-listeners.ts", () => { removeCleanupEventListeners(); addCleanupEventListeners(); - expect(clearEnvironmentStateExpiryCheckListener).toHaveBeenCalledTimes(3); + expect(clearWorkspaceStateExpiryCheckListener).toHaveBeenCalledTimes(3); expect(clearUserStateExpiryCheckListener).toHaveBeenCalledTimes(3); }); @@ -67,7 +67,7 @@ describe("event-listeners.ts", () => { removeAllEventListeners(); - expect(clearEnvironmentStateExpiryCheckListener).toHaveBeenCalledTimes(2); + expect(clearWorkspaceStateExpiryCheckListener).toHaveBeenCalledTimes(2); expect(clearUserStateExpiryCheckListener).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/react-native/src/lib/common/tests/setup.test.ts b/packages/react-native/src/lib/common/tests/setup.test.ts index ce4ebbc..0ed44f2 100644 --- a/packages/react-native/src/lib/common/tests/setup.test.ts +++ b/packages/react-native/src/lib/common/tests/setup.test.ts @@ -25,9 +25,9 @@ import { } from "@/lib/common/setup"; import type * as CommonUtilsModule from "@/lib/common/utils"; import { filterSurveys, isNowExpired } from "@/lib/common/utils"; -import { fetchEnvironmentState } from "@/lib/environment/state"; import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state"; import { sendUpdatesToBackend } from "@/lib/user/update"; +import { fetchWorkspaceState } from "@/lib/workspace/state"; // 1) Mock AsyncStorage vi.mock("@react-native-async-storage/async-storage", () => ({ @@ -67,9 +67,9 @@ vi.mock("@/lib/common/event-listeners", () => ({ removeAllEventListeners: vi.fn(), })); -// 5) Mock fetchEnvironmentState -vi.mock("@/lib/environment/state", () => ({ - fetchEnvironmentState: vi.fn(), +// 5) Mock fetchWorkspaceState +vi.mock("@/lib/workspace/state", () => ({ + fetchWorkspaceState: vi.fn(), })); // 6) Mock filterSurveys @@ -126,7 +126,16 @@ describe("setup.ts", () => { ); }); - test("fails if no environmentId is provided", async () => { + test("fails if no environmentId or workspaceId is provided", async () => { + const result = await setup({ appUrl: "https://my.url" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe("missing_field"); + expect(result.error).toHaveProperty("field", "workspaceId"); + } + }); + + test("fails if empty environmentId is provided without workspaceId", async () => { const result = await setup({ environmentId: "", appUrl: "https://my.url", @@ -145,12 +154,109 @@ describe("setup.ts", () => { } }); + test("succeeds with workspaceId instead of environmentId", async () => { + const mockConfig = { + get: vi.fn().mockReturnValue(undefined), + resetConfig: vi.fn(), + update: vi.fn(), + }; + + getInstanceConfigMock.mockReturnValue( + mockConfig as unknown as Promise, + ); + + (fetchWorkspaceState as unknown as Mock).mockResolvedValueOnce({ + ok: true, + data: { + data: { surveys: [] }, + expiresAt: new Date(Date.now() + 60000), + }, + }); + + (filterSurveys as unknown as Mock).mockReturnValueOnce([]); + + const result = await setup({ + workspaceId: "ws_123", + appUrl: "https://my.url", + }); + expect(result.ok).toBe(true); + expect(fetchWorkspaceState).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "ws_123" }), + ); + expect(mockConfig.update).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "ws_123" }), + ); + }); + + test("prefers workspaceId over environmentId when both provided", async () => { + const mockConfig = { + get: vi.fn().mockReturnValue(undefined), + resetConfig: vi.fn(), + update: vi.fn(), + }; + + getInstanceConfigMock.mockReturnValue( + mockConfig as unknown as Promise, + ); + + (fetchWorkspaceState as unknown as Mock).mockResolvedValueOnce({ + ok: true, + data: { + data: { surveys: [] }, + expiresAt: new Date(Date.now() + 60000), + }, + }); + + (filterSurveys as unknown as Mock).mockReturnValueOnce([]); + + const result = await setup({ + workspaceId: "ws_123", + environmentId: "env_456", + appUrl: "https://my.url", + }); + expect(result.ok).toBe(true); + expect(fetchWorkspaceState).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "ws_123" }), + ); + }); + + test("logs deprecation warning when only environmentId is used", async () => { + const mockConfig = { + get: vi.fn().mockReturnValue(undefined), + resetConfig: vi.fn(), + update: vi.fn(), + }; + + getInstanceConfigMock.mockReturnValue( + mockConfig as unknown as Promise, + ); + + (fetchWorkspaceState as unknown as Mock).mockResolvedValueOnce({ + ok: true, + data: { + data: { surveys: [] }, + expiresAt: new Date(Date.now() + 60000), + }, + }); + + (filterSurveys as unknown as Mock).mockReturnValueOnce([]); + + const result = await setup({ + environmentId: "env_123", + appUrl: "https://my.url", + }); + expect(result.ok).toBe(true); + expect(mockLogger.debug).toHaveBeenCalledWith( + "environmentId is deprecated and will be removed in a future version. Please use workspaceId instead.", + ); + }); + test("skips setup if existing config is in error state and not expired", async () => { const mockConfig = { get: vi.fn().mockReturnValue({ - environmentId: "env_123", + workspaceId: "env_123", appUrl: "https://my.url", - environment: {}, + workspace: {}, user: { data: {}, expiresAt: null }, status: { value: "error", expiresAt: new Date(Date.now() + 10000) }, }), @@ -178,9 +284,9 @@ describe("setup.ts", () => { test("proceeds if error state is expired", async () => { const mockConfig = { get: vi.fn().mockReturnValue({ - environmentId: "env_123", + workspaceId: "env_123", appUrl: "https://my.url", - environment: {}, + workspace: {}, user: { data: {}, expiresAt: null }, status: { value: "error", expiresAt: new Date(Date.now() - 10000) }, // expired }), @@ -203,12 +309,12 @@ describe("setup.ts", () => { ); }); - test("uses existing config if environmentId/appUrl match, checks for expiration sync", async () => { + test("uses existing config if workspaceId/appUrl match, checks for expiration sync", async () => { const mockConfig = { get: vi.fn().mockReturnValue({ - environmentId: "env_123", + workspaceId: "env_123", appUrl: "https://my.url", - environment: { expiresAt: new Date(Date.now() - 5000) }, // environment expired + workspace: { expiresAt: new Date(Date.now() - 5000) }, // workspace expired user: { data: { userId: "user_abc" }, expiresAt: new Date(Date.now() - 5000), // also expired @@ -224,8 +330,8 @@ describe("setup.ts", () => { (isNowExpired as unknown as Mock).mockReturnValue(true); - // Mock environment fetch success - (fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({ + // Mock workspace state fetch success + (fetchWorkspaceState as unknown as Mock).mockResolvedValueOnce({ ok: true, data: { data: { surveys: [] }, @@ -255,8 +361,8 @@ describe("setup.ts", () => { }); expect(result.ok).toBe(true); - // environmentState was fetched - expect(fetchEnvironmentState).toHaveBeenCalled(); + // workspace was fetched + expect(fetchWorkspaceState).toHaveBeenCalled(); // user state was updated expect(sendUpdatesToBackend).toHaveBeenCalled(); // filterSurveys called @@ -273,12 +379,12 @@ describe("setup.ts", () => { ); }); - test("returns an error when environment sync fails", async () => { + test("returns an error when workspace sync fails", async () => { const mockConfig = { get: vi.fn().mockReturnValue({ - environmentId: "env_123", + workspaceId: "env_123", appUrl: "https://my.url", - environment: { + workspace: { data: { surveys: [] }, expiresAt: new Date(Date.now() - 5000), }, @@ -303,7 +409,7 @@ describe("setup.ts", () => { mockConfig as unknown as Promise, ); (isNowExpired as unknown as Mock).mockReturnValue(true); - (fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({ + (fetchWorkspaceState as unknown as Mock).mockResolvedValueOnce({ ok: false, error: { code: "network_error", @@ -320,7 +426,7 @@ describe("setup.ts", () => { if (!result.ok) { expect(result.error.code).toBe("network_error"); expect("message" in result.error && result.error.message).toBe( - "Error fetching environment state", + "Error fetching workspace state", ); } expect(sendUpdatesToBackend).not.toHaveBeenCalled(); @@ -329,9 +435,9 @@ describe("setup.ts", () => { test("returns an error when user sync fails", async () => { const mockConfig = { get: vi.fn().mockReturnValue({ - environmentId: "env_123", + workspaceId: "env_123", appUrl: "https://my.url", - environment: { + workspace: { data: { surveys: [] }, expiresAt: new Date(Date.now() + 60_000), }, @@ -380,7 +486,7 @@ describe("setup.ts", () => { } }); - test("resets config if no valid config found, fetches environment, sets default user", async () => { + test("resets config if no valid config found, fetches workspace state, sets default user", async () => { const mockConfig = { get: () => { throw new Error("no config found"); @@ -393,7 +499,7 @@ describe("setup.ts", () => { mockConfig as unknown as Promise, ); - (fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({ + (fetchWorkspaceState as unknown as Mock).mockResolvedValueOnce({ ok: true, data: { data: { @@ -419,12 +525,12 @@ describe("setup.ts", () => { "No valid configuration found. Resetting config and creating new one.", ); expect(mockConfig.resetConfig).toHaveBeenCalled(); - expect(fetchEnvironmentState).toHaveBeenCalled(); + expect(fetchWorkspaceState).toHaveBeenCalled(); expect(mockConfig.update).toHaveBeenCalledWith({ appUrl: "https://urlX", - environmentId: "envX", + workspaceId: "envX", user: DEFAULT_USER_STATE_NO_USER_ID, - environment: { + workspace: { data: { surveys: [{ name: "SurveyA" }], // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- required for testing this object @@ -435,7 +541,7 @@ describe("setup.ts", () => { }); }); - test("calls handleErrorOnFirstSetup if environment fetch fails initially", async () => { + test("calls handleErrorOnFirstSetup if workspace fetch fails initially", async () => { const mockConfig = { get: vi.fn().mockReturnValue(undefined), update: vi.fn(), @@ -446,7 +552,7 @@ describe("setup.ts", () => { mockConfig as unknown as Promise, ); - (fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({ + (fetchWorkspaceState as unknown as Mock).mockResolvedValueOnce({ ok: false, error: { code: "forbidden", responseMessage: "No access" }, }); @@ -468,7 +574,7 @@ describe("setup.ts", () => { getInstanceConfigMock.mockReturnValueOnce( mockConfig as unknown as Promise, ); - (fetchEnvironmentState as unknown as Mock).mockRejectedValueOnce("boom"); + (fetchWorkspaceState as unknown as Mock).mockRejectedValueOnce("boom"); await expect( setup({ environmentId: "envX", appUrl: "https://urlX" }), @@ -486,9 +592,9 @@ describe("setup.ts", () => { test("adds event listeners and sets isSetup", async () => { const mockConfig = { get: vi.fn().mockReturnValue({ - environmentId: "env_abc", + workspaceId: "env_abc", appUrl: "https://test.app", - environment: {}, + workspace: {}, user: { data: {}, expiresAt: null }, status: { value: "success", expiresAt: null }, }), @@ -530,6 +636,7 @@ describe("setup.ts", () => { const mockConfig = { get: vi.fn().mockReturnValue({ user: { data: { userId: "XYZ" } }, + workspace: { data: { surveys: [] } }, }), update: vi.fn(), }; diff --git a/packages/react-native/src/lib/common/tests/utils.test.ts b/packages/react-native/src/lib/common/tests/utils.test.ts index 25de39b..1ff6094 100644 --- a/packages/react-native/src/lib/common/tests/utils.test.ts +++ b/packages/react-native/src/lib/common/tests/utils.test.ts @@ -1,9 +1,6 @@ // utils.test.ts import { beforeEach, describe, expect, test, vi } from "vitest"; -import { - mockProjectId, - mockSurveyId, -} from "@/lib/common/tests/__mocks__/config.mock"; +import { mockSurveyId } from "@/lib/common/tests/__mocks__/config.mock"; import { delayedResult, diffInDays, @@ -15,9 +12,9 @@ import { wrapThrowsAsync, } from "@/lib/common/utils"; import type { - TEnvironmentState, - TEnvironmentStateProject, TUserState, + TWorkspaceState, + TWorkspaceStateSettings, } from "@/types/config"; import type { TSurvey } from "@/types/survey"; @@ -87,8 +84,8 @@ describe("utils.ts", () => { // filterSurveys // --------------------------------------------------------------------------------- describe("filterSurveys()", () => { - // We'll create a minimal environment state - let environment: TEnvironmentState; + // We'll create a minimal workspace state + let workspace: TWorkspaceState; let user: TUserState; const baseSurvey: Partial = { id: mockSurveyId, @@ -99,18 +96,17 @@ describe("utils.ts", () => { }; beforeEach(() => { - environment = { + workspace = { expiresAt: new Date(), data: { - project: { - id: mockProjectId, + settings: { recontactDays: 7, // fallback if survey doesn't have it clickOutsideClose: false, overlay: "none", placement: "bottomRight", inAppSurveyBranding: true, styling: { allowStyleOverwrite: false }, - } as TEnvironmentStateProject, + } as TWorkspaceStateSettings, surveys: [], actionClasses: [], }, @@ -131,7 +127,7 @@ describe("utils.ts", () => { test("returns no surveys if user has no segments and userId is set", () => { user.data.userId = "user_abc"; // environment has a single survey - environment.data.surveys = [ + workspace.data.surveys = [ { ...baseSurvey, id: mockSurveyId1, @@ -139,13 +135,13 @@ describe("utils.ts", () => { } as TSurvey, ]; - const result = filterSurveys(environment, user); + const result = filterSurveys(workspace, user); expect(result).toEqual([]); // no segments => none pass }); test("returns surveys if user has no userId but displayOnce and no displays yet", () => { // userId is null => it won't segment filter - environment.data.surveys = [ + workspace.data.surveys = [ { ...baseSurvey, id: mockSurveyId1, @@ -153,13 +149,13 @@ describe("utils.ts", () => { } as TSurvey, ]; - const result = filterSurveys(environment, user); + const result = filterSurveys(workspace, user); expect(result).toHaveLength(1); expect(result[0].id).toBe(mockSurveyId1); }); test("filters out surveys that have a segment with filters if userId is not set", () => { - environment.data.surveys = [ + workspace.data.surveys = [ { ...baseSurvey, id: mockSurveyId1, @@ -170,12 +166,12 @@ describe("utils.ts", () => { } as TSurvey, ]; - const result = filterSurveys(environment, user); + const result = filterSurveys(workspace, user); expect(result).toHaveLength(0); }); test("includes surveys without segment filters for anonymous users", () => { - environment.data.surveys = [ + workspace.data.surveys = [ { ...baseSurvey, id: mockSurveyId1, @@ -188,12 +184,12 @@ describe("utils.ts", () => { } as TSurvey, ]; - const result = filterSurveys(environment, user); + const result = filterSurveys(workspace, user); expect(result).toHaveLength(2); }); test("skips surveys that already displayed if displayOnce is used", () => { - environment.data.surveys = [ + workspace.data.surveys = [ { ...baseSurvey, id: mockSurveyId1, @@ -202,12 +198,12 @@ describe("utils.ts", () => { ]; user.data.displays = [{ surveyId: mockSurveyId1, createdAt: new Date() }]; - const result = filterSurveys(environment, user); + const result = filterSurveys(workspace, user); expect(result).toEqual([]); }); test("skips surveys if user responded to them and displayOption=displayMultiple", () => { - environment.data.surveys = [ + workspace.data.surveys = [ { ...baseSurvey, id: mockSurveyId1, @@ -216,12 +212,12 @@ describe("utils.ts", () => { ]; user.data.responses = [mockSurveyId1]; - const result = filterSurveys(environment, user); + const result = filterSurveys(workspace, user); expect(result).toEqual([]); }); test("handles displaySome logic with displayLimit", () => { - environment.data.surveys = [ + workspace.data.surveys = [ { ...baseSurvey, id: mockSurveyId1, @@ -233,13 +229,13 @@ describe("utils.ts", () => { user.data.displays = [{ surveyId: mockSurveyId1, createdAt: new Date() }]; // No responses => so it's still allowed - const result = filterSurveys(environment, user); + const result = filterSurveys(workspace, user); expect(result).toHaveLength(1); }); test("filters out surveys if recontactDays not met", () => { // Suppose survey uses project fallback (7 days) - environment.data.surveys = [ + workspace.data.surveys = [ { ...baseSurvey, id: mockSurveyId1, @@ -249,7 +245,7 @@ describe("utils.ts", () => { // user last displayAt is only 3 days ago user.data.lastDisplayAt = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); - const result = filterSurveys(environment, user); + const result = filterSurveys(workspace, user); expect(result).toHaveLength(0); }); @@ -257,7 +253,7 @@ describe("utils.ts", () => { // user last displayAt is 8 days ago user.data.lastDisplayAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); - environment.data.surveys = [ + workspace.data.surveys = [ { ...baseSurvey, id: mockSurveyId1, @@ -265,14 +261,14 @@ describe("utils.ts", () => { recontactDays: null, } as TSurvey, ]; - const result = filterSurveys(environment, user); + const result = filterSurveys(workspace, user); expect(result).toHaveLength(1); }); test("filters by segment if userId is set and user has segments", () => { user.data.userId = "user_abc"; user.data.segments = [mockSegmentId1]; - environment.data.surveys = [ + workspace.data.surveys = [ { ...baseSurvey, id: mockSurveyId1, @@ -287,7 +283,7 @@ describe("utils.ts", () => { } as TSurvey, ]; - const result = filterSurveys(environment, user); + const result = filterSurveys(workspace, user); // only the one that matches user's segment expect(result).toHaveLength(1); expect(result[0].id).toBe(mockSurveyId1); @@ -298,11 +294,10 @@ describe("utils.ts", () => { // getStyling // --------------------------------------------------------------------------------- describe("getStyling()", () => { - test("returns project styling if allowStyleOverwrite=false", () => { - const project = { - id: "p1", + test("returns workspace styling if allowStyleOverwrite=false", () => { + const settings = { styling: { allowStyleOverwrite: false, brandColor: { light: "#fff" } }, - } as TEnvironmentStateProject; + } as TWorkspaceStateSettings; const survey = { styling: { overwriteThemeStyling: true, @@ -310,16 +305,15 @@ describe("utils.ts", () => { } as TSurvey["styling"], } as TSurvey; - const result = getStyling(project, survey); + const result = getStyling(settings, survey); // should get project styling - expect(result).toEqual(project.styling); + expect(result).toEqual(settings.styling); }); - test("returns project styling if allowStyleOverwrite=true but survey overwriteThemeStyling=false", () => { - const project = { - id: "p1", + test("returns workspace styling if allowStyleOverwrite=true but survey overwriteThemeStyling=false", () => { + const settings = { styling: { allowStyleOverwrite: true, brandColor: { light: "#fff" } }, - } as TEnvironmentStateProject; + } as TWorkspaceStateSettings; const survey = { styling: { overwriteThemeStyling: false, @@ -327,16 +321,15 @@ describe("utils.ts", () => { } as TSurvey["styling"], } as TSurvey; - const result = getStyling(project, survey); + const result = getStyling(settings, survey); // should get project styling still - expect(result).toEqual(project.styling); + expect(result).toEqual(settings.styling); }); test("returns survey styling if allowStyleOverwrite=true and survey overwriteThemeStyling=true", () => { - const project = { - id: "p1", + const settings = { styling: { allowStyleOverwrite: true, brandColor: { light: "#fff" } }, - } as TEnvironmentStateProject; + } as TWorkspaceStateSettings; const survey = { styling: { overwriteThemeStyling: true, @@ -344,7 +337,7 @@ describe("utils.ts", () => { } as TSurvey["styling"], } as TSurvey; - const result = getStyling(project, survey); + const result = getStyling(settings, survey); expect(result).toEqual(survey.styling); }); }); diff --git a/packages/react-native/src/lib/common/utils.ts b/packages/react-native/src/lib/common/utils.ts index 0ee5386..6f9fb79 100644 --- a/packages/react-native/src/lib/common/utils.ts +++ b/packages/react-native/src/lib/common/utils.ts @@ -1,11 +1,11 @@ import type { - TEnvironmentState, - TEnvironmentStateProject, TUserState, + TWorkspaceState, + TWorkspaceStateSettings, } from "@/types/config"; import type { Result } from "@/types/error"; -import type { TProjectStyling } from "@/types/project"; import type { TSurvey } from "@/types/survey"; +import type { TWorkspaceStyling } from "@/types/workspace"; // Helper function to calculate difference in days between two dates export const diffInDays = (date1: Date, date2: Date): number => { @@ -31,17 +31,17 @@ export const wrapThrowsAsync = /** * Filters surveys based on the displayOption, recontactDays, and segments - * @param environmentSate - The environment state + * @param workspace - The workspace state * @param userState - The user state * @returns The filtered surveys */ -// takes the environment and user state and returns the filtered surveys +// takes the workspace and user state and returns the filtered surveys export const filterSurveys = ( - environmentState: TEnvironmentState, + workspace: TWorkspaceState, userState: TUserState, ): TSurvey[] => { - const { project, surveys } = environmentState.data; + const { settings, surveys } = workspace.data; const { displays, responses, lastDisplayAt, segments, userId } = userState.data; @@ -96,10 +96,11 @@ export const filterSurveys = ( ); } - // use recontactDays of the project if survey does not have recontactDays - if (project.recontactDays) { + // use recontactDays of the workspace if survey does not have recontactDays + if (settings.recontactDays) { return ( - diffInDays(new Date(), new Date(lastDisplayAt)) >= project.recontactDays + diffInDays(new Date(), new Date(lastDisplayAt)) >= + settings.recontactDays ); } @@ -130,22 +131,22 @@ export const filterSurveys = ( }; export const getStyling = ( - project: TEnvironmentStateProject, + settings: TWorkspaceStateSettings, survey: TSurvey, -): TProjectStyling | TSurvey["styling"] => { - // allow style overwrite is enabled from the project - if (project.styling.allowStyleOverwrite) { +): TWorkspaceStyling | TSurvey["styling"] => { + // allow style overwrite is enabled from the workspace + if (settings.styling.allowStyleOverwrite) { // survey style overwrite is disabled if (!survey.styling?.overwriteThemeStyling) { - return project.styling; + return settings.styling; } // survey style overwrite is enabled return survey.styling; } - // allow style overwrite is disabled from the project - return project.styling; + // allow style overwrite is disabled from the workspace + return settings.styling; }; export const getDefaultLanguageCode = (survey: TSurvey): string | undefined => { diff --git a/packages/react-native/src/lib/environment/state.ts b/packages/react-native/src/lib/environment/state.ts deleted file mode 100644 index fe48f5e..0000000 --- a/packages/react-native/src/lib/environment/state.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* eslint-disable no-console -- logging required for error logging */ -import { ApiClient } from "@/lib/common/api"; -import { RNConfig } from "@/lib/common/config"; -import { Logger } from "@/lib/common/logger"; -import { filterSurveys } from "@/lib/common/utils"; -import type { TConfigInput, TEnvironmentState } from "@/types/config"; -import { type ApiErrorResponse, err, ok, type Result } from "@/types/error"; - -let environmentStateSyncIntervalId: number | null = null; - -/** - * Fetch the environment state from the backend - * @param appUrl - The app URL - * @param environmentId - The environment ID - * @returns The environment state - * @throws NetworkError - */ -export const fetchEnvironmentState = async ({ - appUrl, - environmentId, -}: TConfigInput): Promise> => { - const url = `${appUrl}/api/v1/client/${environmentId}/environment`; - const api = new ApiClient({ appUrl, environmentId, isDebug: false }); - - try { - const response = await api.getEnvironmentState(); - - if (!response.ok) { - return err({ - code: response.error.code, - status: response.error.status, - message: "Error syncing with backend", - url: new URL(url), - responseMessage: response.error.message, - }); - } - - return ok(response.data); - } catch (e: unknown) { - const errorTyped = e as ApiErrorResponse; - return err({ - code: "network_error", - message: errorTyped.message, - status: 500, - url: new URL(url), - responseMessage: errorTyped.responseMessage ?? "Network error", - }); - } -}; - -/** - * Add a listener to check if the environment state has expired with a certain interval - */ -export const addEnvironmentStateExpiryCheckListener = - async (): Promise => { - const appConfig = await RNConfig.getInstance(); - const logger = Logger.getInstance(); - - const updateInterval = 1000 * 60; // every minute - - if (environmentStateSyncIntervalId === null) { - const intervalHandler = async (): Promise => { - const expiresAt = appConfig.get().environment.expiresAt; - - try { - // check if the environmentState has not expired yet - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- expiresAt is checked for null - if (expiresAt && new Date(expiresAt) >= new Date()) { - return; - } - - logger.debug("Environment State has expired. Starting sync."); - - const personState = appConfig.get().user; - const environmentState = await fetchEnvironmentState({ - appUrl: appConfig.get().appUrl, - environmentId: appConfig.get().environmentId, - }); - - if (environmentState.ok) { - const { data: state } = environmentState; - const filteredSurveys = filterSurveys(state, personState); - - appConfig.update({ - ...appConfig.get(), - environment: state, - filteredSurveys, - }); - } else { - // eslint-disable-next-line @typescript-eslint/only-throw-error -- error is an ApiErrorResponse - throw environmentState.error; - } - } catch (e) { - console.error(`Error during expiry check: `, e); - logger.debug("Extending config and try again later."); - const existingConfig = appConfig.get(); - appConfig.update({ - ...existingConfig, - environment: { - ...existingConfig.environment, - expiresAt: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes - }, - }); - } - }; - - environmentStateSyncIntervalId = setInterval( - () => void intervalHandler(), - updateInterval, - ) as unknown as number; - } - }; - -export const clearEnvironmentStateExpiryCheckListener = (): void => { - if (environmentStateSyncIntervalId) { - clearInterval(environmentStateSyncIntervalId); - environmentStateSyncIntervalId = null; - } -}; diff --git a/packages/react-native/src/lib/survey/action.ts b/packages/react-native/src/lib/survey/action.ts index e5c8c8f..b346d0b 100644 --- a/packages/react-native/src/lib/survey/action.ts +++ b/packages/react-native/src/lib/survey/action.ts @@ -101,7 +101,7 @@ export const track = async ( } const { - environment: { + workspace: { data: { actionClasses = [] }, }, } = appConfig.get(); diff --git a/packages/react-native/src/lib/survey/tests/action.test.ts b/packages/react-native/src/lib/survey/tests/action.test.ts index f8fd706..0885cc9 100644 --- a/packages/react-native/src/lib/survey/tests/action.test.ts +++ b/packages/react-native/src/lib/survey/tests/action.test.ts @@ -165,7 +165,7 @@ describe("survey/action.ts", () => { beforeEach(() => { mockAppConfig.get.mockReturnValue({ - environment: { + workspace: { data: { actionClasses: mockActionClasses }, }, }); diff --git a/packages/react-native/src/lib/user/tests/update.test.ts b/packages/react-native/src/lib/user/tests/update.test.ts index 300a591..2b56471 100644 --- a/packages/react-native/src/lib/user/tests/update.test.ts +++ b/packages/react-native/src/lib/user/tests/update.test.ts @@ -63,7 +63,7 @@ describe("sendUpdatesToBackend", () => { const result = await sendUpdatesToBackend({ appUrl: mockAppUrl, - environmentId: mockEnvironmentId, + workspaceId: mockEnvironmentId, updates: { userId: mockUserId, attributes: mockAttributes }, }); @@ -97,7 +97,7 @@ describe("sendUpdatesToBackend", () => { const result = await sendUpdatesToBackend({ appUrl: mockAppUrl, - environmentId: mockEnvironmentId, + workspaceId: mockEnvironmentId, updates: mockUpdates, }); @@ -127,7 +127,7 @@ describe("sendUpdatesToBackend", () => { await expect( sendUpdatesToBackend({ appUrl: mockAppUrl, - environmentId: mockEnvironmentId, + workspaceId: mockEnvironmentId, updates: mockUpdates, }), ).rejects.toThrow("Network error"); @@ -139,8 +139,8 @@ describe("sendUpdates", () => { (RNConfig.getInstance as Mock).mockImplementation(() => ({ get: vi.fn().mockReturnValue({ appUrl: mockAppUrl, - environmentId: mockEnvironmentId, - environment: { + workspaceId: mockEnvironmentId, + workspace: { data: { surveys: [], }, diff --git a/packages/react-native/src/lib/user/update.ts b/packages/react-native/src/lib/user/update.ts index 5f88bd9..1dae82f 100644 --- a/packages/react-native/src/lib/user/update.ts +++ b/packages/react-native/src/lib/user/update.ts @@ -8,11 +8,11 @@ import { type ApiErrorResponse, err, ok, type Result } from "@/types/error"; export const sendUpdatesToBackend = async ({ appUrl, - environmentId, + workspaceId, updates, }: { appUrl: string; - environmentId: string; + workspaceId: string; updates: TUpdates; }): Promise< Result< @@ -24,8 +24,8 @@ export const sendUpdatesToBackend = async ({ ApiErrorResponse > > => { - const url = `${appUrl}/api/v1/client/${environmentId}/user`; - const api = new ApiClient({ appUrl, environmentId, isDebug: false }); + const url = `${appUrl}/api/v2/client/${workspaceId}/user`; + const api = new ApiClient({ appUrl, workspaceId, isDebug: false }); try { const response = await api.createOrUpdateUser({ @@ -68,14 +68,14 @@ export const sendUpdates = async ({ const config = await RNConfig.getInstance(); const logger = Logger.getInstance(); - const { appUrl, environmentId } = config.get(); + const { appUrl, workspaceId } = config.get(); // update endpoint call - const url = `${appUrl}/api/v1/client/${environmentId}/user`; + const url = `${appUrl}/api/v2/client/${workspaceId}/user`; try { const updatesResponse = await sendUpdatesToBackend({ appUrl, - environmentId, + workspaceId, updates, }); @@ -84,7 +84,7 @@ export const sendUpdates = async ({ } const userState = updatesResponse.data.state; - const filteredSurveys = filterSurveys(config.get().environment, userState); + const filteredSurveys = filterSurveys(config.get().workspace, userState); // messages => informational debug messages (e.g., "email already exists") // errors => error messages that should always be visible (e.g., invalid attribute keys) diff --git a/packages/react-native/src/lib/workspace/state.ts b/packages/react-native/src/lib/workspace/state.ts new file mode 100644 index 0000000..5e80661 --- /dev/null +++ b/packages/react-native/src/lib/workspace/state.ts @@ -0,0 +1,142 @@ +/* eslint-disable no-console -- logging required for error logging */ +import { ApiClient } from "@/lib/common/api"; +import { RNConfig } from "@/lib/common/config"; +import { Logger } from "@/lib/common/logger"; +import { filterSurveys } from "@/lib/common/utils"; +import type { TWorkspaceState } from "@/types/config"; +import { type ApiErrorResponse, err, ok, type Result } from "@/types/error"; + +let workspaceSyncIntervalId: number | null = null; + +/** + * Fetch the workspace state from the backend + * @param appUrl - The app URL + * @param workspaceId - The workspace ID + * @returns The workspace state + * @throws NetworkError + */ +export const fetchWorkspaceState = async ({ + appUrl, + workspaceId, +}: { + appUrl: string; + workspaceId: string; +}): Promise> => { + const url = `${appUrl}/api/v1/client/${workspaceId}/environment`; + const api = new ApiClient({ appUrl, workspaceId, isDebug: false }); + + try { + const response = await api.getWorkspaceState(); + + if (!response.ok) { + return err({ + code: response.error.code, + status: response.error.status, + message: "Error syncing with backend", + url: new URL(url), + responseMessage: response.error.message, + }); + } + + // The server responds with `data.workspace` (new) or `data.project` (legacy + // backwards-compat alias) but SDK internals use `data.settings` to avoid + // the `workspace.workspace` nesting. Map the field name here. + const rawData = response.data as TWorkspaceState & { + data: { + workspace?: TWorkspaceState["data"]["settings"]; + project?: TWorkspaceState["data"]["settings"]; + }; + }; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- server may send `workspace` or legacy `project` instead of `settings` + if (!rawData.data.settings) { + if (rawData.data.workspace) { + rawData.data.settings = rawData.data.workspace; + delete rawData.data.workspace; + } else if (rawData.data.project) { + rawData.data.settings = rawData.data.project; + delete rawData.data.project; + } + } + + return ok(rawData); + } catch (e: unknown) { + const errorTyped = e as ApiErrorResponse; + return err({ + code: "network_error", + message: errorTyped.message, + status: 500, + url: new URL(url), + responseMessage: errorTyped.responseMessage ?? "Network error", + }); + } +}; + +/** + * Add a listener to check if the workspace state has expired with a certain interval + */ +export const addWorkspaceStateExpiryCheckListener = async (): Promise => { + const appConfig = await RNConfig.getInstance(); + const logger = Logger.getInstance(); + + const updateInterval = 1000 * 60; // every minute + + if (workspaceSyncIntervalId === null) { + const intervalHandler = async (): Promise => { + const expiresAt = appConfig.get().workspace.expiresAt; + + try { + // check if the workspace state has not expired yet + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- expiresAt is checked for null + if (expiresAt && new Date(expiresAt) >= new Date()) { + return; + } + + logger.debug("Workspace state has expired. Starting sync."); + + const personState = appConfig.get().user; + const workspace = await fetchWorkspaceState({ + appUrl: appConfig.get().appUrl, + workspaceId: appConfig.get().workspaceId, + }); + + if (workspace.ok) { + const { data: state } = workspace; + const filteredSurveys = filterSurveys(state, personState); + + appConfig.update({ + ...appConfig.get(), + workspace: state, + filteredSurveys, + }); + } else { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- error is an ApiErrorResponse + throw workspace.error; + } + } catch (e) { + console.error(`Error during expiry check: `, e); + logger.debug("Extending config and try again later."); + const existingConfig = appConfig.get(); + appConfig.update({ + ...existingConfig, + workspace: { + ...existingConfig.workspace, + expiresAt: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes + }, + }); + } + }; + + workspaceSyncIntervalId = setInterval( + () => void intervalHandler(), + updateInterval, + ) as unknown as number; + } +}; + +export const clearWorkspaceStateExpiryCheckListener = (): void => { + if (workspaceSyncIntervalId !== null) { + clearInterval(workspaceSyncIntervalId); + workspaceSyncIntervalId = null; + } +}; diff --git a/packages/react-native/src/lib/environment/tests/state.test.ts b/packages/react-native/src/lib/workspace/tests/state.test.ts similarity index 62% rename from packages/react-native/src/lib/environment/tests/state.test.ts rename to packages/react-native/src/lib/workspace/tests/state.test.ts index 7515b1c..114d1a8 100644 --- a/packages/react-native/src/lib/environment/tests/state.test.ts +++ b/packages/react-native/src/lib/workspace/tests/state.test.ts @@ -14,20 +14,20 @@ import { RNConfig } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { filterSurveys } from "@/lib/common/utils"; import { - addEnvironmentStateExpiryCheckListener, - clearEnvironmentStateExpiryCheckListener, - fetchEnvironmentState, -} from "@/lib/environment/state"; -import type { TEnvironmentState } from "@/types/config"; + addWorkspaceStateExpiryCheckListener, + clearWorkspaceStateExpiryCheckListener, + fetchWorkspaceState, +} from "@/lib/workspace/state"; +import type { TWorkspaceState } from "@/types/config"; -// Mock the FormbricksAPI so we can control environment.getState +// Mock the ApiClient so we can control workspace.getWorkspaceState vi.mock("@/lib/common/api", () => ({ ApiClient: vi.fn().mockImplementation(function MockApiClient() { - return { getEnvironmentState: vi.fn() }; + return { getWorkspaceState: vi.fn() }; }), })); -// Mock logger (so we don’t spam console) +// Mock logger (so we don't spam console) vi.mock("@/lib/common/logger", () => ({ Logger: { getInstance: vi.fn(() => { @@ -57,7 +57,7 @@ vi.mock("@/lib/common/config", () => { }; }); -describe("environment/state.ts", () => { +describe("workspace/state.ts", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -67,16 +67,16 @@ describe("environment/state.ts", () => { vi.useRealTimers(); }); - describe("fetchEnvironmentState()", () => { - test("returns ok(...) with environment state", async () => { + describe("fetchWorkspaceState()", () => { + test("returns ok(...) with workspace state", async () => { // Setup mock (ApiClient as unknown as Mock).mockImplementationOnce( function MockApiClient() { return { - getEnvironmentState: vi.fn().mockResolvedValue({ + getWorkspaceState: vi.fn().mockResolvedValue({ ok: true, data: { - data: { foo: "bar" }, + data: { settings: { foo: "bar" } }, expiresAt: new Date(Date.now() + 1000 * 60 * 30), }, }), @@ -84,21 +84,79 @@ describe("environment/state.ts", () => { }, ); - const result = await fetchEnvironmentState({ + const result = await fetchWorkspaceState({ appUrl: "https://fake.host", - environmentId: "env_123", + workspaceId: "ws_123", }); expect(result.ok).toBe(true); if (result.ok) { - const val: TEnvironmentState = result.data; - expect(val.data).toEqual({ foo: "bar" }); + const val: TWorkspaceState = result.data; + expect(val.data.settings).toEqual({ foo: "bar" }); expect(val.expiresAt).toBeInstanceOf(Date); } }); - test("returns err(...) if environment.getState is not ok", async () => { + test("maps server `workspace` field to SDK `settings`", async () => { + (ApiClient as unknown as Mock).mockImplementationOnce( + function MockApiClient() { + return { + getWorkspaceState: vi.fn().mockResolvedValue({ + ok: true, + data: { + data: { workspace: { brandColor: "#fff" } }, + expiresAt: new Date(Date.now() + 1000 * 60), + }, + }), + }; + }, + ); + + const result = await fetchWorkspaceState({ + appUrl: "https://fake.host", + workspaceId: "ws_123", + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data.settings).toEqual({ brandColor: "#fff" }); + expect( + (result.data.data as unknown as { workspace?: unknown }).workspace, + ).toBeUndefined(); + } + }); + + test("maps legacy server `project` field to SDK `settings`", async () => { + (ApiClient as unknown as Mock).mockImplementationOnce( + function MockApiClient() { + return { + getWorkspaceState: vi.fn().mockResolvedValue({ + ok: true, + data: { + data: { project: { brandColor: "#000" } }, + expiresAt: new Date(Date.now() + 1000 * 60), + }, + }), + }; + }, + ); + + const result = await fetchWorkspaceState({ + appUrl: "https://fake.host", + workspaceId: "ws_123", + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data.settings).toEqual({ brandColor: "#000" }); + expect( + (result.data.data as unknown as { project?: unknown }).project, + ).toBeUndefined(); + } + }); + + test("returns err(...) if getWorkspaceState is not ok", async () => { const mockError = { code: "forbidden", status: 403, @@ -108,7 +166,7 @@ describe("environment/state.ts", () => { (ApiClient as unknown as Mock).mockImplementationOnce( function MockApiClient() { return { - getEnvironmentState: vi.fn().mockResolvedValue({ + getWorkspaceState: vi.fn().mockResolvedValue({ ok: false, error: mockError, }), @@ -116,9 +174,9 @@ describe("environment/state.ts", () => { }, ); - const result = await fetchEnvironmentState({ + const result = await fetchWorkspaceState({ appUrl: "https://fake.host", - environmentId: "env_123", + workspaceId: "ws_123", }); expect(result.ok).toBe(false); if (!result.ok) { @@ -138,14 +196,14 @@ describe("environment/state.ts", () => { (ApiClient as unknown as Mock).mockImplementationOnce( function MockApiClient() { return { - getEnvironmentState: vi.fn().mockRejectedValue(mockNetworkError), + getWorkspaceState: vi.fn().mockRejectedValue(mockNetworkError), }; }, ); - const result = await fetchEnvironmentState({ + const result = await fetchWorkspaceState({ appUrl: "https://fake.host", - environmentId: "env_123", + workspaceId: "ws_123", }); expect(result.ok).toBe(false); if (!result.ok) { @@ -158,7 +216,7 @@ describe("environment/state.ts", () => { }); }); - describe("addEnvironmentStateExpiryCheckListener()", () => { + describe("addWorkspaceStateExpiryCheckListener()", () => { let mockRNConfig: MockInstance<() => Promise>; let mockLoggerInstance: MockInstance<() => Logger>; @@ -174,11 +232,11 @@ describe("environment/state.ts", () => { mockRNConfig = vi.spyOn(RNConfig, "getInstance"); const mockConfig = { get: vi.fn().mockReturnValue({ - environment: { + workspace: { expiresAt: new Date(Date.now() + 60_000), // Not expired for now }, user: {}, - environmentId: "env_123", + workspaceId: "ws_123", appUrl: "https://fake.host", }), }; @@ -190,17 +248,17 @@ describe("environment/state.ts", () => { }); afterEach(() => { - clearEnvironmentStateExpiryCheckListener(); // clear after each test + clearWorkspaceStateExpiryCheckListener(); // clear after each test }); test("starts interval check and updates state when expired", async () => { const mockConfig = { get: vi.fn().mockReturnValue({ - environment: { + workspace: { expiresAt: new Date(Date.now() - 1000).toISOString(), // expired }, appUrl: "https://test.com", - environmentId: "env_123", + workspaceId: "ws_123", user: { data: {} }, }), update: vi.fn(), @@ -208,15 +266,16 @@ describe("environment/state.ts", () => { const mockNewState = { data: { - expiresAt: new Date(Date.now() + 1000 * 60 * 30).toISOString(), + settings: { foo: "bar" }, }, + expiresAt: new Date(Date.now() + 1000 * 60 * 30).toISOString(), }; mockRNConfig.mockReturnValue(mockConfig as unknown as Promise); (ApiClient as Mock).mockImplementation(function MockApiClient() { return { - getEnvironmentState: vi.fn().mockResolvedValue({ + getWorkspaceState: vi.fn().mockResolvedValue({ ok: true, data: mockNewState, }), @@ -226,7 +285,7 @@ describe("environment/state.ts", () => { (filterSurveys as Mock).mockReturnValue([]); // Add listener - await addEnvironmentStateExpiryCheckListener(); + await addWorkspaceStateExpiryCheckListener(); // Fast-forward time await vi.advanceTimersByTimeAsync(1000 * 60); @@ -238,11 +297,11 @@ describe("environment/state.ts", () => { test("extends expiry on error", async () => { const mockConfig = { get: vi.fn().mockReturnValue({ - environment: { + workspace: { expiresAt: new Date(Date.now() - 1000).toISOString(), }, appUrl: "https://test.com", - environmentId: "env_123", + workspaceId: "ws_123", }), update: vi.fn(), }; @@ -252,13 +311,13 @@ describe("environment/state.ts", () => { // Mock API to throw an error (ApiClient as Mock).mockImplementation(function MockApiClient() { return { - getEnvironmentState: vi + getWorkspaceState: vi .fn() .mockRejectedValue(new Error("Network error")), }; }); - await addEnvironmentStateExpiryCheckListener(); + await addWorkspaceStateExpiryCheckListener(); // Fast-forward time await vi.advanceTimersByTimeAsync(1000 * 60); @@ -271,11 +330,11 @@ describe("environment/state.ts", () => { const futureDate = new Date(Date.now() + 1000 * 60 * 60); // 1 hour in future const mockConfig = { get: vi.fn().mockReturnValue({ - environment: { + workspace: { expiresAt: futureDate.toISOString(), }, appUrl: "https://test.com", - environmentId: "env_123", + workspaceId: "ws_123", }), update: vi.fn(), }; @@ -283,12 +342,12 @@ describe("environment/state.ts", () => { mockRNConfig.mockReturnValue(mockConfig as unknown as Promise); const apiMock = vi.fn().mockImplementation(function MockApiClient() { - return { getEnvironmentState: vi.fn() }; + return { getWorkspaceState: vi.fn() }; }); (ApiClient as Mock).mockImplementation(apiMock); - await addEnvironmentStateExpiryCheckListener(); + await addWorkspaceStateExpiryCheckListener(); // Fast-forward time by less than expiry await vi.advanceTimersByTimeAsync(1000 * 60); @@ -296,11 +355,11 @@ describe("environment/state.ts", () => { expect(mockConfig.update).not.toHaveBeenCalled(); }); - test("clears interval when clearEnvironmentStateExpiryCheckListener is called", async () => { + test("clears interval when clearWorkspaceStateExpiryCheckListener is called", async () => { const clearIntervalSpy = vi.spyOn(global, "clearInterval"); - await addEnvironmentStateExpiryCheckListener(); - clearEnvironmentStateExpiryCheckListener(); + await addWorkspaceStateExpiryCheckListener(); + clearWorkspaceStateExpiryCheckListener(); expect(clearIntervalSpy).toHaveBeenCalled(); }); diff --git a/packages/react-native/src/types/config.ts b/packages/react-native/src/types/config.ts index b053409..6a12e43 100644 --- a/packages/react-native/src/types/config.ts +++ b/packages/react-native/src/types/config.ts @@ -2,32 +2,31 @@ import { z } from "zod"; import type { TResponseUpdate } from "@/types/response"; import type { TFileUploadParams } from "@/types/storage"; import type { TActionClass } from "./action-class"; -import type { TProject, TProjectStyling } from "./project"; import type { TSurvey } from "./survey"; +import type { TWorkspace, TWorkspaceStyling } from "./workspace"; -export type TEnvironmentStateProject = Pick< - TProject, - | "id" +export type TWorkspaceStateSettings = Pick< + TWorkspace, | "recontactDays" | "clickOutsideClose" | "overlay" | "placement" | "inAppSurveyBranding" > & { - styling: TProjectStyling; + styling: TWorkspaceStyling; }; -export type TEnvironmentStateActionClass = Pick< +export type TWorkspaceStateActionClass = Pick< TActionClass, "id" | "key" | "type" | "name" | "noCodeConfig" >; -export interface TEnvironmentState { +export interface TWorkspaceState { expiresAt: Date; data: { surveys: TSurvey[]; - actionClasses: TEnvironmentStateActionClass[]; - project: TEnvironmentStateProject; + actionClasses: TWorkspaceStateActionClass[]; + settings: TWorkspaceStateSettings; }; } @@ -45,9 +44,9 @@ export interface TUserState { } export interface TConfig { - environmentId: string; + workspaceId: string; appUrl: string; - environment: TEnvironmentState; + workspace: TWorkspaceState; user: TUserState; filteredSurveys: TSurvey[]; status: { @@ -66,10 +65,29 @@ export type TConfigUpdateInput = Omit & { export type TAttributes = Record; export interface TConfigInput { - environmentId: string; + /** @deprecated Use `workspaceId` instead. Still works as a backward-compatible alias. */ + environmentId?: string; + workspaceId?: string; appUrl: string; } +/** + * Legacy config shape persisted before the workspace rename. + * Used to migrate AsyncStorage payloads that still use `environmentId` / `environment`. + */ +export type TLegacyConfig = TConfig & { + environmentId?: string; + environment?: { + expiresAt: Date; + data: { + surveys: TSurvey[]; + actionClasses: TWorkspaceStateActionClass[]; + project?: TWorkspaceStateSettings; + settings?: TWorkspaceStateSettings; + }; + }; +}; + export interface TWebViewOnMessageData { onFinished?: boolean | null; onDisplay?: boolean | null; diff --git a/packages/react-native/src/types/survey.ts b/packages/react-native/src/types/survey.ts index 006c513..7ff07a0 100644 --- a/packages/react-native/src/types/survey.ts +++ b/packages/react-native/src/types/survey.ts @@ -1,7 +1,7 @@ import type { TResponseData, TResponseUpdate } from "@/types/response"; import type { TFileUploadParams, TUploadFileConfig } from "@/types/storage"; import type { TOverlay } from "./common"; -import type { TProjectStyling } from "./project"; +import type { TWorkspaceStyling } from "./workspace"; export interface TJsFileUploadParams { file: { @@ -18,7 +18,7 @@ export interface TJsFileUploadParams { export interface SurveyBaseProps { survey: TSurvey; - styling: TSurvey["styling"] | TProjectStyling; + styling: TSurvey["styling"] | TWorkspaceStyling; isBrandingEnabled: boolean; getSetIsError?: (getSetError: (value: boolean) => void) => void; getSetIsResponseSendingFinished?: ( @@ -60,7 +60,9 @@ export interface SurveyInlineProps extends SurveyBaseProps { export interface SurveyContainerProps extends Omit { appUrl?: string; + /** @deprecated Use `workspaceId` instead. Still works as a backward-compatible alias. */ environmentId?: string; + workspaceId?: string; userId?: string; contactId?: string; onDisplayCreated?: () => void | Promise; diff --git a/packages/react-native/src/types/project.ts b/packages/react-native/src/types/workspace.ts similarity index 89% rename from packages/react-native/src/types/project.ts rename to packages/react-native/src/types/workspace.ts index 711b8de..95e79c5 100644 --- a/packages/react-native/src/types/project.ts +++ b/packages/react-native/src/types/workspace.ts @@ -1,7 +1,7 @@ import type { TOverlay } from "./common"; import type { TBaseStyling } from "./styling"; -export interface TProject { +export interface TWorkspace { id: string; createdAt: Date; updatedAt: Date; @@ -28,6 +28,6 @@ export interface TProject { } | null; } -export interface TProjectStyling extends TBaseStyling { +export interface TWorkspaceStyling extends TBaseStyling { allowStyleOverwrite: boolean; }