mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-16 19:07:16 -06:00
chore: migrate react-native to its own repo (#5583)
This commit is contained in:
2
LICENSE
2
LICENSE
@@ -3,7 +3,7 @@ Copyright (c) 2024 Formbricks GmbH
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
- All content that resides under the "apps/web/modules/ee" directory of this repository, if these directories exist, is licensed under the license defined in "apps/web/modules/ee/LICENSE".
|
||||
- All content that resides under the "packages/js/", "packages/react-native/", "packages/android/", "packages/ios/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
|
||||
- All content that resides under the "packages/js/", "packages/android/", "packages/ios/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
|
||||
- All third party components incorporated into the Formbricks Software are licensed under the original license provided by the owner of the applicable component.
|
||||
- Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
EXPO_PUBLIC_APP_URL=http://192.168.0.197:3000
|
||||
EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=cm5p0cs7r000819182b32j0a1
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/react.js"],
|
||||
parserOptions: {
|
||||
project: "tsconfig.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
};
|
||||
35
apps/demo-react-native/.gitignore
vendored
35
apps/demo-react-native/.gitignore
vendored
@@ -1,35 +0,0 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#ffffff",
|
||||
"foregroundImage": "./assets/adaptive-icon.png"
|
||||
}
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"icon": "./assets/icon.png",
|
||||
"ios": {
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "Take pictures for certain activities.",
|
||||
"NSMicrophoneUsageDescription": "Need microphone access for recording videos.",
|
||||
"NSPhotoLibraryUsageDescription": "Select pictures for certain activities."
|
||||
},
|
||||
"supportsTablet": true
|
||||
},
|
||||
"jsEngine": "hermes",
|
||||
"name": "react-native-demo",
|
||||
"newArchEnabled": true,
|
||||
"orientation": "portrait",
|
||||
"slug": "react-native-demo",
|
||||
"splash": {
|
||||
"backgroundColor": "#ffffff",
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain"
|
||||
},
|
||||
"userInterfaceStyle": "light",
|
||||
"version": "1.0.0",
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
@@ -1,6 +0,0 @@
|
||||
module.exports = function babel(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { registerRootComponent } from "expo";
|
||||
import { LogBox } from "react-native";
|
||||
import App from "./src/app";
|
||||
|
||||
registerRootComponent(App);
|
||||
|
||||
LogBox.ignoreAllLogs();
|
||||
@@ -1,21 +0,0 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const path = require("node:path");
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
|
||||
// Find the workspace root, this can be replaced with `find-yarn-workspace-root`
|
||||
const workspaceRoot = path.resolve(__dirname, "../..");
|
||||
const projectRoot = __dirname;
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
// 1. Watch all files within the monorepo
|
||||
config.watchFolders = [workspaceRoot];
|
||||
// 2. Let Metro know where to resolve packages, and in what order
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, "node_modules"),
|
||||
path.resolve(workspaceRoot, "node_modules"),
|
||||
];
|
||||
// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
|
||||
config.resolver.disableHierarchicalLookup = true;
|
||||
|
||||
module.exports = config;
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "@formbricks/demo-react-native",
|
||||
"version": "1.0.0",
|
||||
"main": "./index.js",
|
||||
"scripts": {
|
||||
"dev": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"eject": "expo eject",
|
||||
"clean": "rimraf .turbo node_modules .expo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@formbricks/react-native": "workspace:*",
|
||||
"@react-native-async-storage/async-storage": "2.1.0",
|
||||
"expo": "52.0.28",
|
||||
"expo-status-bar": "2.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.6",
|
||||
"react-native-webview": "13.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.26.0",
|
||||
"@types/react": "18.3.18",
|
||||
"typescript": "5.7.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import React, { type JSX } from "react";
|
||||
import { Button, LogBox, StyleSheet, Text, View } from "react-native";
|
||||
import Formbricks, {
|
||||
logout,
|
||||
setAttribute,
|
||||
setAttributes,
|
||||
setLanguage,
|
||||
setUserId,
|
||||
track,
|
||||
} from "@formbricks/react-native";
|
||||
|
||||
LogBox.ignoreAllLogs();
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
if (!process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID) {
|
||||
throw new Error("EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID is required");
|
||||
}
|
||||
|
||||
if (!process.env.EXPO_PUBLIC_APP_URL) {
|
||||
throw new Error("EXPO_PUBLIC_APP_URL is required");
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Formbricks React Native SDK Demo</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}>
|
||||
<Button
|
||||
title="Trigger Code Action"
|
||||
onPress={() => {
|
||||
track("code").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error tracking event:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Set User Id"
|
||||
onPress={() => {
|
||||
setUserId("random-user-id").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error setting user id:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Set User Attributess (multiple)"
|
||||
onPress={() => {
|
||||
setAttributes({
|
||||
testAttr: "attr-test",
|
||||
testAttr2: "attr-test-2",
|
||||
testAttr3: "attr-test-3",
|
||||
testAttr4: "attr-test-4",
|
||||
}).catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error setting user attributes:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Set User Attributes (single)"
|
||||
onPress={() => {
|
||||
setAttribute("testSingleAttr", "testSingleAttr").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error setting user attributes:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Logout"
|
||||
onPress={() => {
|
||||
logout().catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error logging out:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Set Language (de)"
|
||||
onPress={() => {
|
||||
setLanguage("de").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error setting language:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<StatusBar style="auto" />
|
||||
|
||||
<Formbricks
|
||||
appUrl={process.env.EXPO_PUBLIC_APP_URL as string}
|
||||
environmentId={process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID as string}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#fff",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
},
|
||||
"extends": "expo/tsconfig.base"
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/react.js"],
|
||||
parserOptions: {
|
||||
project: "tsconfig.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"import/no-relative-packages": "off",
|
||||
},
|
||||
};
|
||||
28
packages/react-native/.gitignore
vendored
28
packages/react-native/.gitignore
vendored
@@ -1,28 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# build
|
||||
dist
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
@@ -1,9 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Formbricks GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Formbricks React Native SDK
|
||||
|
||||
[](https://www.npmjs.com/package/@formbricks/react-native)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
Please see [Formbricks Docs](https://formbricks.com/docs).
|
||||
Specifically, [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides).
|
||||
|
||||
## What is Formbricks
|
||||
|
||||
Formbricks is your go-to solution for in-product micro-surveys that will supercharge your product experience! 🚀 For more information please check out [formbricks.com](https://formbricks.com).
|
||||
|
||||
## How to use this library
|
||||
|
||||
1. Install the Formbricks package inside your project using npm:
|
||||
|
||||
```bash
|
||||
npm install @formbricks/react-native
|
||||
```
|
||||
|
||||
1. Import Formbricks and initialize the widget in your main component (e.g., App.tsx or App.js):
|
||||
|
||||
```javascript
|
||||
import Formbricks, { track } from "@formbricks/react-native";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<View>
|
||||
{/* Your app code */}
|
||||
<Formbricks appUrl="https://app.formbricks.com" environmentId="your-environment-id" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Replace your-environment-id with your actual environment ID. You can find your environment ID in the **Connections instructions** in the Formbricks **Configuration** pages.
|
||||
|
||||
For more detailed guides for different frameworks, check out our [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides).
|
||||
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"name": "@formbricks/react-native",
|
||||
"version": "2.1.2",
|
||||
"license": "MIT",
|
||||
"description": "Formbricks React Native SDK allows you to connect your app to Formbricks, display surveys and trigger events.",
|
||||
"homepage": "https://formbricks.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/formbricks/formbricks"
|
||||
},
|
||||
"keywords": [
|
||||
"Formbricks",
|
||||
"surveys",
|
||||
"experience management",
|
||||
"react native",
|
||||
"sdk"
|
||||
],
|
||||
"author": "Formbricks <hola@formbricks.com>",
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && vite build",
|
||||
"build:dev": "tsc && vite build --mode dev",
|
||||
"lint": "eslint src --ext .ts,.js,.tsx,.jsx",
|
||||
"dev": "vite build --watch --mode dev",
|
||||
"clean": "rimraf .turbo node_modules dist .turbo",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"zod": "3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@types/react": "18.3.1",
|
||||
"@vitest/coverage-v8": "3.1.1",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.74.5",
|
||||
"terser": "5.37.0",
|
||||
"vite": "6.2.5",
|
||||
"vite-plugin-dts": "4.5.3",
|
||||
"vitest": "3.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-native-async-storage/async-storage": ">=2.1.0",
|
||||
"react": ">=16.8.0",
|
||||
"react-native": ">=0.60.0",
|
||||
"react-native-webview": ">=13.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { SurveyWebView } from "@/components/survey-web-view";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { setup } from "@/lib/common/setup";
|
||||
import { SurveyStore } from "@/lib/survey/store";
|
||||
import React, { useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
|
||||
interface FormbricksProps {
|
||||
appUrl: string;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
const surveyStore = SurveyStore.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
export function Formbricks({ appUrl, environmentId }: FormbricksProps): React.JSX.Element | null {
|
||||
// initializes sdk
|
||||
useEffect(() => {
|
||||
const setupFormbricks = async (): Promise<void> => {
|
||||
try {
|
||||
await setup({
|
||||
environmentId,
|
||||
appUrl,
|
||||
});
|
||||
} catch {
|
||||
logger.debug("Initialization failed");
|
||||
}
|
||||
};
|
||||
|
||||
setupFormbricks().catch(() => {
|
||||
logger.debug("Initialization error");
|
||||
});
|
||||
}, [environmentId, appUrl]);
|
||||
|
||||
const subscribe = useCallback((callback: () => void) => {
|
||||
const unsubscribe = surveyStore.subscribe(callback);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const getSnapshot = useCallback(() => surveyStore.getSurvey(), []);
|
||||
const survey = useSyncExternalStore(subscribe, getSnapshot);
|
||||
|
||||
return survey ? <SurveyWebView survey={survey} /> : null;
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
/* eslint-disable no-console -- debugging*/
|
||||
import { RNConfig } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { filterSurveys, getLanguageCode, getStyling } from "@/lib/common/utils";
|
||||
import { SurveyStore } from "@/lib/survey/store";
|
||||
import { type TEnvironmentStateSurvey, type TUserState, ZJsRNWebViewOnMessageData } from "@/types/config";
|
||||
import type { SurveyContainerProps } from "@/types/survey";
|
||||
import React, { type JSX, useEffect, useRef, useState } from "react";
|
||||
import { Modal } from "react-native";
|
||||
import { WebView, type WebViewMessageEvent } from "react-native-webview";
|
||||
|
||||
const appConfig = RNConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
logger.configure({ logLevel: "debug" });
|
||||
|
||||
const surveyStore = SurveyStore.getInstance();
|
||||
|
||||
interface SurveyWebViewProps {
|
||||
survey: TEnvironmentStateSurvey;
|
||||
}
|
||||
|
||||
export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | undefined {
|
||||
const webViewRef = useRef(null);
|
||||
const [isSurveyRunning, setIsSurveyRunning] = useState(false);
|
||||
const [showSurvey, setShowSurvey] = useState(false);
|
||||
|
||||
const project = appConfig.get().environment.data.project;
|
||||
const language = appConfig.get().user.data.language;
|
||||
|
||||
const styling = getStyling(project, survey);
|
||||
const isBrandingEnabled = project.inAppSurveyBranding;
|
||||
const isMultiLanguageSurvey = survey.languages.length > 1;
|
||||
const [languageCode, setLanguageCode] = useState("default");
|
||||
|
||||
useEffect(() => {
|
||||
if (isMultiLanguageSurvey) {
|
||||
const displayLanguage = getLanguageCode(survey, language);
|
||||
if (!displayLanguage) {
|
||||
logger.debug(`Survey "${survey.name}" is not available in specified language.`);
|
||||
setIsSurveyRunning(false);
|
||||
setShowSurvey(false);
|
||||
surveyStore.resetSurvey();
|
||||
return;
|
||||
}
|
||||
setLanguageCode(displayLanguage);
|
||||
setIsSurveyRunning(true);
|
||||
} else {
|
||||
setIsSurveyRunning(true);
|
||||
}
|
||||
}, [isMultiLanguageSurvey, language, survey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSurveyRunning) {
|
||||
setShowSurvey(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (survey.delay) {
|
||||
logger.debug(`Delaying survey "${survey.name}" by ${String(survey.delay)} seconds`);
|
||||
const timerId = setTimeout(() => {
|
||||
setShowSurvey(true);
|
||||
}, survey.delay * 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timerId);
|
||||
};
|
||||
}
|
||||
|
||||
setShowSurvey(true);
|
||||
}, [survey.delay, isSurveyRunning, survey.name]);
|
||||
|
||||
const onCloseSurvey = (): void => {
|
||||
const { environment: environmentState, user: personState } = appConfig.get();
|
||||
const filteredSurveys = filterSurveys(environmentState, personState);
|
||||
|
||||
appConfig.update({
|
||||
...appConfig.get(),
|
||||
environment: environmentState,
|
||||
user: personState,
|
||||
filteredSurveys,
|
||||
});
|
||||
|
||||
surveyStore.resetSurvey();
|
||||
setShowSurvey(false);
|
||||
};
|
||||
|
||||
const surveyPlacement = survey.projectOverwrites?.placement ?? project.placement;
|
||||
const clickOutside = survey.projectOverwrites?.clickOutsideClose ?? project.clickOutsideClose;
|
||||
const darkOverlay = survey.projectOverwrites?.darkOverlay ?? project.darkOverlay;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
animationType="slide"
|
||||
visible={showSurvey}
|
||||
transparent
|
||||
onRequestClose={() => {
|
||||
setShowSurvey(false);
|
||||
setIsSurveyRunning(false);
|
||||
}}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
originWhitelist={["*"]}
|
||||
source={{
|
||||
html: renderHtml({
|
||||
environmentId: appConfig.get().environmentId,
|
||||
contactId: appConfig.get().user.data.contactId ?? undefined,
|
||||
survey,
|
||||
isBrandingEnabled,
|
||||
styling,
|
||||
languageCode,
|
||||
placement: surveyPlacement,
|
||||
appUrl: appConfig.get().appUrl,
|
||||
clickOutside: surveyPlacement === "center" ? clickOutside : true,
|
||||
darkOverlay,
|
||||
getSetIsResponseSendingFinished: (_f: (value: boolean) => void) => undefined,
|
||||
isWebEnvironment: false,
|
||||
}),
|
||||
}}
|
||||
style={{ backgroundColor: "transparent" }}
|
||||
contentMode="mobile"
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
startInLoadingState
|
||||
mixedContentMode="always"
|
||||
allowFileAccess
|
||||
allowFileAccessFromFileURLs
|
||||
allowUniversalAccessFromFileURLs
|
||||
onShouldStartLoadWithRequest={(event) => {
|
||||
// prevent webview from redirecting if users taps on formbricks link.
|
||||
if (event.url.startsWith("https://formbricks")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
onMessage={(event: WebViewMessageEvent) => {
|
||||
try {
|
||||
const { data } = event.nativeEvent;
|
||||
const unvalidatedMessage = JSON.parse(data) as { type: string; data: unknown };
|
||||
|
||||
// debugger
|
||||
if (unvalidatedMessage.type === "Console") {
|
||||
console.info(`[Console] ${JSON.stringify(unvalidatedMessage.data)}`);
|
||||
}
|
||||
|
||||
const validatedMessage = ZJsRNWebViewOnMessageData.safeParse(unvalidatedMessage);
|
||||
if (!validatedMessage.success) {
|
||||
logger.error("Error parsing message from WebView.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { onDisplayCreated, onResponseCreated, onClose } = validatedMessage.data;
|
||||
if (onDisplayCreated) {
|
||||
const existingDisplays = appConfig.get().user.data.displays;
|
||||
const newDisplay = { surveyId: survey.id, createdAt: new Date() };
|
||||
|
||||
const displays = [...existingDisplays, newDisplay];
|
||||
const previousConfig = appConfig.get();
|
||||
|
||||
const updatedPersonState = {
|
||||
...previousConfig.user,
|
||||
data: {
|
||||
...previousConfig.user.data,
|
||||
displays,
|
||||
lastDisplayAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
const filteredSurveys = filterSurveys(previousConfig.environment, updatedPersonState);
|
||||
|
||||
appConfig.update({
|
||||
...previousConfig,
|
||||
environment: previousConfig.environment,
|
||||
user: updatedPersonState,
|
||||
filteredSurveys,
|
||||
});
|
||||
}
|
||||
if (onResponseCreated) {
|
||||
const responses = appConfig.get().user.data.responses;
|
||||
const newPersonState: TUserState = {
|
||||
...appConfig.get().user,
|
||||
data: {
|
||||
...appConfig.get().user.data,
|
||||
responses: [...responses, survey.id],
|
||||
},
|
||||
};
|
||||
|
||||
const filteredSurveys = filterSurveys(appConfig.get().environment, newPersonState);
|
||||
|
||||
appConfig.update({
|
||||
...appConfig.get(),
|
||||
environment: appConfig.get().environment,
|
||||
user: newPersonState,
|
||||
filteredSurveys,
|
||||
});
|
||||
}
|
||||
if (onClose) {
|
||||
onCloseSurvey();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error handling WebView message: ${error as string}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const renderHtml = (options: Partial<SurveyContainerProps> & { appUrl?: string }): string => {
|
||||
return `
|
||||
<!doctype html>
|
||||
<html>
|
||||
<meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0">
|
||||
<head>
|
||||
<title>Formbricks WebView Survey</title>
|
||||
</head>
|
||||
<body style="overflow: hidden; height: 100vh; margin: 0;">
|
||||
</body>
|
||||
|
||||
<script type="text/javascript">
|
||||
const consoleLog = (type, log) => window.ReactNativeWebView.postMessage(JSON.stringify({'type': 'Console', 'data': {'type': type, 'log': log}}));
|
||||
console = {
|
||||
log: (log) => consoleLog('log', log),
|
||||
debug: (log) => consoleLog('debug', log),
|
||||
info: (log) => consoleLog('info', log),
|
||||
warn: (log) => consoleLog('warn', log),
|
||||
error: (log) => consoleLog('error', log),
|
||||
};
|
||||
|
||||
function onClose() {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ onClose: true }));
|
||||
};
|
||||
|
||||
function onDisplayCreated() {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ onDisplayCreated: true }));
|
||||
};
|
||||
|
||||
function onResponseCreated() {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ onResponseCreated: true }));
|
||||
};
|
||||
|
||||
function loadSurvey() {
|
||||
const options = ${JSON.stringify(options)};
|
||||
const surveyProps = {
|
||||
...options,
|
||||
onDisplayCreated,
|
||||
onResponseCreated,
|
||||
onClose,
|
||||
};
|
||||
|
||||
window.formbricksSurveys.renderSurvey(surveyProps);
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = "${options.appUrl ?? "http://localhost:3000"}/js/surveys.umd.cjs";
|
||||
script.async = true;
|
||||
script.onload = () => loadSurvey();
|
||||
script.onerror = (error) => {
|
||||
console.error("Failed to load Formbricks Surveys library:", error);
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
</script>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Formbricks } from "@/components/formbricks";
|
||||
import { CommandQueue } from "@/lib/common/command-queue";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import * as Actions from "@/lib/survey/action";
|
||||
import * as Attributes from "@/lib/user/attribute";
|
||||
import * as User from "@/lib/user/user";
|
||||
|
||||
const logger = Logger.getInstance();
|
||||
logger.debug("Create command queue");
|
||||
const queue = new CommandQueue();
|
||||
|
||||
export const track = async (name: string): Promise<void> => {
|
||||
queue.add(Actions.track, true, name);
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
export const setUserId = async (userId: string): Promise<void> => {
|
||||
queue.add(User.setUserId, true, userId);
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
export const setAttribute = async (key: string, value: string): Promise<void> => {
|
||||
queue.add(Attributes.setAttributes, true, { [key]: value });
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
export const setAttributes = async (attributes: Record<string, string>): Promise<void> => {
|
||||
queue.add(Attributes.setAttributes, true, attributes);
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
export const setLanguage = async (language: string): Promise<void> => {
|
||||
queue.add(Attributes.setAttributes, true, { language });
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
export const logout = async (): Promise<void> => {
|
||||
queue.add(User.logout, true);
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
export default Formbricks;
|
||||
@@ -1,102 +0,0 @@
|
||||
import { wrapThrowsAsync } from "@/lib/common/utils";
|
||||
import { ApiResponse, ApiSuccessResponse, CreateOrUpdateUserResponse } from "@/types/api";
|
||||
import { TEnvironmentState } from "@/types/config";
|
||||
import { ApiErrorResponse, Result, err, ok } from "@/types/error";
|
||||
|
||||
export const makeRequest = async <T>(
|
||||
appUrl: string,
|
||||
endpoint: string,
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
data?: unknown,
|
||||
isDebug = false
|
||||
): Promise<Result<T, ApiErrorResponse>> => {
|
||||
const url = new URL(appUrl + endpoint);
|
||||
const body = data ? JSON.stringify(data) : undefined;
|
||||
|
||||
const res = await wrapThrowsAsync(fetch)(url.toString(), {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(isDebug && { "Cache-Control": "no-cache" }),
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return err({
|
||||
code: "network_error",
|
||||
status: 500,
|
||||
message: "Something went wrong",
|
||||
});
|
||||
}
|
||||
|
||||
const response = res.data;
|
||||
const json = (await response.json()) as ApiResponse;
|
||||
|
||||
if (!response.ok) {
|
||||
const errorResponse = json as ApiErrorResponse;
|
||||
return err({
|
||||
code: errorResponse.code === "forbidden" ? "forbidden" : "network_error",
|
||||
status: response.status,
|
||||
message: errorResponse.message || "Something went wrong",
|
||||
url,
|
||||
...(Object.keys(errorResponse.details ?? {}).length > 0 && { details: errorResponse.details }),
|
||||
});
|
||||
}
|
||||
|
||||
const successResponse = json as ApiSuccessResponse<T>;
|
||||
return ok(successResponse.data);
|
||||
};
|
||||
|
||||
// Simple API client using fetch
|
||||
export class ApiClient {
|
||||
private appUrl: string;
|
||||
private environmentId: string;
|
||||
private isDebug: boolean;
|
||||
|
||||
constructor({
|
||||
appUrl,
|
||||
environmentId,
|
||||
isDebug = false,
|
||||
}: {
|
||||
appUrl: string;
|
||||
environmentId: string;
|
||||
isDebug: boolean;
|
||||
}) {
|
||||
this.appUrl = appUrl;
|
||||
this.environmentId = environmentId;
|
||||
this.isDebug = isDebug;
|
||||
}
|
||||
|
||||
async createOrUpdateUser(userUpdateInput: {
|
||||
userId: string;
|
||||
attributes?: Record<string, string>;
|
||||
}): Promise<Result<CreateOrUpdateUserResponse, ApiErrorResponse>> {
|
||||
// transform all attributes to string if attributes are present into a new attributes copy
|
||||
const attributes: Record<string, string> = {};
|
||||
for (const key in userUpdateInput.attributes) {
|
||||
attributes[key] = String(userUpdateInput.attributes[key]);
|
||||
}
|
||||
|
||||
return makeRequest(
|
||||
this.appUrl,
|
||||
`/api/v2/client/${this.environmentId}/user`,
|
||||
"POST",
|
||||
{
|
||||
userId: userUpdateInput.userId,
|
||||
attributes,
|
||||
},
|
||||
this.isDebug
|
||||
);
|
||||
}
|
||||
|
||||
async getEnvironmentState(): Promise<Result<TEnvironmentState, ApiErrorResponse>> {
|
||||
return makeRequest(
|
||||
this.appUrl,
|
||||
`/api/v1/client/${this.environmentId}/environment`,
|
||||
"GET",
|
||||
undefined,
|
||||
this.isDebug
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/* eslint-disable no-console -- we need to log global errors */
|
||||
import { checkSetup } from "@/lib/common/setup";
|
||||
import { wrapThrowsAsync } from "@/lib/common/utils";
|
||||
import type { Result } from "@/types/error";
|
||||
|
||||
export class CommandQueue {
|
||||
private queue: {
|
||||
command: (...args: any[]) => Promise<Result<void, unknown>> | Result<void, unknown> | Promise<void>;
|
||||
checkSetup: boolean;
|
||||
commandArgs: any[];
|
||||
}[] = [];
|
||||
private running = false;
|
||||
private resolvePromise: (() => void) | null = null;
|
||||
private commandPromise: Promise<void> | null = null;
|
||||
|
||||
public add<A>(
|
||||
command: (...args: A[]) => Promise<Result<void, unknown>> | Result<void, unknown> | Promise<void>,
|
||||
shouldCheckSetup = true,
|
||||
...args: A[]
|
||||
): void {
|
||||
this.queue.push({ command, checkSetup: shouldCheckSetup, commandArgs: args });
|
||||
|
||||
if (!this.running) {
|
||||
this.commandPromise = new Promise((resolve) => {
|
||||
this.resolvePromise = resolve;
|
||||
void this.run();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async wait(): Promise<void> {
|
||||
if (this.running) {
|
||||
await this.commandPromise;
|
||||
}
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
this.running = true;
|
||||
while (this.queue.length > 0) {
|
||||
const currentItem = this.queue.shift();
|
||||
|
||||
if (!currentItem) continue;
|
||||
|
||||
// make sure formbricks is setup
|
||||
if (currentItem.checkSetup) {
|
||||
// call different function based on package type
|
||||
const setupResult = checkSetup();
|
||||
|
||||
if (!setupResult.ok) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const executeCommand = async (): Promise<Result<void, unknown>> => {
|
||||
return (await currentItem.command.apply(null, currentItem.commandArgs)) as Result<void, unknown>;
|
||||
};
|
||||
|
||||
const result = await wrapThrowsAsync(executeCommand)();
|
||||
|
||||
if (!result.ok) {
|
||||
console.error("🧱 Formbricks - Global error: ", result.error);
|
||||
} else if (!result.data.ok) {
|
||||
console.error("🧱 Formbricks - Global error: ", result.data.error);
|
||||
}
|
||||
}
|
||||
this.running = false;
|
||||
if (this.resolvePromise) {
|
||||
this.resolvePromise();
|
||||
this.resolvePromise = null;
|
||||
this.commandPromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/* 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 Result, err, ok } from "@/types/error";
|
||||
|
||||
export const RN_ASYNC_STORAGE_KEY = "formbricks-react-native";
|
||||
|
||||
export class RNConfig {
|
||||
private static instance: RNConfig | null = null;
|
||||
|
||||
private config: TConfig | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.loadFromStorage()
|
||||
.then((localConfig) => {
|
||||
if (localConfig.ok) {
|
||||
this.config = localConfig.data;
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
console.error("Error loading config from storage", e);
|
||||
});
|
||||
}
|
||||
|
||||
static getInstance(): RNConfig {
|
||||
if (!RNConfig.instance) {
|
||||
RNConfig.instance = new RNConfig();
|
||||
}
|
||||
|
||||
return RNConfig.instance;
|
||||
}
|
||||
|
||||
public update(newConfig: TConfigUpdateInput): void {
|
||||
this.config = {
|
||||
...this.config,
|
||||
...newConfig,
|
||||
status: {
|
||||
value: newConfig.status?.value ?? "success",
|
||||
expiresAt: newConfig.status?.expiresAt ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
void this.saveToStorage();
|
||||
}
|
||||
|
||||
public get(): TConfig {
|
||||
if (!this.config) {
|
||||
throw new Error("config is null, maybe the init function was not called?");
|
||||
}
|
||||
return this.config;
|
||||
}
|
||||
|
||||
public async loadFromStorage(): Promise<Result<TConfig>> {
|
||||
try {
|
||||
const savedConfig = await AsyncStorage.getItem(RN_ASYNC_STORAGE_KEY);
|
||||
if (savedConfig) {
|
||||
const parsedConfig = JSON.parse(savedConfig) as TConfig;
|
||||
|
||||
// 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()
|
||||
) {
|
||||
return err(new Error("Config in local storage has expired"));
|
||||
}
|
||||
|
||||
return ok(parsedConfig);
|
||||
}
|
||||
} catch {
|
||||
return err(new Error("No or invalid config in local storage"));
|
||||
}
|
||||
|
||||
return err(new Error("No or invalid config in local storage"));
|
||||
}
|
||||
|
||||
private async saveToStorage(): Promise<Result<void>> {
|
||||
return wrapThrowsAsync(async () => {
|
||||
await AsyncStorage.setItem(RN_ASYNC_STORAGE_KEY, JSON.stringify(this.config));
|
||||
})();
|
||||
}
|
||||
|
||||
// reset the config
|
||||
public async resetConfig(): Promise<Result<void>> {
|
||||
this.config = null;
|
||||
|
||||
return wrapThrowsAsync(async () => {
|
||||
await AsyncStorage.removeItem(RN_ASYNC_STORAGE_KEY);
|
||||
})();
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import {
|
||||
addEnvironmentStateExpiryCheckListener,
|
||||
clearEnvironmentStateExpiryCheckListener,
|
||||
} from "@/lib/environment/state";
|
||||
import { addUserStateExpiryCheckListener, clearUserStateExpiryCheckListener } from "@/lib/user/state";
|
||||
|
||||
let areRemoveEventListenersAdded = false;
|
||||
|
||||
export const addEventListeners = (): void => {
|
||||
addEnvironmentStateExpiryCheckListener();
|
||||
addUserStateExpiryCheckListener();
|
||||
};
|
||||
|
||||
export const addCleanupEventListeners = (): void => {
|
||||
if (areRemoveEventListenersAdded) return;
|
||||
clearEnvironmentStateExpiryCheckListener();
|
||||
clearUserStateExpiryCheckListener();
|
||||
areRemoveEventListenersAdded = true;
|
||||
};
|
||||
|
||||
export const removeCleanupEventListeners = (): void => {
|
||||
if (!areRemoveEventListenersAdded) return;
|
||||
clearEnvironmentStateExpiryCheckListener();
|
||||
clearUserStateExpiryCheckListener();
|
||||
areRemoveEventListenersAdded = false;
|
||||
};
|
||||
|
||||
export const removeAllEventListeners = (): void => {
|
||||
clearEnvironmentStateExpiryCheckListener();
|
||||
clearUserStateExpiryCheckListener();
|
||||
removeCleanupEventListeners();
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
/* eslint-disable no-console -- used for error logging */
|
||||
import { type TUploadFileConfig, type TUploadFileResponse } from "@/types/storage";
|
||||
|
||||
export class StorageAPI {
|
||||
private appUrl: string;
|
||||
private environmentId: string;
|
||||
|
||||
constructor(appUrl: string, environmentId: string) {
|
||||
this.appUrl = appUrl;
|
||||
this.environmentId = environmentId;
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
file: {
|
||||
type: string;
|
||||
name: string;
|
||||
base64: string;
|
||||
},
|
||||
{ allowedFileExtensions, surveyId }: TUploadFileConfig | undefined = {}
|
||||
): Promise<string> {
|
||||
if (!file.name || !file.type || !file.base64) {
|
||||
throw new Error(`Invalid file object`);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
fileName: file.name,
|
||||
fileType: file.type,
|
||||
allowedFileExtensions,
|
||||
surveyId,
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.appUrl}/api/v1/client/${this.environmentId}/storage`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed with status: ${String(response.status)}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as TUploadFileResponse;
|
||||
|
||||
const { data } = json;
|
||||
|
||||
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
|
||||
|
||||
let localUploadDetails: Record<string, string> = {};
|
||||
|
||||
if (signingData) {
|
||||
const { signature, timestamp, uuid } = signingData;
|
||||
|
||||
localUploadDetails = {
|
||||
fileType: file.type,
|
||||
fileName: encodeURIComponent(updatedFileName),
|
||||
surveyId: surveyId ?? "",
|
||||
signature,
|
||||
timestamp: String(timestamp),
|
||||
uuid,
|
||||
};
|
||||
}
|
||||
|
||||
const formData: Record<string, string> = {};
|
||||
const formDataForS3 = new FormData();
|
||||
|
||||
if (presignedFields) {
|
||||
Object.keys(presignedFields).forEach((key) => {
|
||||
formDataForS3.append(key, presignedFields[key]);
|
||||
});
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(file.base64.split(",")[1], "base64");
|
||||
const blob = new Blob([buffer], { type: file.type });
|
||||
|
||||
formDataForS3.append("file", blob);
|
||||
} catch (buffErr) {
|
||||
console.error({ buffErr });
|
||||
|
||||
throw new Error("Error uploading file");
|
||||
}
|
||||
}
|
||||
|
||||
formData.fileBase64String = file.base64;
|
||||
|
||||
let uploadResponse: Response = {} as Response;
|
||||
|
||||
const signedUrlCopy = signedUrl.replace("http://localhost:3000", this.appUrl);
|
||||
|
||||
try {
|
||||
uploadResponse = await fetch(signedUrlCopy, {
|
||||
method: "POST",
|
||||
body: presignedFields
|
||||
? formDataForS3
|
||||
: JSON.stringify({
|
||||
...formData,
|
||||
...localUploadDetails,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error uploading file", err);
|
||||
}
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
// if local storage is used, we'll use the json response:
|
||||
if (signingData) {
|
||||
const uploadJson = (await uploadResponse.json()) as { message: string };
|
||||
const error = new Error(uploadJson.message);
|
||||
error.name = "FileTooLargeError";
|
||||
throw error;
|
||||
}
|
||||
|
||||
// if s3 is used, we'll use the text response:
|
||||
const errorText = await uploadResponse.text();
|
||||
if (presignedFields && errorText.includes("EntityTooLarge")) {
|
||||
const error = new Error("File size exceeds the size limit for your plan");
|
||||
error.name = "FileTooLargeError";
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(`Upload failed with status: ${String(uploadResponse.status)}`);
|
||||
}
|
||||
|
||||
return fileUrl;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/* eslint-disable no-console -- Required for logging */
|
||||
type LogLevel = "debug" | "error";
|
||||
|
||||
interface LoggerConfig {
|
||||
logLevel?: LogLevel;
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
private static instance: Logger | undefined;
|
||||
private logLevel: LogLevel = "error";
|
||||
|
||||
static getInstance(): Logger {
|
||||
if (!Logger.instance) {
|
||||
Logger.instance = new Logger();
|
||||
}
|
||||
return Logger.instance;
|
||||
}
|
||||
|
||||
configure(config: LoggerConfig): void {
|
||||
if (config.logLevel !== undefined) {
|
||||
this.logLevel = config.logLevel;
|
||||
}
|
||||
}
|
||||
|
||||
private logger(message: string, level: LogLevel): void {
|
||||
if (level === "debug" && this.logLevel !== "debug") {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const logMessage = `🧱 Formbricks - ${timestamp} [${level.toUpperCase()}] - ${message}`;
|
||||
if (level === "error") {
|
||||
console.error(logMessage);
|
||||
} else {
|
||||
console.log(logMessage);
|
||||
}
|
||||
}
|
||||
|
||||
debug(message: string): void {
|
||||
this.logger(message, "debug");
|
||||
}
|
||||
|
||||
error(message: string): void {
|
||||
this.logger(message, "error");
|
||||
}
|
||||
|
||||
public resetInstance(): void {
|
||||
Logger.instance = undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
import { RNConfig, RN_ASYNC_STORAGE_KEY } from "@/lib/common/config";
|
||||
import {
|
||||
addCleanupEventListeners,
|
||||
addEventListeners,
|
||||
removeAllEventListeners,
|
||||
} from "@/lib/common/event-listeners";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { AsyncStorage } from "@/lib/common/storage";
|
||||
import { filterSurveys, 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 { type TConfig, type TConfigInput, type TEnvironmentState, type TUserState } from "@/types/config";
|
||||
import {
|
||||
type MissingFieldError,
|
||||
type MissingPersonError,
|
||||
type NetworkError,
|
||||
type NotSetupError,
|
||||
type Result,
|
||||
err,
|
||||
okVoid,
|
||||
} from "@/types/error";
|
||||
|
||||
let isSetup = false;
|
||||
|
||||
export const setIsSetup = (state: boolean): void => {
|
||||
isSetup = state;
|
||||
};
|
||||
|
||||
export const migrateUserStateAddContactId = async (): Promise<{ changed: boolean }> => {
|
||||
const existingConfigString = await AsyncStorage.getItem(RN_ASYNC_STORAGE_KEY);
|
||||
|
||||
if (existingConfigString) {
|
||||
const existingConfig = JSON.parse(existingConfigString) as Partial<TConfig>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- data could be undefined
|
||||
if (existingConfig.user?.data?.contactId) {
|
||||
return { changed: false };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- data could be undefined
|
||||
if (!existingConfig.user?.data?.contactId && existingConfig.user?.data?.userId) {
|
||||
return { changed: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { changed: false };
|
||||
};
|
||||
|
||||
export const setup = async (
|
||||
configInput: TConfigInput
|
||||
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
|
||||
let appConfig = RNConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
const { changed } = await migrateUserStateAddContactId();
|
||||
|
||||
if (changed) {
|
||||
await appConfig.resetConfig();
|
||||
appConfig = RNConfig.getInstance();
|
||||
}
|
||||
|
||||
if (isSetup) {
|
||||
logger.debug("Already set up, skipping setup.");
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
let existingConfig: TConfig | undefined;
|
||||
try {
|
||||
existingConfig = appConfig.get();
|
||||
logger.debug("Found existing configuration.");
|
||||
} catch {
|
||||
logger.debug("No existing configuration found.");
|
||||
}
|
||||
|
||||
// formbricks is in error state, skip setup
|
||||
if (existingConfig?.status.value === "error") {
|
||||
logger.debug("Formbricks was set to an error state.");
|
||||
|
||||
const expiresAt = existingConfig.status.expiresAt;
|
||||
|
||||
if (expiresAt && isNowExpired(expiresAt)) {
|
||||
logger.debug("Error state is not expired, skipping setup");
|
||||
return okVoid();
|
||||
}
|
||||
logger.debug("Error state is expired. Continue with setup.");
|
||||
}
|
||||
|
||||
logger.debug("Start setup");
|
||||
|
||||
if (!configInput.environmentId) {
|
||||
logger.debug("No environmentId provided");
|
||||
return err({
|
||||
code: "missing_field",
|
||||
field: "environmentId",
|
||||
});
|
||||
}
|
||||
|
||||
if (!configInput.appUrl) {
|
||||
logger.debug("No appUrl provided");
|
||||
|
||||
return err({
|
||||
code: "missing_field",
|
||||
field: "appUrl",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
existingConfig?.environment &&
|
||||
existingConfig.environmentId === configInput.environmentId &&
|
||||
existingConfig.appUrl === configInput.appUrl
|
||||
) {
|
||||
logger.debug("Configuration fits setup parameters.");
|
||||
let isEnvironmentStateExpired = false;
|
||||
let isUserStateExpired = false;
|
||||
|
||||
if (isNowExpired(existingConfig.environment.expiresAt)) {
|
||||
logger.debug("Environment state expired. Syncing.");
|
||||
isEnvironmentStateExpired = true;
|
||||
}
|
||||
|
||||
if (existingConfig.user.expiresAt && isNowExpired(existingConfig.user.expiresAt)) {
|
||||
logger.debug("Person state expired. Syncing.");
|
||||
isUserStateExpired = true;
|
||||
}
|
||||
|
||||
try {
|
||||
// fetch the environment state (if expired)
|
||||
let environmentState: TEnvironmentState = existingConfig.environment;
|
||||
let userState: TUserState = existingConfig.user;
|
||||
|
||||
if (isEnvironmentStateExpired) {
|
||||
const environmentStateResponse = await fetchEnvironmentState({
|
||||
appUrl: configInput.appUrl,
|
||||
environmentId: configInput.environmentId,
|
||||
});
|
||||
|
||||
if (environmentStateResponse.ok) {
|
||||
environmentState = environmentStateResponse.data;
|
||||
} else {
|
||||
logger.error(
|
||||
`Error fetching environment state: ${environmentStateResponse.error.code} - ${environmentStateResponse.error.responseMessage ?? ""}`
|
||||
);
|
||||
return err({
|
||||
code: "network_error",
|
||||
message: "Error fetching environment state",
|
||||
status: 500,
|
||||
url: new URL(`${configInput.appUrl}/api/v1/client/${configInput.environmentId}/environment`),
|
||||
responseMessage: environmentStateResponse.error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isUserStateExpired) {
|
||||
// If the existing person state (expired) has a userId, we need to fetch the person state
|
||||
// If the existing person state (expired) has no userId, we need to set the person state to the default
|
||||
|
||||
if (userState.data.userId) {
|
||||
const updatesResponse = await sendUpdatesToBackend({
|
||||
appUrl: configInput.appUrl,
|
||||
environmentId: configInput.environmentId,
|
||||
updates: {
|
||||
userId: userState.data.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (updatesResponse.ok) {
|
||||
userState = updatesResponse.data.state;
|
||||
} else {
|
||||
logger.error(
|
||||
`Error updating user state: ${updatesResponse.error.code} - ${updatesResponse.error.responseMessage ?? ""}`
|
||||
);
|
||||
return err({
|
||||
code: "network_error",
|
||||
message: "Error updating user state",
|
||||
status: 500,
|
||||
url: new URL(
|
||||
`${configInput.appUrl}/api/v1/client/${configInput.environmentId}/update/contacts/${userState.data.userId}`
|
||||
),
|
||||
responseMessage: "Unknown error",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
userState = DEFAULT_USER_STATE_NO_USER_ID;
|
||||
}
|
||||
}
|
||||
|
||||
// filter the environment state wrt the person state
|
||||
const filteredSurveys = filterSurveys(environmentState, userState);
|
||||
|
||||
// update the appConfig with the new filtered surveys and person state
|
||||
appConfig.update({
|
||||
...existingConfig,
|
||||
environment: environmentState,
|
||||
user: userState,
|
||||
filteredSurveys,
|
||||
});
|
||||
|
||||
const surveyNames = filteredSurveys.map((s) => s.name);
|
||||
logger.debug(`Fetched ${surveyNames.length.toString()} surveys during sync: ${surveyNames.join(", ")}`);
|
||||
} catch {
|
||||
logger.debug("Error during sync. Please try again.");
|
||||
}
|
||||
} else {
|
||||
logger.debug("No valid configuration found. Resetting config and creating new one.");
|
||||
void appConfig.resetConfig();
|
||||
logger.debug("Syncing.");
|
||||
|
||||
// During setup, if we don't have a valid config, we need to fetch the environment state
|
||||
// but not the person state, we can set it to the default value.
|
||||
// The person state will be fetched when the `setUserId` method is called.
|
||||
|
||||
try {
|
||||
const environmentStateResponse = await fetchEnvironmentState({
|
||||
appUrl: configInput.appUrl,
|
||||
environmentId: configInput.environmentId,
|
||||
});
|
||||
|
||||
if (!environmentStateResponse.ok) {
|
||||
// eslint-disable-next-line @typescript-eslint/only-throw-error -- error is ApiErrorResponse
|
||||
throw environmentStateResponse.error;
|
||||
}
|
||||
|
||||
const personState = DEFAULT_USER_STATE_NO_USER_ID;
|
||||
const environmentState = environmentStateResponse.data;
|
||||
|
||||
const filteredSurveys = filterSurveys(environmentState, personState);
|
||||
|
||||
appConfig.update({
|
||||
appUrl: configInput.appUrl,
|
||||
environmentId: configInput.environmentId,
|
||||
user: personState,
|
||||
environment: environmentState,
|
||||
filteredSurveys,
|
||||
});
|
||||
} catch (e) {
|
||||
await handleErrorOnFirstSetup(e as { code: string; responseMessage: string });
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Adding event listeners");
|
||||
addEventListeners();
|
||||
addCleanupEventListeners();
|
||||
|
||||
setIsSetup(true);
|
||||
logger.debug("Set up complete");
|
||||
|
||||
// check page url if set up after page load
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
export const checkSetup = (): Result<void, NotSetupError> => {
|
||||
const logger = Logger.getInstance();
|
||||
logger.debug("Check if set up");
|
||||
|
||||
if (!isSetup) {
|
||||
return err({
|
||||
code: "not_setup",
|
||||
message: "Formbricks is not set up. Call setup() first.",
|
||||
});
|
||||
}
|
||||
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await -- disabled for now
|
||||
export const tearDown = async (): Promise<void> => {
|
||||
const logger = Logger.getInstance();
|
||||
const appConfig = RNConfig.getInstance();
|
||||
|
||||
logger.debug("Setting user state to default");
|
||||
// clear the user state and set it to the default value
|
||||
appConfig.update({
|
||||
...appConfig.get(),
|
||||
user: DEFAULT_USER_STATE_NO_USER_ID,
|
||||
});
|
||||
|
||||
setIsSetup(false);
|
||||
removeAllEventListeners();
|
||||
};
|
||||
|
||||
export const handleErrorOnFirstSetup = async (e: {
|
||||
code: string;
|
||||
responseMessage: string;
|
||||
}): Promise<never> => {
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
if (e.code === "forbidden") {
|
||||
logger.error(`Authorization error: ${e.responseMessage}`);
|
||||
} else {
|
||||
logger.error(`Error during first setup: ${e.code} - ${e.responseMessage}. Please try again later.`);
|
||||
}
|
||||
|
||||
// put formbricks in error state (by creating a new config) and throw error
|
||||
const initialErrorConfig: Partial<TConfig> = {
|
||||
status: {
|
||||
value: "error",
|
||||
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
|
||||
},
|
||||
};
|
||||
|
||||
await wrapThrowsAsync(async () => {
|
||||
await AsyncStorage.setItem(RN_ASYNC_STORAGE_KEY, JSON.stringify(initialErrorConfig));
|
||||
})();
|
||||
|
||||
throw new Error("Could not set up formbricks");
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import AsyncStorageModule from "@react-native-async-storage/async-storage";
|
||||
|
||||
const AsyncStorageWithDefault = AsyncStorageModule as typeof AsyncStorageModule & {
|
||||
default?: typeof AsyncStorageModule;
|
||||
};
|
||||
|
||||
const AsyncStorage = AsyncStorageWithDefault.default ?? AsyncStorageModule;
|
||||
|
||||
export { AsyncStorage };
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { TConfig } from "@/types/config";
|
||||
|
||||
// ids
|
||||
export const mockEnvironmentId = "ggskhsue85p2xrxrc7x3qagg";
|
||||
export const mockProjectId = "f5kptre0saxmltl7ram364qt";
|
||||
export const mockLanguageId = "n4ts6u7wy5lbn4q3jovikqot";
|
||||
export const mockSurveyId = "lz5m554yqh1i3moa3y230wei";
|
||||
export const mockActionClassId = "wypzu5qw7adgy66vq8s77tso";
|
||||
|
||||
export const mockConfig: TConfig = {
|
||||
environmentId: mockEnvironmentId,
|
||||
appUrl: "https://myapp.example",
|
||||
environment: {
|
||||
expiresAt: "2999-12-31T23:59:59Z",
|
||||
data: {
|
||||
surveys: [
|
||||
{
|
||||
id: mockSurveyId,
|
||||
name: "Onboarding Survey",
|
||||
welcomeCard: null,
|
||||
questions: [],
|
||||
variables: [],
|
||||
type: "app", // "link" or "app"
|
||||
showLanguageSwitch: true,
|
||||
endings: [],
|
||||
autoClose: 5,
|
||||
status: "inProgress", // whatever statuses you use
|
||||
recontactDays: 7,
|
||||
displayLimit: 1,
|
||||
displayOption: "displayMultiple",
|
||||
hiddenFields: [],
|
||||
delay: 5, // e.g. 5s
|
||||
projectOverwrites: {},
|
||||
languages: [
|
||||
{
|
||||
// SurveyLanguage fields
|
||||
surveyId: mockSurveyId,
|
||||
default: true,
|
||||
enabled: true,
|
||||
languageId: mockLanguageId,
|
||||
language: {
|
||||
// Language fields
|
||||
id: mockLanguageId,
|
||||
code: "en",
|
||||
alias: "en",
|
||||
createdAt: "2025-01-01T10:00:00Z",
|
||||
updatedAt: "2025-01-01T10:00:00Z",
|
||||
projectId: mockProjectId,
|
||||
},
|
||||
},
|
||||
],
|
||||
triggers: [
|
||||
{
|
||||
actionClass: {
|
||||
id: mockActionClassId,
|
||||
key: "onboardingTrigger",
|
||||
type: "code",
|
||||
name: "Manual Trigger",
|
||||
createdAt: "2025-01-01T10:00:00Z",
|
||||
updatedAt: "2025-01-01T10:00:00Z",
|
||||
environmentId: mockEnvironmentId,
|
||||
description: "Manual Trigger",
|
||||
noCodeConfig: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
segment: undefined, // or mock your Segment if needed
|
||||
displayPercentage: 100,
|
||||
styling: {
|
||||
// TSurveyStyling
|
||||
overwriteThemeStyling: false,
|
||||
brandColor: { light: "#2B6CB0" },
|
||||
},
|
||||
},
|
||||
],
|
||||
actionClasses: [
|
||||
{
|
||||
id: mockActionClassId,
|
||||
key: "onboardingTrigger",
|
||||
type: "code",
|
||||
name: "Manual Trigger",
|
||||
noCodeConfig: {},
|
||||
},
|
||||
],
|
||||
project: {
|
||||
id: mockProjectId,
|
||||
recontactDays: 14,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
styling: {
|
||||
// TProjectStyling
|
||||
allowStyleOverwrite: true,
|
||||
brandColor: { light: "#319795" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
expiresAt: null,
|
||||
data: {
|
||||
userId: "user_abc",
|
||||
segments: ["beta-testers"],
|
||||
displays: [
|
||||
{
|
||||
surveyId: mockSurveyId,
|
||||
createdAt: "2025-01-01T10:00:00Z",
|
||||
},
|
||||
],
|
||||
responses: [mockSurveyId],
|
||||
lastDisplayAt: "2025-01-02T15:00:00Z",
|
||||
language: "en",
|
||||
},
|
||||
},
|
||||
filteredSurveys: [], // fill if you'd like to pre-filter any surveys
|
||||
attributes: {
|
||||
plan: "premium",
|
||||
region: "US",
|
||||
},
|
||||
status: {
|
||||
value: "success",
|
||||
expiresAt: null,
|
||||
},
|
||||
} as unknown as TConfig;
|
||||
@@ -1,318 +0,0 @@
|
||||
// api.test.ts
|
||||
import { ApiClient, makeRequest } from "@/lib/common/api";
|
||||
import type { TEnvironmentState } from "@/types/config";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
describe("api.ts", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// makeRequest
|
||||
// ---------------------------------------------------------------------------------
|
||||
describe("makeRequest()", () => {
|
||||
test("successful GET request", async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ data: { test: "data" } }),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await makeRequest<{ test: string }>("https://example.com", "/api/test", "GET");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/test", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({ test: "data" });
|
||||
}
|
||||
});
|
||||
|
||||
test("successful POST request with data", async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ data: { test: "data" } }),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await makeRequest<{ test: string }>("https://example.com", "/api/test", "POST", {
|
||||
input: "data",
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ input: "data" }),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({ test: "data" });
|
||||
}
|
||||
});
|
||||
|
||||
test("handles network error", async () => {
|
||||
const mockError = {
|
||||
code: "network_error",
|
||||
message: "Something went wrong",
|
||||
status: 500,
|
||||
};
|
||||
mockFetch.mockRejectedValue(mockError);
|
||||
|
||||
const result = await makeRequest<{ test: string }>("https://example.com", "/api/test", "GET");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe(mockError.code);
|
||||
}
|
||||
});
|
||||
|
||||
test("handles non-OK response", async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
code: "not_found",
|
||||
message: "Resource not found",
|
||||
}),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await makeRequest<{ test: string }>("https://example.com", "/api/test", "GET");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
code: "network_error",
|
||||
status: 404,
|
||||
message: "Resource not found",
|
||||
url: expect.any(URL) as URL,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("handles forbidden response", async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
code: "forbidden",
|
||||
message: "Access forbidden",
|
||||
}),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await makeRequest<{ test: string }>("https://example.com", "/api/test", "GET");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
code: "forbidden",
|
||||
status: 403,
|
||||
message: "Access forbidden",
|
||||
url: expect.any(URL) as URL,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("handles error response with details", async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
code: "bad_request",
|
||||
message: "Invalid input",
|
||||
details: { field: "email", message: "Invalid email format" },
|
||||
}),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await makeRequest<{ test: string }>("https://example.com", "/api/test", "GET");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
code: "network_error",
|
||||
status: 400,
|
||||
message: "Invalid input",
|
||||
url: expect.any(URL) as URL,
|
||||
details: { field: "email", message: "Invalid email format" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("uses debug mode when specified", async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ data: { test: "data" } }),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await makeRequest<{ test: string }>("https://example.com", "/api/test", "GET", undefined, true);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/test", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// ApiClient
|
||||
// ---------------------------------------------------------------------------------
|
||||
describe("ApiClient", () => {
|
||||
let apiClient: ApiClient;
|
||||
|
||||
beforeEach(() => {
|
||||
apiClient = new ApiClient({
|
||||
appUrl: "https://example.com",
|
||||
environmentId: "env123",
|
||||
isDebug: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("creates or updates user successfully", async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
state: {
|
||||
expiresAt: new Date("2023-01-01"),
|
||||
data: {
|
||||
userId: "user123",
|
||||
contactId: "contact123",
|
||||
segments: ["segment1"],
|
||||
displays: [{ surveyId: "survey1", createdAt: new Date() }],
|
||||
responses: ["response1"],
|
||||
lastDisplayAt: new Date(),
|
||||
},
|
||||
},
|
||||
messages: ["User updated successfully"],
|
||||
},
|
||||
}),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await apiClient.createOrUpdateUser({
|
||||
userId: "user123",
|
||||
attributes: { name: "John", age: "30" },
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/v2/client/env123/user", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: "user123",
|
||||
attributes: { name: "John", age: "30" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.state.data.userId).toBe("user123");
|
||||
expect(result.data.messages).toEqual(["User updated successfully"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("creates or updates user with error", async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
code: "bad_request",
|
||||
message: "Invalid user data",
|
||||
}),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await apiClient.createOrUpdateUser({
|
||||
userId: "user123",
|
||||
attributes: { name: "John", age: "30" },
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("network_error");
|
||||
expect(result.error.message).toBe("Invalid user data");
|
||||
}
|
||||
});
|
||||
|
||||
test("gets environment state successfully", async () => {
|
||||
const mockEnvironmentState: TEnvironmentState = {
|
||||
expiresAt: new Date("2023-01-01"),
|
||||
data: {
|
||||
surveys: [],
|
||||
actionClasses: [],
|
||||
project: {
|
||||
id: "project123",
|
||||
recontactDays: 30,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
data: mockEnvironmentState,
|
||||
}),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await apiClient.getEnvironmentState();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/v1/client/env123/environment", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(mockEnvironmentState);
|
||||
}
|
||||
});
|
||||
|
||||
test("gets environment state with error", async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
code: "not_found",
|
||||
message: "Environment not found",
|
||||
}),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await apiClient.getEnvironmentState();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("network_error");
|
||||
expect(result.error.message).toBe("Environment not found");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { CommandQueue } from "@/lib/common/command-queue";
|
||||
import { checkSetup } from "@/lib/common/setup";
|
||||
import { type Result } from "@/types/error";
|
||||
|
||||
// Mock the setup module so we can control checkSetup()
|
||||
vi.mock("@/lib/common/setup", () => ({
|
||||
checkSetup: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("CommandQueue", () => {
|
||||
let queue: CommandQueue;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
// Create a fresh CommandQueue instance
|
||||
queue = new CommandQueue();
|
||||
});
|
||||
|
||||
test("executes commands in FIFO order", async () => {
|
||||
const executionOrder: string[] = [];
|
||||
|
||||
// Mock commands with proper Result returns
|
||||
const cmdA = vi.fn(async (): Promise<Result<void, unknown>> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
executionOrder.push("A");
|
||||
resolve({ ok: true, data: undefined });
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
const cmdB = vi.fn(async (): Promise<Result<void, unknown>> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
executionOrder.push("B");
|
||||
resolve({ ok: true, data: undefined });
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
const cmdC = vi.fn(async (): Promise<Result<void, unknown>> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
executionOrder.push("C");
|
||||
resolve({ ok: true, data: undefined });
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
|
||||
// We'll assume checkSetup always ok for this test
|
||||
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
|
||||
|
||||
// Enqueue commands
|
||||
queue.add(cmdA, true);
|
||||
queue.add(cmdB, true);
|
||||
queue.add(cmdC, true);
|
||||
|
||||
// Wait for them to finish
|
||||
await queue.wait();
|
||||
|
||||
expect(executionOrder).toEqual(["A", "B", "C"]);
|
||||
});
|
||||
|
||||
test("skips execution if checkSetup() fails", async () => {
|
||||
const cmd = vi.fn(async (): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
|
||||
// Force checkSetup to fail
|
||||
vi.mocked(checkSetup).mockReturnValue({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "not_setup",
|
||||
message: "Not setup",
|
||||
},
|
||||
});
|
||||
|
||||
queue.add(cmd, true);
|
||||
await queue.wait();
|
||||
|
||||
// Command should never have been called
|
||||
expect(cmd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("executes command if checkSetup is false (no check)", async () => {
|
||||
const cmd = vi.fn(async (): Promise<Result<void, unknown>> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({ ok: true, data: undefined });
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
|
||||
// checkSetup is irrelevant in this scenario, but let's mock it anyway
|
||||
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
|
||||
|
||||
// Here we pass 'false' for the second argument, so no check is performed
|
||||
queue.add(cmd, false);
|
||||
await queue.wait();
|
||||
|
||||
expect(cmd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("logs errors if a command throws or returns error", async () => {
|
||||
// Spy on console.error to see if it's called
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||
return {
|
||||
ok: true,
|
||||
data: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
// Force checkSetup to succeed
|
||||
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
|
||||
|
||||
// Mock command that fails
|
||||
const failingCmd = vi.fn(async () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve("some error");
|
||||
}, 10);
|
||||
});
|
||||
|
||||
throw new Error("some error");
|
||||
});
|
||||
|
||||
queue.add(failingCmd, true);
|
||||
await queue.wait();
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("🧱 Formbricks - Global error: ", expect.any(Error));
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("resolves wait() after all commands complete", async () => {
|
||||
const cmd1 = vi.fn(async (): Promise<Result<void, unknown>> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({ ok: true, data: undefined });
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
const cmd2 = vi.fn(async (): Promise<Result<void, unknown>> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({ ok: true, data: undefined });
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
|
||||
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
|
||||
|
||||
queue.add(cmd1, true);
|
||||
queue.add(cmd2, true);
|
||||
|
||||
await queue.wait();
|
||||
|
||||
// By the time `await queue.wait()` resolves, both commands should be done
|
||||
expect(cmd1).toHaveBeenCalled();
|
||||
expect(cmd2).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
// config.test.ts
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { mockConfig } from "./__mocks__/config.mock";
|
||||
import { RNConfig, RN_ASYNC_STORAGE_KEY } from "@/lib/common/config";
|
||||
import type { TConfig, TConfigUpdateInput } from "@/types/config";
|
||||
|
||||
// Define mocks outside of any describe block
|
||||
|
||||
describe("RNConfig", () => {
|
||||
let configInstance: RNConfig;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear mocks between tests
|
||||
vi.clearAllMocks();
|
||||
|
||||
// get the config instance
|
||||
configInstance = RNConfig.getInstance();
|
||||
|
||||
// reset the config
|
||||
await configInstance.resetConfig();
|
||||
|
||||
// get the config instance again
|
||||
configInstance = RNConfig.getInstance();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// In case we want to restore them after all tests
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("getInstance() returns a singleton", () => {
|
||||
const secondInstance = RNConfig.getInstance();
|
||||
expect(configInstance).toBe(secondInstance);
|
||||
});
|
||||
|
||||
test("get() throws if config is null", () => {
|
||||
// constructor didn't load anything successfully
|
||||
// so config is still null
|
||||
expect(() => configInstance.get()).toThrow("config is null, maybe the init function was not called?");
|
||||
});
|
||||
|
||||
test("loadFromStorage() returns ok if valid config is found", async () => {
|
||||
vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(mockConfig));
|
||||
|
||||
const result = await configInstance.loadFromStorage();
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(mockConfig);
|
||||
}
|
||||
});
|
||||
|
||||
test("loadFromStorage() returns err if config is expired", async () => {
|
||||
const expiredConfig = {
|
||||
...mockConfig,
|
||||
environment: {
|
||||
...mockConfig.environment,
|
||||
expiresAt: new Date("2000-01-01T00:00:00Z"),
|
||||
},
|
||||
};
|
||||
|
||||
vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(expiredConfig));
|
||||
|
||||
const result = await configInstance.loadFromStorage();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe("Config in local storage has expired");
|
||||
}
|
||||
});
|
||||
|
||||
test("loadFromStorage() returns err if no or invalid config in storage", async () => {
|
||||
// Simulate no data
|
||||
vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(null);
|
||||
|
||||
const result = await configInstance.loadFromStorage();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe("No or invalid config in local storage");
|
||||
}
|
||||
});
|
||||
|
||||
test("update() merges new config, calls saveToStorage()", async () => {
|
||||
vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(mockConfig));
|
||||
|
||||
// Wait for the constructor's async load
|
||||
await new Promise(setImmediate);
|
||||
|
||||
// Now we call update()
|
||||
const newStatus = { value: "error", expiresAt: "2100-01-01T00:00:00Z" } as unknown as TConfig["status"];
|
||||
|
||||
configInstance.update({ ...mockConfig, status: newStatus } as unknown as TConfigUpdateInput);
|
||||
|
||||
// The update call should eventually call setItem on AsyncStorage
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalled();
|
||||
// Let’s check if we can read the updated config:
|
||||
const updatedConfig = configInstance.get();
|
||||
expect(updatedConfig.status.value).toBe("error");
|
||||
expect(updatedConfig.status.expiresAt).toBe("2100-01-01T00:00:00Z");
|
||||
});
|
||||
|
||||
test("saveToStorage() is invoked internally on update()", async () => {
|
||||
vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(mockConfig));
|
||||
|
||||
await new Promise(setImmediate);
|
||||
|
||||
configInstance.update({ status: { value: "success", expiresAt: null } } as unknown as TConfigUpdateInput);
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
RN_ASYNC_STORAGE_KEY,
|
||||
expect.any(String) // the JSON string
|
||||
);
|
||||
});
|
||||
|
||||
test("resetConfig() clears config and AsyncStorage", async () => {
|
||||
vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(mockConfig));
|
||||
await new Promise(setImmediate);
|
||||
|
||||
// Now reset
|
||||
const result = await configInstance.resetConfig();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
// config is now null
|
||||
expect(() => configInstance.get()).toThrow("config is null");
|
||||
// removeItem should be called
|
||||
expect(AsyncStorage.removeItem).toHaveBeenCalledWith(RN_ASYNC_STORAGE_KEY);
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
// file-upload.test.ts
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { StorageAPI } from "@/lib/common/file-upload";
|
||||
import type { TUploadFileConfig } from "@/types/storage";
|
||||
|
||||
// A global fetch mock so we can capture fetch calls.
|
||||
// Alternatively, use `vi.stubGlobal("fetch", ...)`.
|
||||
const fetchMock = vi.fn();
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const mockEnvironmentId = "dv46cywjt1fxkkempq7vwued";
|
||||
|
||||
describe("StorageAPI", () => {
|
||||
const APP_URL = "https://myapp.example";
|
||||
const ENV_ID = mockEnvironmentId;
|
||||
|
||||
let storage: StorageAPI;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
storage = new StorageAPI(APP_URL, ENV_ID);
|
||||
});
|
||||
|
||||
test("throws an error if file object is invalid", async () => {
|
||||
// File missing "name", "type", or "base64"
|
||||
await expect(storage.uploadFile({ type: "", name: "", base64: "" }, {})).rejects.toThrow(
|
||||
"Invalid file object"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws if first fetch (storage route) returns non-OK", async () => {
|
||||
// We provide a valid file object
|
||||
const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" };
|
||||
|
||||
// First fetch returns not ok
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
} as Response);
|
||||
|
||||
await expect(storage.uploadFile(file)).rejects.toThrow("Upload failed with status: 400");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${APP_URL}/api/v1/client/${ENV_ID}/storage`,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("throws if second fetch returns non-OK (local storage w/ signingData)", async () => {
|
||||
// Suppose the first fetch is OK and returns JSON with signingData
|
||||
const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" };
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
|
||||
return {
|
||||
data: {
|
||||
signedUrl: "https://myapp.example/uploadLocal",
|
||||
fileUrl: "https://myapp.example/files/test.png",
|
||||
signingData: { signature: "xxx", timestamp: 1234, uuid: "abc" },
|
||||
presignedFields: null,
|
||||
updatedFileName: "test.png",
|
||||
},
|
||||
};
|
||||
},
|
||||
} as Response)
|
||||
// second fetch fails
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
|
||||
return { message: "File size exceeded your plan limit" };
|
||||
},
|
||||
} as Response);
|
||||
|
||||
await expect(storage.uploadFile(file)).rejects.toThrow("File size exceeded your plan limit");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("throws if second fetch returns non-OK (S3) containing 'EntityTooLarge'", async () => {
|
||||
const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" };
|
||||
|
||||
// First fetch response includes presignedFields => indicates S3 scenario
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
|
||||
return {
|
||||
data: {
|
||||
signedUrl: "https://some-s3-bucket/presigned",
|
||||
fileUrl: "https://some-s3-bucket/test.png",
|
||||
signingData: null, // means not local
|
||||
presignedFields: {
|
||||
key: "some-key",
|
||||
policy: "base64policy",
|
||||
},
|
||||
updatedFileName: "test.png",
|
||||
},
|
||||
};
|
||||
},
|
||||
} as Response)
|
||||
// second fetch fails with "EntityTooLarge"
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
text: async () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
|
||||
return "Some error with EntityTooLarge text in it";
|
||||
},
|
||||
} as Response);
|
||||
|
||||
await expect(storage.uploadFile(file)).rejects.toThrow("File size exceeds the size limit for your plan");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("successful upload returns fileUrl", async () => {
|
||||
const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" };
|
||||
const mockFileUrl = "https://myapp.example/files/test.png";
|
||||
|
||||
// First fetch => OK, returns JSON with 'signedUrl', 'fileUrl', etc.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
|
||||
return {
|
||||
data: {
|
||||
signedUrl: "https://myapp.example/uploadLocal",
|
||||
fileUrl: mockFileUrl,
|
||||
signingData: {
|
||||
signature: "xxx",
|
||||
timestamp: 1234,
|
||||
uuid: "abc",
|
||||
},
|
||||
presignedFields: null,
|
||||
updatedFileName: "test.png",
|
||||
},
|
||||
};
|
||||
},
|
||||
} as Response)
|
||||
// second fetch => also OK
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
} as Response);
|
||||
|
||||
const url = await storage.uploadFile(file, {
|
||||
allowedFileExtensions: [".png", ".jpg"],
|
||||
surveyId: "survey_123",
|
||||
} as TUploadFileConfig);
|
||||
|
||||
expect(url).toBe(mockFileUrl);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// We can also check the first fetch request body
|
||||
const firstCall = fetchMock.mock.calls[0];
|
||||
expect(firstCall[0]).toBe(`${APP_URL}/api/v1/client/${ENV_ID}/storage`);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access -- we know it's a string
|
||||
const bodyPayload = JSON.parse(firstCall[1].body as string);
|
||||
|
||||
expect(bodyPayload).toMatchObject({
|
||||
fileName: "test.png",
|
||||
fileType: "image/png",
|
||||
allowedFileExtensions: [".png", ".jpg"],
|
||||
surveyId: "survey_123",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
// logger.test.ts
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
|
||||
// adjust import path as needed
|
||||
|
||||
describe("Logger", () => {
|
||||
let logger: Logger;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = Logger.getInstance();
|
||||
|
||||
// Reset any existing singleton
|
||||
logger.resetInstance();
|
||||
|
||||
logger = Logger.getInstance();
|
||||
|
||||
// Mock console so we don't actually log in test output
|
||||
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {
|
||||
return {
|
||||
ok: true,
|
||||
data: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||
return {
|
||||
ok: true,
|
||||
data: undefined,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("getInstance() returns a singleton", () => {
|
||||
const anotherLogger = Logger.getInstance();
|
||||
expect(logger).toBe(anotherLogger);
|
||||
});
|
||||
|
||||
test("default logLevel is 'error', so debug messages shouldn't appear", () => {
|
||||
logger.debug("This is a debug log");
|
||||
logger.error("This is an error log");
|
||||
|
||||
// debug should NOT be logged by default
|
||||
expect(consoleLogSpy).not.toHaveBeenCalledWith(expect.stringContaining("This is a debug log"));
|
||||
// error should be logged
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("[ERROR] - This is an error log"));
|
||||
});
|
||||
|
||||
test("configure to logLevel=debug => debug messages appear", () => {
|
||||
logger.configure({ logLevel: "debug" });
|
||||
|
||||
logger.debug("Debug log after config");
|
||||
logger.error("Error log after config");
|
||||
|
||||
// debug should now appear
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/🧱 Formbricks.*\[DEBUG\].*Debug log after config/)
|
||||
);
|
||||
// error should appear as well
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/🧱 Formbricks.*\[ERROR\].*Error log after config/)
|
||||
);
|
||||
});
|
||||
|
||||
test("logs have correct format including timestamp prefix", () => {
|
||||
logger.configure({ logLevel: "debug" });
|
||||
logger.debug("Some message");
|
||||
|
||||
// Check that the log includes 🧱 Formbricks, timestamp, [DEBUG], and the message
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/^🧱 Formbricks - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z \[DEBUG\] - Some message$/
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,359 +0,0 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { RNConfig, RN_ASYNC_STORAGE_KEY } from "@/lib/common/config";
|
||||
import {
|
||||
addCleanupEventListeners,
|
||||
addEventListeners,
|
||||
removeAllEventListeners,
|
||||
} from "@/lib/common/event-listeners";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { checkSetup, handleErrorOnFirstSetup, setIsSetup, setup, tearDown } from "@/lib/common/setup";
|
||||
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";
|
||||
|
||||
// 1) Mock AsyncStorage
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
setItem: vi.fn(),
|
||||
getItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// 2) Mock RNConfig
|
||||
vi.mock("@/lib/common/config", () => ({
|
||||
RN_ASYNC_STORAGE_KEY: "formbricks-react-native",
|
||||
RNConfig: {
|
||||
getInstance: vi.fn(() => ({
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
resetConfig: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
// 3) Mock logger
|
||||
vi.mock("@/lib/common/logger", () => ({
|
||||
Logger: {
|
||||
getInstance: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
// 4) Mock event-listeners
|
||||
vi.mock("@/lib/common/event-listeners", () => ({
|
||||
addEventListeners: vi.fn(),
|
||||
addCleanupEventListeners: vi.fn(),
|
||||
removeAllEventListeners: vi.fn(),
|
||||
}));
|
||||
|
||||
// 5) Mock fetchEnvironmentState
|
||||
vi.mock("@/lib/environment/state", () => ({
|
||||
fetchEnvironmentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// 6) Mock filterSurveys
|
||||
vi.mock("@/lib/common/utils", async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal<typeof import("@/lib/common/utils")>()),
|
||||
filterSurveys: vi.fn(),
|
||||
isNowExpired: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// 7) Mock user/update
|
||||
vi.mock("@/lib/user/update", () => ({
|
||||
sendUpdatesToBackend: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("setup.ts", () => {
|
||||
let getInstanceConfigMock: MockInstance<() => RNConfig>;
|
||||
let getInstanceLoggerMock: MockInstance<() => Logger>;
|
||||
|
||||
const mockLogger = {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// By default, set isSetup to false so we can test setup logic from scratch
|
||||
setIsSetup(false);
|
||||
|
||||
getInstanceConfigMock = vi.spyOn(RNConfig, "getInstance");
|
||||
getInstanceLoggerMock = vi.spyOn(Logger, "getInstance").mockReturnValue(mockLogger as unknown as Logger);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("setup()", () => {
|
||||
test("returns ok if already setup", async () => {
|
||||
getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger);
|
||||
setIsSetup(true);
|
||||
const result = await setup({ environmentId: "env_id", appUrl: "https://my.url" });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith("Already set up, skipping setup.");
|
||||
});
|
||||
|
||||
test("fails if no environmentId is provided", async () => {
|
||||
const result = await setup({ environmentId: "", appUrl: "https://my.url" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("missing_field");
|
||||
}
|
||||
});
|
||||
|
||||
test("fails if no appUrl is provided", async () => {
|
||||
const result = await setup({ environmentId: "env_123", appUrl: "" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("missing_field");
|
||||
}
|
||||
});
|
||||
|
||||
test("skips setup if existing config is in error state and not expired", async () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environmentId: "env_123",
|
||||
appUrl: "https://my.url",
|
||||
environment: {},
|
||||
user: { data: {}, expiresAt: null },
|
||||
status: { value: "error", expiresAt: new Date(Date.now() + 10000) },
|
||||
}),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
|
||||
|
||||
(isNowExpired as unknown as Mock).mockReturnValue(true);
|
||||
|
||||
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith("Formbricks was set to an error state.");
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith("Error state is not expired, skipping setup");
|
||||
});
|
||||
|
||||
test("proceeds if error state is expired", async () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environmentId: "env_123",
|
||||
appUrl: "https://my.url",
|
||||
environment: {},
|
||||
user: { data: {}, expiresAt: null },
|
||||
status: { value: "error", expiresAt: new Date(Date.now() - 10000) }, // expired
|
||||
}),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
|
||||
|
||||
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith("Formbricks was set to an error state.");
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith("Error state is expired. Continue with setup.");
|
||||
});
|
||||
|
||||
test("uses existing config if environmentId/appUrl match, checks for expiration sync", async () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environmentId: "env_123",
|
||||
appUrl: "https://my.url",
|
||||
environment: { expiresAt: new Date(Date.now() - 5000) }, // environment expired
|
||||
user: {
|
||||
data: { userId: "user_abc" },
|
||||
expiresAt: new Date(Date.now() - 5000), // also expired
|
||||
},
|
||||
status: { value: "success", expiresAt: null },
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
|
||||
|
||||
(isNowExpired as unknown as Mock).mockReturnValue(true);
|
||||
|
||||
// Mock environment fetch success
|
||||
(fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: { data: { surveys: [] }, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
|
||||
// Mock sendUpdatesToBackend success
|
||||
(sendUpdatesToBackend as unknown as Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: {
|
||||
state: {
|
||||
expiresAt: new Date(),
|
||||
data: { userId: "user_abc", segments: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
(filterSurveys as unknown as Mock).mockReturnValueOnce([{ name: "S1" }, { name: "S2" }]);
|
||||
|
||||
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
// environmentState was fetched
|
||||
expect(fetchEnvironmentState).toHaveBeenCalled();
|
||||
// user state was updated
|
||||
expect(sendUpdatesToBackend).toHaveBeenCalled();
|
||||
// filterSurveys called
|
||||
expect(filterSurveys).toHaveBeenCalled();
|
||||
// config updated
|
||||
expect(mockConfig.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- required for testing this object
|
||||
user: expect.objectContaining({
|
||||
data: { userId: "user_abc", segments: [] },
|
||||
}),
|
||||
filteredSurveys: [{ name: "S1" }, { name: "S2" }],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("resets config if no valid config found, fetches environment, sets default user", async () => {
|
||||
const mockConfig = {
|
||||
get: () => {
|
||||
throw new Error("no config found");
|
||||
},
|
||||
resetConfig: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
|
||||
|
||||
(fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: {
|
||||
data: {
|
||||
surveys: [{ name: "SurveyA" }],
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
(filterSurveys as unknown as Mock).mockReturnValueOnce([{ name: "SurveyA" }]);
|
||||
|
||||
const result = await setup({ environmentId: "envX", appUrl: "https://urlX" });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith("No existing configuration found.");
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
"No valid configuration found. Resetting config and creating new one."
|
||||
);
|
||||
expect(mockConfig.resetConfig).toHaveBeenCalled();
|
||||
expect(fetchEnvironmentState).toHaveBeenCalled();
|
||||
expect(mockConfig.update).toHaveBeenCalledWith({
|
||||
appUrl: "https://urlX",
|
||||
environmentId: "envX",
|
||||
user: DEFAULT_USER_STATE_NO_USER_ID,
|
||||
environment: {
|
||||
data: {
|
||||
surveys: [{ name: "SurveyA" }],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- required for testing this object
|
||||
expiresAt: expect.any(Date),
|
||||
},
|
||||
},
|
||||
filteredSurveys: [{ name: "SurveyA" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("calls handleErrorOnFirstSetup if environment fetch fails initially", async () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue(undefined),
|
||||
update: vi.fn(),
|
||||
resetConfig: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValueOnce(mockConfig as unknown as RNConfig);
|
||||
|
||||
(fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: "forbidden", responseMessage: "No access" },
|
||||
});
|
||||
|
||||
await expect(setup({ environmentId: "envX", appUrl: "https://urlX" })).rejects.toThrow(
|
||||
"Could not set up formbricks"
|
||||
);
|
||||
});
|
||||
|
||||
test("adds event listeners and sets isSetup", async () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environmentId: "env_abc",
|
||||
appUrl: "https://test.app",
|
||||
environment: {},
|
||||
user: { data: {}, expiresAt: null },
|
||||
status: { value: "success", expiresAt: null },
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValueOnce(mockConfig as unknown as RNConfig);
|
||||
|
||||
const result = await setup({ environmentId: "env_abc", appUrl: "https://test.app" });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(addEventListeners).toHaveBeenCalled();
|
||||
expect(addCleanupEventListeners).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkSetup()", () => {
|
||||
test("returns err if not setup", () => {
|
||||
const res = checkSetup();
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.error.code).toBe("not_setup");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns ok if setup", () => {
|
||||
setIsSetup(true);
|
||||
const res = checkSetup();
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tearDown()", () => {
|
||||
test("resets user state to default and removes event listeners", async () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
user: { data: { userId: "XYZ" } },
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValueOnce(mockConfig as unknown as RNConfig);
|
||||
|
||||
await tearDown();
|
||||
|
||||
expect(mockConfig.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user: DEFAULT_USER_STATE_NO_USER_ID,
|
||||
})
|
||||
);
|
||||
expect(removeAllEventListeners).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleErrorOnFirstSetup()", () => {
|
||||
test("stores error state in AsyncStorage, throws error", async () => {
|
||||
// We import the function directly
|
||||
const errorObj = { code: "forbidden", responseMessage: "No access" };
|
||||
|
||||
await expect(async () => {
|
||||
await handleErrorOnFirstSetup(errorObj);
|
||||
}).rejects.toThrow("Could not set up formbricks");
|
||||
|
||||
// AsyncStorage setItem should be called with the error config
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
RN_ASYNC_STORAGE_KEY,
|
||||
expect.stringContaining('"value":"error"')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,395 +0,0 @@
|
||||
// utils.test.ts
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { mockProjectId, mockSurveyId } from "@/lib/common/tests/__mocks__/config.mock";
|
||||
import {
|
||||
diffInDays,
|
||||
filterSurveys,
|
||||
getDefaultLanguageCode,
|
||||
getLanguageCode,
|
||||
getStyling,
|
||||
shouldDisplayBasedOnPercentage,
|
||||
wrapThrowsAsync,
|
||||
} from "@/lib/common/utils";
|
||||
import type {
|
||||
TEnvironmentState,
|
||||
TEnvironmentStateProject,
|
||||
TEnvironmentStateSurvey,
|
||||
TSurveyStyling,
|
||||
TUserState,
|
||||
} from "@/types/config";
|
||||
|
||||
const mockSurveyId1 = "e3kxlpnzmdp84op9qzxl9olj";
|
||||
const mockSurveyId2 = "qo9rwjmms42hoy3k85fp8vgu";
|
||||
const mockSegmentId1 = "p6yrnz3s2tvoe5r0l28unq7k";
|
||||
const mockSegmentId2 = "wz43zrxeddhb1uo9cicustar";
|
||||
|
||||
describe("utils.ts", () => {
|
||||
// ---------------------------------------------------------------------------------
|
||||
// diffInDays
|
||||
// ---------------------------------------------------------------------------------
|
||||
describe("diffInDays()", () => {
|
||||
test("calculates correct day difference", () => {
|
||||
const date1 = new Date("2023-01-01");
|
||||
const date2 = new Date("2023-01-05");
|
||||
expect(diffInDays(date1, date2)).toBe(4); // four days apart
|
||||
});
|
||||
|
||||
test("handles negative differences (abs)", () => {
|
||||
const date1 = new Date("2023-01-10");
|
||||
const date2 = new Date("2023-01-05");
|
||||
expect(diffInDays(date1, date2)).toBe(5);
|
||||
});
|
||||
|
||||
test("0 if same day", () => {
|
||||
const date = new Date("2023-01-01");
|
||||
expect(diffInDays(date, date)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// wrapThrowsAsync
|
||||
// ---------------------------------------------------------------------------------
|
||||
describe("wrapThrowsAsync()", () => {
|
||||
test("returns ok on success", async () => {
|
||||
const fn = vi.fn(async (x: number) => {
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, 10);
|
||||
});
|
||||
return x * 2;
|
||||
});
|
||||
|
||||
const wrapped = wrapThrowsAsync(fn);
|
||||
|
||||
const result = await wrapped(5);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBe(10);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns err on error", async () => {
|
||||
const fn = vi.fn(async () => {
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, 10);
|
||||
});
|
||||
throw new Error("Something broke");
|
||||
});
|
||||
const wrapped = wrapThrowsAsync(fn);
|
||||
|
||||
const result = await wrapped();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe("Something broke");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// filterSurveys
|
||||
// ---------------------------------------------------------------------------------
|
||||
describe("filterSurveys()", () => {
|
||||
// We'll create a minimal environment state
|
||||
let environment: TEnvironmentState;
|
||||
let user: TUserState;
|
||||
const baseSurvey: Partial<TEnvironmentStateSurvey> = {
|
||||
id: mockSurveyId,
|
||||
displayOption: "displayOnce",
|
||||
displayLimit: 1,
|
||||
recontactDays: null,
|
||||
languages: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
environment = {
|
||||
expiresAt: new Date(),
|
||||
data: {
|
||||
project: {
|
||||
id: mockProjectId,
|
||||
recontactDays: 7, // fallback if survey doesn't have it
|
||||
clickOutsideClose: false,
|
||||
darkOverlay: false,
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
styling: { allowStyleOverwrite: false },
|
||||
} as TEnvironmentStateProject,
|
||||
surveys: [],
|
||||
actionClasses: [],
|
||||
},
|
||||
};
|
||||
user = {
|
||||
expiresAt: null,
|
||||
data: {
|
||||
userId: null,
|
||||
contactId: null,
|
||||
segments: [],
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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 = [
|
||||
{ ...baseSurvey, id: mockSurveyId1, segment: { id: mockSegmentId1 } } as TEnvironmentStateSurvey,
|
||||
];
|
||||
|
||||
const result = filterSurveys(environment, 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 = [
|
||||
{ ...baseSurvey, id: mockSurveyId1, displayOption: "displayOnce" } as TEnvironmentStateSurvey,
|
||||
];
|
||||
|
||||
const result = filterSurveys(environment, user);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(mockSurveyId1);
|
||||
});
|
||||
|
||||
test("skips surveys that already displayed if displayOnce is used", () => {
|
||||
environment.data.surveys = [
|
||||
{ ...baseSurvey, id: mockSurveyId1, displayOption: "displayOnce" } as TEnvironmentStateSurvey,
|
||||
];
|
||||
user.data.displays = [{ surveyId: mockSurveyId1, createdAt: new Date() }];
|
||||
|
||||
const result = filterSurveys(environment, user);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips surveys if user responded to them and displayOption=displayMultiple", () => {
|
||||
environment.data.surveys = [
|
||||
{ ...baseSurvey, id: mockSurveyId1, displayOption: "displayMultiple" } as TEnvironmentStateSurvey,
|
||||
];
|
||||
user.data.responses = [mockSurveyId1];
|
||||
|
||||
const result = filterSurveys(environment, user);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles displaySome logic with displayLimit", () => {
|
||||
environment.data.surveys = [
|
||||
{
|
||||
...baseSurvey,
|
||||
id: mockSurveyId1,
|
||||
displayOption: "displaySome",
|
||||
displayLimit: 2,
|
||||
} as TEnvironmentStateSurvey,
|
||||
];
|
||||
// user has 1 display of s1
|
||||
user.data.displays = [{ surveyId: mockSurveyId1, createdAt: new Date() }];
|
||||
|
||||
// No responses => so it's still allowed
|
||||
const result = filterSurveys(environment, user);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("filters out surveys if recontactDays not met", () => {
|
||||
// Suppose survey uses project fallback (7 days)
|
||||
environment.data.surveys = [
|
||||
{ ...baseSurvey, id: mockSurveyId1, displayOption: "displayOnce" } as TEnvironmentStateSurvey,
|
||||
];
|
||||
// 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);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("passes surveys if enough days have passed since lastDisplayAt", () => {
|
||||
// user last displayAt is 8 days ago
|
||||
user.data.lastDisplayAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
|
||||
|
||||
environment.data.surveys = [
|
||||
{
|
||||
...baseSurvey,
|
||||
id: mockSurveyId1,
|
||||
displayOption: "respondMultiple",
|
||||
recontactDays: null,
|
||||
} as TEnvironmentStateSurvey,
|
||||
];
|
||||
const result = filterSurveys(environment, 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 = [
|
||||
{
|
||||
...baseSurvey,
|
||||
id: mockSurveyId1,
|
||||
segment: { id: mockSegmentId1 },
|
||||
displayOption: "respondMultiple",
|
||||
} as TEnvironmentStateSurvey,
|
||||
{
|
||||
...baseSurvey,
|
||||
id: mockSurveyId2,
|
||||
segment: { id: mockSegmentId2 },
|
||||
displayOption: "respondMultiple",
|
||||
} as TEnvironmentStateSurvey,
|
||||
];
|
||||
|
||||
const result = filterSurveys(environment, user);
|
||||
// only the one that matches user's segment
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(mockSurveyId1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// getStyling
|
||||
// ---------------------------------------------------------------------------------
|
||||
describe("getStyling()", () => {
|
||||
test("returns project styling if allowStyleOverwrite=false", () => {
|
||||
const project = {
|
||||
id: "p1",
|
||||
styling: { allowStyleOverwrite: false, brandColor: { light: "#fff" } },
|
||||
} as TEnvironmentStateProject;
|
||||
const survey = {
|
||||
styling: {
|
||||
overwriteThemeStyling: true,
|
||||
brandColor: { light: "#000" },
|
||||
} as TSurveyStyling,
|
||||
} as TEnvironmentStateSurvey;
|
||||
|
||||
const result = getStyling(project, survey);
|
||||
// should get project styling
|
||||
expect(result).toEqual(project.styling);
|
||||
});
|
||||
|
||||
test("returns project styling if allowStyleOverwrite=true but survey overwriteThemeStyling=false", () => {
|
||||
const project = {
|
||||
id: "p1",
|
||||
styling: { allowStyleOverwrite: true, brandColor: { light: "#fff" } },
|
||||
} as TEnvironmentStateProject;
|
||||
const survey = {
|
||||
styling: {
|
||||
overwriteThemeStyling: false,
|
||||
brandColor: { light: "#000" },
|
||||
} as TSurveyStyling,
|
||||
} as TEnvironmentStateSurvey;
|
||||
|
||||
const result = getStyling(project, survey);
|
||||
// should get project styling still
|
||||
expect(result).toEqual(project.styling);
|
||||
});
|
||||
|
||||
test("returns survey styling if allowStyleOverwrite=true and survey overwriteThemeStyling=true", () => {
|
||||
const project = {
|
||||
id: "p1",
|
||||
styling: { allowStyleOverwrite: true, brandColor: { light: "#fff" } },
|
||||
} as TEnvironmentStateProject;
|
||||
const survey = {
|
||||
styling: {
|
||||
overwriteThemeStyling: true,
|
||||
brandColor: { light: "#000" },
|
||||
} as TSurveyStyling,
|
||||
} as TEnvironmentStateSurvey;
|
||||
|
||||
const result = getStyling(project, survey);
|
||||
expect(result).toEqual(survey.styling);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// getDefaultLanguageCode
|
||||
// ---------------------------------------------------------------------------------
|
||||
describe("getDefaultLanguageCode()", () => {
|
||||
test("returns code of the language if it is flagged default", () => {
|
||||
const survey = {
|
||||
languages: [
|
||||
{
|
||||
language: { code: "en" },
|
||||
default: false,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
language: { code: "fr" },
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
} as unknown as TEnvironmentStateSurvey;
|
||||
expect(getDefaultLanguageCode(survey)).toBe("fr");
|
||||
});
|
||||
|
||||
test("returns undefined if no default language found", () => {
|
||||
const survey = {
|
||||
languages: [
|
||||
{ language: { code: "en" }, default: false, enabled: true },
|
||||
{ language: { code: "fr" }, default: false, enabled: true },
|
||||
],
|
||||
} as unknown as TEnvironmentStateSurvey;
|
||||
expect(getDefaultLanguageCode(survey)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// getLanguageCode
|
||||
// ---------------------------------------------------------------------------------
|
||||
describe("getLanguageCode()", () => {
|
||||
test("returns 'default' if no language param is passed", () => {
|
||||
const survey = {
|
||||
languages: [{ language: { code: "en" }, default: true, enabled: true }],
|
||||
} as unknown as TEnvironmentStateSurvey;
|
||||
const code = getLanguageCode(survey, undefined);
|
||||
expect(code).toBe("default");
|
||||
});
|
||||
|
||||
test("returns 'default' if the chosen language is the default one", () => {
|
||||
const survey = {
|
||||
languages: [
|
||||
{ language: { code: "en" }, default: true, enabled: true },
|
||||
{ language: { code: "fr" }, default: false, enabled: true },
|
||||
],
|
||||
} as unknown as TEnvironmentStateSurvey;
|
||||
const code = getLanguageCode(survey, "en");
|
||||
expect(code).toBe("default");
|
||||
});
|
||||
|
||||
test("returns undefined if language not found or disabled", () => {
|
||||
const survey = {
|
||||
languages: [
|
||||
{ language: { code: "en" }, default: true, enabled: true },
|
||||
{ language: { code: "fr" }, default: false, enabled: false },
|
||||
],
|
||||
} as unknown as TEnvironmentStateSurvey;
|
||||
const code = getLanguageCode(survey, "fr");
|
||||
expect(code).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns the language code if found and enabled", () => {
|
||||
const survey = {
|
||||
languages: [
|
||||
{ language: { code: "en", alias: "English" }, default: true, enabled: true },
|
||||
{ language: { code: "fr", alias: "fr-FR" }, default: false, enabled: true },
|
||||
],
|
||||
} as unknown as TEnvironmentStateSurvey;
|
||||
expect(getLanguageCode(survey, "fr")).toBe("fr");
|
||||
expect(getLanguageCode(survey, "fr-FR")).toBe("fr");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// shouldDisplayBasedOnPercentage
|
||||
// ---------------------------------------------------------------------------------
|
||||
describe("shouldDisplayBasedOnPercentage()", () => {
|
||||
test("returns true if random number <= displayPercentage", () => {
|
||||
// We'll mock Math.random to return something
|
||||
const mockedRandom = vi.spyOn(Math, "random").mockReturnValue(0.2); // 0.2 => 20%
|
||||
// displayPercentage = 30 => 30% => we should display
|
||||
expect(shouldDisplayBasedOnPercentage(30)).toBe(true);
|
||||
|
||||
mockedRandom.mockReturnValue(0.5); // 50%
|
||||
expect(shouldDisplayBasedOnPercentage(30)).toBe(false);
|
||||
|
||||
// restore
|
||||
mockedRandom.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
import type {
|
||||
TEnvironmentState,
|
||||
TEnvironmentStateProject,
|
||||
TEnvironmentStateSurvey,
|
||||
TProjectStyling,
|
||||
TSurveyStyling,
|
||||
TUserState,
|
||||
} from "@/types/config";
|
||||
import type { Result } from "@/types/error";
|
||||
|
||||
// Helper function to calculate difference in days between two dates
|
||||
export const diffInDays = (date1: Date, date2: Date): number => {
|
||||
const diffTime = Math.abs(date2.getTime() - date1.getTime());
|
||||
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
export const wrapThrowsAsync =
|
||||
<T, A extends unknown[]>(fn: (...args: A) => Promise<T>) =>
|
||||
async (...args: A): Promise<Result<T>> => {
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
data: await fn(...args),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error as Error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters surveys based on the displayOption, recontactDays, and segments
|
||||
* @param environmentSate - The environment state
|
||||
* @param userState - The user state
|
||||
* @returns The filtered surveys
|
||||
*/
|
||||
|
||||
// takes the environment and user state and returns the filtered surveys
|
||||
export const filterSurveys = (
|
||||
environmentState: TEnvironmentState,
|
||||
userState: TUserState
|
||||
): TEnvironmentStateSurvey[] => {
|
||||
const { project, surveys } = environmentState.data;
|
||||
const { displays, responses, lastDisplayAt, segments, userId } = userState.data;
|
||||
|
||||
// Function to filter surveys based on displayOption criteria
|
||||
let filteredSurveys = surveys.filter((survey: TEnvironmentStateSurvey) => {
|
||||
switch (survey.displayOption) {
|
||||
case "respondMultiple":
|
||||
return true;
|
||||
case "displayOnce":
|
||||
return displays.filter((display) => display.surveyId === survey.id).length === 0;
|
||||
case "displayMultiple":
|
||||
return responses.filter((surveyId) => surveyId === survey.id).length === 0;
|
||||
|
||||
case "displaySome":
|
||||
if (survey.displayLimit === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if survey response exists, if so, stop here
|
||||
if (responses.filter((surveyId) => surveyId === survey.id).length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Otherwise, check if displays length is less than displayLimit
|
||||
return displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit;
|
||||
|
||||
default:
|
||||
throw Error("Invalid displayOption");
|
||||
}
|
||||
});
|
||||
|
||||
// filter surveys that meet the recontactDays criteria
|
||||
filteredSurveys = filteredSurveys.filter((survey) => {
|
||||
// if no survey was displayed yet, show the survey
|
||||
if (!lastDisplayAt) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if survey has recontactDays, check if the last display was more than recontactDays ago
|
||||
// The previous approach checked the last display for each survey which is why we still have a surveyId in the displays array.
|
||||
// TODO: Remove the surveyId from the displays array
|
||||
if (survey.recontactDays !== null) {
|
||||
return diffInDays(new Date(), new Date(lastDisplayAt)) >= survey.recontactDays;
|
||||
}
|
||||
|
||||
// use recontactDays of the project if survey does not have recontactDays
|
||||
if (project.recontactDays) {
|
||||
return diffInDays(new Date(), new Date(lastDisplayAt)) >= project.recontactDays;
|
||||
}
|
||||
|
||||
// if no recontactDays is set, show the survey
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!userId) {
|
||||
return filteredSurveys;
|
||||
}
|
||||
|
||||
if (!segments.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// filter surveys based on segments
|
||||
return filteredSurveys.filter((survey) => {
|
||||
return survey.segment?.id && segments.includes(survey.segment.id);
|
||||
});
|
||||
};
|
||||
|
||||
export const getStyling = (
|
||||
project: TEnvironmentStateProject,
|
||||
survey: TEnvironmentStateSurvey
|
||||
): TProjectStyling | TSurveyStyling => {
|
||||
// allow style overwrite is enabled from the project
|
||||
if (project.styling.allowStyleOverwrite) {
|
||||
// survey style overwrite is disabled
|
||||
if (!survey.styling?.overwriteThemeStyling) {
|
||||
return project.styling;
|
||||
}
|
||||
|
||||
// survey style overwrite is enabled
|
||||
return survey.styling;
|
||||
}
|
||||
|
||||
// allow style overwrite is disabled from the project
|
||||
return project.styling;
|
||||
};
|
||||
|
||||
export const getDefaultLanguageCode = (survey: TEnvironmentStateSurvey): string | undefined => {
|
||||
const defaultSurveyLanguage = survey.languages.find((surveyLanguage) => {
|
||||
return surveyLanguage.default;
|
||||
});
|
||||
if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code;
|
||||
};
|
||||
|
||||
export const getLanguageCode = (survey: TEnvironmentStateSurvey, language?: string): string | undefined => {
|
||||
const availableLanguageCodes = survey.languages.map((surveyLanguage) => surveyLanguage.language.code);
|
||||
if (!language) return "default";
|
||||
|
||||
const selectedLanguage = survey.languages.find((surveyLanguage) => {
|
||||
return (
|
||||
surveyLanguage.language.code === language.toLowerCase() ||
|
||||
surveyLanguage.language.alias?.toLowerCase() === language.toLowerCase()
|
||||
);
|
||||
});
|
||||
if (selectedLanguage?.default) {
|
||||
return "default";
|
||||
}
|
||||
if (
|
||||
!selectedLanguage ||
|
||||
!selectedLanguage.enabled ||
|
||||
!availableLanguageCodes.includes(selectedLanguage.language.code)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return selectedLanguage.language.code;
|
||||
};
|
||||
|
||||
export const shouldDisplayBasedOnPercentage = (displayPercentage: number): boolean => {
|
||||
const randomNum = Math.floor(Math.random() * 10000) / 100;
|
||||
return randomNum <= displayPercentage;
|
||||
};
|
||||
|
||||
export const isNowExpired = (expirationDate: Date): boolean => {
|
||||
return new Date() >= expirationDate;
|
||||
};
|
||||
@@ -1,118 +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, type Result, err, ok } 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<Result<TEnvironmentState, ApiErrorResponse>> => {
|
||||
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 = (): void => {
|
||||
const appConfig = RNConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
const updateInterval = 1000 * 60; // every minute
|
||||
|
||||
if (environmentStateSyncIntervalId === null) {
|
||||
const intervalHandler = async (): Promise<void> => {
|
||||
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(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
environmentStateSyncIntervalId = setInterval(
|
||||
() => void intervalHandler(),
|
||||
updateInterval
|
||||
) as unknown as number;
|
||||
}
|
||||
};
|
||||
|
||||
export const clearEnvironmentStateExpiryCheckListener = (): void => {
|
||||
if (environmentStateSyncIntervalId) {
|
||||
clearInterval(environmentStateSyncIntervalId);
|
||||
environmentStateSyncIntervalId = null;
|
||||
}
|
||||
};
|
||||
@@ -1,278 +0,0 @@
|
||||
// state.test.ts
|
||||
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 {
|
||||
addEnvironmentStateExpiryCheckListener,
|
||||
clearEnvironmentStateExpiryCheckListener,
|
||||
fetchEnvironmentState,
|
||||
} from "@/lib/environment/state";
|
||||
import type { TEnvironmentState } from "@/types/config";
|
||||
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock the FormbricksAPI so we can control environment.getState
|
||||
vi.mock("@/lib/common/api", () => ({
|
||||
ApiClient: vi.fn().mockImplementation(() => ({
|
||||
getEnvironmentState: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock logger (so we don’t spam console)
|
||||
vi.mock("@/lib/common/logger", () => ({
|
||||
Logger: {
|
||||
getInstance: vi.fn(() => {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock filterSurveys
|
||||
vi.mock("@/lib/common/utils", () => ({
|
||||
filterSurveys: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock RNConfig
|
||||
vi.mock("@/lib/common/config", () => {
|
||||
return {
|
||||
RN_ASYNC_STORAGE_KEY: "formbricks-react-native",
|
||||
RNConfig: {
|
||||
getInstance: vi.fn(() => ({
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("environment/state.ts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Use real timers so we don't pollute subsequent test code
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("fetchEnvironmentState()", () => {
|
||||
test("returns ok(...) with environment state", async () => {
|
||||
// Setup mock
|
||||
(ApiClient as unknown as Mock).mockImplementationOnce(() => {
|
||||
return {
|
||||
getEnvironmentState: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
data: { data: { foo: "bar" }, expiresAt: new Date(Date.now() + 1000 * 60 * 30) },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const result = await fetchEnvironmentState({
|
||||
appUrl: "https://fake.host",
|
||||
environmentId: "env_123",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
const val: TEnvironmentState = result.data;
|
||||
expect(val.data).toEqual({ foo: "bar" });
|
||||
expect(val.expiresAt).toBeInstanceOf(Date);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns err(...) if environment.getState is not ok", async () => {
|
||||
const mockError = { code: "forbidden", status: 403, message: "Access denied" };
|
||||
|
||||
(ApiClient as unknown as Mock).mockImplementationOnce(() => {
|
||||
return {
|
||||
getEnvironmentState: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
error: mockError,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const result = await fetchEnvironmentState({
|
||||
appUrl: "https://fake.host",
|
||||
environmentId: "env_123",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe(mockError.code);
|
||||
expect(result.error.status).toBe(mockError.status);
|
||||
expect(result.error.responseMessage).toBe(mockError.message);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns err(...) on network error catch", async () => {
|
||||
const mockNetworkError = {
|
||||
code: "network_error",
|
||||
message: "Timeout",
|
||||
responseMessage: "Network fail",
|
||||
};
|
||||
|
||||
(ApiClient as unknown as Mock).mockImplementationOnce(() => {
|
||||
return {
|
||||
getEnvironmentState: vi.fn().mockRejectedValue(mockNetworkError),
|
||||
};
|
||||
});
|
||||
|
||||
const result = await fetchEnvironmentState({
|
||||
appUrl: "https://fake.host",
|
||||
environmentId: "env_123",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe(mockNetworkError.code);
|
||||
expect(result.error.message).toBe(mockNetworkError.message);
|
||||
expect(result.error.responseMessage).toBe(mockNetworkError.responseMessage);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("addEnvironmentStateExpiryCheckListener()", () => {
|
||||
let mockRNConfig: MockInstance<() => RNConfig>;
|
||||
let mockLoggerInstance: MockInstance<() => Logger>;
|
||||
|
||||
const mockLogger = {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
mockRNConfig = vi.spyOn(RNConfig, "getInstance");
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environment: {
|
||||
expiresAt: new Date(Date.now() + 60_000), // Not expired for now
|
||||
},
|
||||
user: {},
|
||||
environmentId: "env_123",
|
||||
appUrl: "https://fake.host",
|
||||
}),
|
||||
};
|
||||
|
||||
mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
|
||||
|
||||
mockLoggerInstance = vi.spyOn(Logger, "getInstance");
|
||||
mockLoggerInstance.mockReturnValue(mockLogger as unknown as Logger);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearEnvironmentStateExpiryCheckListener(); // clear after each test
|
||||
});
|
||||
|
||||
test("starts interval check and updates state when expired", async () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environment: {
|
||||
expiresAt: new Date(Date.now() - 1000).toISOString(), // expired
|
||||
},
|
||||
appUrl: "https://test.com",
|
||||
environmentId: "env_123",
|
||||
user: { data: {} },
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
const mockNewState = {
|
||||
data: {
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 30).toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
|
||||
|
||||
(ApiClient as Mock).mockImplementation(() => ({
|
||||
getEnvironmentState: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
data: mockNewState,
|
||||
}),
|
||||
}));
|
||||
|
||||
(filterSurveys as Mock).mockReturnValue([]);
|
||||
|
||||
// Add listener
|
||||
addEnvironmentStateExpiryCheckListener();
|
||||
|
||||
// Fast-forward time
|
||||
await vi.advanceTimersByTimeAsync(1000 * 60);
|
||||
|
||||
// Verify the update was called
|
||||
expect(mockConfig.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("extends expiry on error", async () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environment: {
|
||||
expiresAt: new Date(Date.now() - 1000).toISOString(),
|
||||
},
|
||||
appUrl: "https://test.com",
|
||||
environmentId: "env_123",
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
|
||||
|
||||
// Mock API to throw an error
|
||||
(ApiClient as Mock).mockImplementation(() => ({
|
||||
getEnvironmentState: vi.fn().mockRejectedValue(new Error("Network error")),
|
||||
}));
|
||||
|
||||
addEnvironmentStateExpiryCheckListener();
|
||||
|
||||
// Fast-forward time
|
||||
await vi.advanceTimersByTimeAsync(1000 * 60);
|
||||
|
||||
// Verify the config was updated with extended expiry
|
||||
expect(mockConfig.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not fetch new state if not expired", async () => {
|
||||
const futureDate = new Date(Date.now() + 1000 * 60 * 60); // 1 hour in future
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environment: {
|
||||
expiresAt: futureDate.toISOString(),
|
||||
},
|
||||
appUrl: "https://test.com",
|
||||
environmentId: "env_123",
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
|
||||
|
||||
const apiMock = vi.fn().mockImplementation(() => ({
|
||||
getEnvironmentState: vi.fn(),
|
||||
}));
|
||||
|
||||
(ApiClient as Mock).mockImplementation(apiMock);
|
||||
|
||||
addEnvironmentStateExpiryCheckListener();
|
||||
|
||||
// Fast-forward time by less than expiry
|
||||
await vi.advanceTimersByTimeAsync(1000 * 60);
|
||||
|
||||
expect(mockConfig.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("clears interval when clearEnvironmentStateExpiryCheckListener is called", () => {
|
||||
const clearIntervalSpy = vi.spyOn(global, "clearInterval");
|
||||
|
||||
addEnvironmentStateExpiryCheckListener();
|
||||
clearEnvironmentStateExpiryCheckListener();
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
import { RNConfig } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { shouldDisplayBasedOnPercentage } from "@/lib/common/utils";
|
||||
import { SurveyStore } from "@/lib/survey/store";
|
||||
import type { TEnvironmentStateSurvey } from "@/types/config";
|
||||
import { type InvalidCodeError, type NetworkError, type Result, err, okVoid } from "@/types/error";
|
||||
import { fetch } from "@react-native-community/netinfo";
|
||||
|
||||
/**
|
||||
* Triggers the display of a survey if it meets the display percentage criteria
|
||||
* @param survey - The survey configuration to potentially display
|
||||
*/
|
||||
export const triggerSurvey = (survey: TEnvironmentStateSurvey): void => {
|
||||
const surveyStore = SurveyStore.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
// Check if the survey should be displayed based on displayPercentage
|
||||
if (survey.displayPercentage) {
|
||||
const shouldDisplaySurvey = shouldDisplayBasedOnPercentage(survey.displayPercentage);
|
||||
if (!shouldDisplaySurvey) {
|
||||
logger.debug(`Survey display of "${survey.name}" skipped based on displayPercentage.`);
|
||||
return; // skip displaying the survey
|
||||
}
|
||||
}
|
||||
|
||||
surveyStore.setSurvey(survey);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks an action name and triggers associated surveys
|
||||
* @param name - The name of the action to track
|
||||
* @param alias - Optional alias for the action name
|
||||
* @returns Result indicating success or network error
|
||||
*/
|
||||
export const trackAction = (name: string, alias?: string): Result<void, NetworkError> => {
|
||||
const logger = Logger.getInstance();
|
||||
const appConfig = RNConfig.getInstance();
|
||||
|
||||
const aliasName = alias ?? name;
|
||||
|
||||
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
|
||||
|
||||
// get a list of surveys that are collecting insights
|
||||
const activeSurveys = appConfig.get().filteredSurveys;
|
||||
|
||||
if (Boolean(activeSurveys) && activeSurveys.length > 0) {
|
||||
for (const survey of activeSurveys) {
|
||||
for (const trigger of survey.triggers) {
|
||||
if (trigger.actionClass.name === name) {
|
||||
triggerSurvey(survey);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug("No active surveys to display");
|
||||
}
|
||||
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks an action by its code and triggers associated surveys (used for code actions only)
|
||||
* @param code - The action code to track
|
||||
* @returns Result indicating success, network error, or invalid code error
|
||||
*/
|
||||
export const track = async (
|
||||
code: string
|
||||
): Promise<
|
||||
| Result<void, NetworkError>
|
||||
| Result<void, InvalidCodeError>
|
||||
| Result<void, { code: "error"; message: string }>
|
||||
> => {
|
||||
try {
|
||||
const appConfig = RNConfig.getInstance();
|
||||
|
||||
const netInfo = await fetch();
|
||||
|
||||
if (!netInfo.isConnected) {
|
||||
return err({
|
||||
code: "network_error",
|
||||
status: 500,
|
||||
message: "No internet connection. Please check your connection and try again.",
|
||||
responseMessage: "No internet connection. Please check your connection and try again.",
|
||||
url: new URL(`${appConfig.get().appUrl}/js/surveys.umd.cjs`),
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
environment: {
|
||||
data: { actionClasses = [] },
|
||||
},
|
||||
} = appConfig.get();
|
||||
|
||||
const codeActionClasses = actionClasses.filter((action) => action.type === "code");
|
||||
const actionClass = codeActionClasses.find((action) => action.key === code);
|
||||
|
||||
if (!actionClass) {
|
||||
return err({
|
||||
code: "invalid_code",
|
||||
message: `${code} action unknown. Please add this action in Formbricks first in order to use it in your code.`,
|
||||
});
|
||||
}
|
||||
|
||||
return trackAction(actionClass.name, code);
|
||||
} catch (error) {
|
||||
const logger = Logger.getInstance();
|
||||
logger.error(`Error tracking action ${error as string}`);
|
||||
|
||||
return err({
|
||||
code: "error",
|
||||
message: "Error tracking action",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { TEnvironmentStateSurvey } from "@/types/config";
|
||||
|
||||
type Listener = (state: TEnvironmentStateSurvey | null, prevSurvey: TEnvironmentStateSurvey | null) => void;
|
||||
|
||||
export class SurveyStore {
|
||||
private static instance: SurveyStore | undefined;
|
||||
private survey: TEnvironmentStateSurvey | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
static getInstance(): SurveyStore {
|
||||
if (!SurveyStore.instance) {
|
||||
SurveyStore.instance = new SurveyStore();
|
||||
}
|
||||
return SurveyStore.instance;
|
||||
}
|
||||
|
||||
public getSurvey(): TEnvironmentStateSurvey | null {
|
||||
return this.survey;
|
||||
}
|
||||
|
||||
public setSurvey(survey: TEnvironmentStateSurvey): void {
|
||||
const prevSurvey = this.survey;
|
||||
if (prevSurvey?.id !== survey.id) {
|
||||
this.survey = survey;
|
||||
this.listeners.forEach((listener) => {
|
||||
listener(this.survey, prevSurvey);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public resetSurvey(): void {
|
||||
const prevSurvey = this.survey;
|
||||
if (prevSurvey !== null) {
|
||||
this.survey = null;
|
||||
this.listeners.forEach((listener) => {
|
||||
listener(this.survey, prevSurvey);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public subscribe(listener: Listener) {
|
||||
this.listeners.add(listener);
|
||||
// Unsubscribe
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export const mockSurveyId = "jgocyoxk9uifo6u381qahmes";
|
||||
export const mockSurveyName = "Test Survey";
|
||||
@@ -1,181 +0,0 @@
|
||||
import { RNConfig } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { shouldDisplayBasedOnPercentage } from "@/lib/common/utils";
|
||||
import { track, trackAction, triggerSurvey } from "@/lib/survey/action";
|
||||
import { SurveyStore } from "@/lib/survey/store";
|
||||
import { type TEnvironmentStateSurvey } from "@/types/config";
|
||||
import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/common/config", () => ({
|
||||
RNConfig: {
|
||||
getInstance: vi.fn(() => ({
|
||||
get: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/store", () => ({
|
||||
SurveyStore: {
|
||||
getInstance: vi.fn(() => ({
|
||||
setSurvey: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/logger", () => ({
|
||||
Logger: {
|
||||
getInstance: vi.fn(() => {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
};
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/utils", () => ({
|
||||
shouldDisplayBasedOnPercentage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-community/netinfo", () => ({
|
||||
fetch: vi.fn(() => ({
|
||||
isConnected: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("survey/action.ts", () => {
|
||||
const mockSurvey = {
|
||||
id: "survey_001",
|
||||
name: "Test Survey",
|
||||
displayPercentage: 50,
|
||||
triggers: [
|
||||
{
|
||||
actionClass: { name: "testAction" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockAppConfig = {
|
||||
get: vi.fn(),
|
||||
};
|
||||
|
||||
const mockSurveyStore = {
|
||||
setSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const getInstanceRn = vi.spyOn(RNConfig, "getInstance");
|
||||
const getInstanceSurveyStore = vi.spyOn(SurveyStore, "getInstance");
|
||||
const getInstanceLogger = vi.spyOn(Logger, "getInstance");
|
||||
|
||||
// Mock instances
|
||||
getInstanceRn.mockReturnValue(mockAppConfig as unknown as RNConfig);
|
||||
getInstanceSurveyStore.mockReturnValue(mockSurveyStore as unknown as SurveyStore);
|
||||
getInstanceLogger.mockReturnValue(mockLogger as unknown as Logger);
|
||||
});
|
||||
|
||||
describe("triggerSurvey", () => {
|
||||
test("does not trigger survey if displayPercentage criteria is not met", () => {
|
||||
const shouldDisplayBasedOnPercentageMock = vi.mocked(shouldDisplayBasedOnPercentage);
|
||||
shouldDisplayBasedOnPercentageMock.mockReturnValueOnce(false);
|
||||
|
||||
triggerSurvey(mockSurvey as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
// Ensure survey is not set
|
||||
expect(mockSurveyStore.setSurvey).not.toHaveBeenCalled();
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
'Survey display of "Test Survey" skipped based on displayPercentage.'
|
||||
);
|
||||
});
|
||||
|
||||
test("triggers survey if displayPercentage criteria is met", () => {
|
||||
// Mock `shouldDisplayBasedOnPercentage` to return true
|
||||
const shouldDisplayBasedOnPercentageMock = vi.mocked(shouldDisplayBasedOnPercentage);
|
||||
shouldDisplayBasedOnPercentageMock.mockReturnValueOnce(true);
|
||||
|
||||
triggerSurvey(mockSurvey as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
// Ensure survey is set
|
||||
expect(mockSurveyStore.setSurvey).toHaveBeenCalledWith(mockSurvey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trackAction", () => {
|
||||
const mockActiveSurveys = [mockSurvey];
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppConfig.get.mockReturnValue({
|
||||
filteredSurveys: mockActiveSurveys,
|
||||
});
|
||||
});
|
||||
|
||||
test("triggers survey associated with action name", () => {
|
||||
(shouldDisplayBasedOnPercentage as unknown as Mock).mockReturnValue(true);
|
||||
|
||||
trackAction("testAction");
|
||||
|
||||
// Ensure triggerSurvey is called for the matching survey
|
||||
expect(mockSurveyStore.setSurvey).toHaveBeenCalledWith(mockSurvey);
|
||||
});
|
||||
|
||||
test("does not trigger survey if no active surveys are found", () => {
|
||||
mockAppConfig.get.mockReturnValue({
|
||||
filteredSurveys: [],
|
||||
});
|
||||
|
||||
trackAction("testAction");
|
||||
|
||||
// Ensure no surveys are triggered
|
||||
expect(mockSurveyStore.setSurvey).not.toHaveBeenCalled();
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith("No active surveys to display");
|
||||
});
|
||||
|
||||
test("logs tracked action name", () => {
|
||||
trackAction("testAction");
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith('Formbricks: Action "testAction" tracked');
|
||||
});
|
||||
});
|
||||
|
||||
describe("track", () => {
|
||||
const mockActionClasses = [
|
||||
{
|
||||
key: "testCode",
|
||||
type: "code",
|
||||
name: "testAction",
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppConfig.get.mockReturnValue({
|
||||
environment: {
|
||||
data: { actionClasses: mockActionClasses },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("tracks a valid action by code", async () => {
|
||||
const result = await track("testCode");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("returns error for invalid action code", async () => {
|
||||
const result = await track("invalidCode");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("invalid_code");
|
||||
expect(result.error.message).toBe(
|
||||
"invalidCode action unknown. Please add this action in Formbricks first in order to use it in your code."
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import { mockSurveyId, mockSurveyName } from "@/lib/survey/tests/__mocks__/store.mock";
|
||||
import { SurveyStore } from "@/lib/survey/store";
|
||||
import type { TEnvironmentStateSurvey } from "@/types/config";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
describe("SurveyStore", () => {
|
||||
let store: SurveyStore;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the singleton instance before each test
|
||||
// @ts-expect-error accessing private static property
|
||||
SurveyStore.instance = undefined;
|
||||
store = SurveyStore.getInstance();
|
||||
});
|
||||
|
||||
describe("getInstance", () => {
|
||||
test("returns singleton instance", () => {
|
||||
const instance1 = SurveyStore.getInstance();
|
||||
const instance2 = SurveyStore.getInstance();
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSurvey", () => {
|
||||
test("returns null when no survey is set", () => {
|
||||
expect(store.getSurvey()).toBeNull();
|
||||
});
|
||||
|
||||
test("returns current survey when set", () => {
|
||||
const mockSurvey: TEnvironmentStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TEnvironmentStateSurvey;
|
||||
|
||||
store.setSurvey(mockSurvey);
|
||||
expect(store.getSurvey()).toBe(mockSurvey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSurvey", () => {
|
||||
test("updates survey and notifies listeners when survey changes", () => {
|
||||
const listener = vi.fn();
|
||||
const mockSurvey: TEnvironmentStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TEnvironmentStateSurvey;
|
||||
|
||||
store.subscribe(listener);
|
||||
store.setSurvey(mockSurvey);
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(mockSurvey, null);
|
||||
expect(store.getSurvey()).toBe(mockSurvey);
|
||||
});
|
||||
|
||||
test("does not notify listeners when setting same survey", () => {
|
||||
const listener = vi.fn();
|
||||
const mockSurvey: TEnvironmentStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TEnvironmentStateSurvey;
|
||||
|
||||
store.setSurvey(mockSurvey);
|
||||
store.subscribe(listener);
|
||||
store.setSurvey(mockSurvey);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetSurvey", () => {
|
||||
test("resets survey to null and notifies listeners", () => {
|
||||
const listener = vi.fn();
|
||||
const mockSurvey: TEnvironmentStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TEnvironmentStateSurvey;
|
||||
|
||||
store.setSurvey(mockSurvey);
|
||||
store.subscribe(listener);
|
||||
store.resetSurvey();
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(null, mockSurvey);
|
||||
expect(store.getSurvey()).toBeNull();
|
||||
});
|
||||
|
||||
test("does not notify listeners when already null", () => {
|
||||
const listener = vi.fn();
|
||||
store.subscribe(listener);
|
||||
store.resetSurvey();
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(store.getSurvey()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribe", () => {
|
||||
test("adds listener and returns unsubscribe function", () => {
|
||||
const listener = vi.fn();
|
||||
const mockSurvey: TEnvironmentStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TEnvironmentStateSurvey;
|
||||
|
||||
const unsubscribe = store.subscribe(listener);
|
||||
store.setSurvey(mockSurvey);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsubscribe();
|
||||
store.setSurvey({ ...mockSurvey, name: "Updated Survey" } as TEnvironmentStateSurvey);
|
||||
expect(listener).toHaveBeenCalledTimes(1); // Still 1, not called after unsubscribe
|
||||
});
|
||||
|
||||
test("multiple listeners receive updates", () => {
|
||||
const listener1 = vi.fn();
|
||||
const listener2 = vi.fn();
|
||||
const mockSurvey: TEnvironmentStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TEnvironmentStateSurvey;
|
||||
|
||||
store.subscribe(listener1);
|
||||
store.subscribe(listener2);
|
||||
store.setSurvey(mockSurvey);
|
||||
|
||||
expect(listener1).toHaveBeenCalledWith(mockSurvey, null);
|
||||
expect(listener2).toHaveBeenCalledWith(mockSurvey, null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import { type NetworkError, type Result, okVoid } from "@/types/error";
|
||||
|
||||
export const setAttributes = async (
|
||||
attributes: Record<string, string>
|
||||
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
|
||||
): Promise<Result<void, NetworkError>> => {
|
||||
const updateQueue = UpdateQueue.getInstance();
|
||||
updateQueue.updateAttributes(attributes);
|
||||
void updateQueue.processUpdates();
|
||||
return okVoid();
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import { RNConfig } from "@/lib/common/config";
|
||||
import type { TUserState } from "@/types/config";
|
||||
|
||||
let userStateSyncIntervalId: number | null = null;
|
||||
|
||||
export const DEFAULT_USER_STATE_NO_USER_ID: TUserState = {
|
||||
expiresAt: null,
|
||||
data: {
|
||||
userId: null,
|
||||
contactId: null,
|
||||
segments: [],
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Add a listener to check if the user state has expired with a certain interval
|
||||
*/
|
||||
export const addUserStateExpiryCheckListener = (): void => {
|
||||
const config = RNConfig.getInstance();
|
||||
const updateInterval = 1000 * 60; // every 60 seconds
|
||||
|
||||
if (userStateSyncIntervalId === null) {
|
||||
const intervalHandler = (): void => {
|
||||
const userId = config.get().user.data.userId;
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// extend the personState validity by 30 minutes:
|
||||
config.update({
|
||||
...config.get(),
|
||||
user: {
|
||||
...config.get().user,
|
||||
expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
userStateSyncIntervalId = setInterval(intervalHandler, updateInterval) as unknown as number;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the person state expiry check listener
|
||||
*/
|
||||
export const clearUserStateExpiryCheckListener = (): void => {
|
||||
if (userStateSyncIntervalId) {
|
||||
clearInterval(userStateSyncIntervalId);
|
||||
userStateSyncIntervalId = null;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
export const mockUserId1 = "user_123";
|
||||
export const mockUserId2 = "user_456";
|
||||
export const mockAttributes = {
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
export const mockUserId = "user_123";
|
||||
export const mockEnvironmentId = "ew9ba7urnv7u3eo11k5c1z0r";
|
||||
export const mockAppUrl = "https://app.formbricks.com";
|
||||
export const mockAttributes = {
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { setAttributes } from "@/lib/user/attribute";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
|
||||
export const mockAttributes = {
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
};
|
||||
|
||||
// Mock the UpdateQueue
|
||||
vi.mock("@/lib/user/update-queue", () => ({
|
||||
UpdateQueue: {
|
||||
getInstance: vi.fn(() => ({
|
||||
updateAttributes: vi.fn(),
|
||||
processUpdates: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("User Attributes", () => {
|
||||
const mockUpdateQueue = {
|
||||
updateAttributes: vi.fn(),
|
||||
processUpdates: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const getInstanceUpdateQueue = vi.spyOn(UpdateQueue, "getInstance");
|
||||
getInstanceUpdateQueue.mockReturnValue(mockUpdateQueue as unknown as UpdateQueue);
|
||||
});
|
||||
|
||||
describe("setAttributes", () => {
|
||||
test("successfully updates attributes and triggers processing", async () => {
|
||||
const result = await setAttributes(mockAttributes);
|
||||
|
||||
// Verify UpdateQueue methods were called correctly
|
||||
expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith(mockAttributes);
|
||||
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
|
||||
|
||||
// Verify result is ok
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("processes multiple attribute updates", async () => {
|
||||
const firstAttributes = { name: mockAttributes.name };
|
||||
const secondAttributes = { email: mockAttributes.email };
|
||||
|
||||
await setAttributes(firstAttributes);
|
||||
await setAttributes(secondAttributes);
|
||||
|
||||
expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledTimes(2);
|
||||
expect(mockUpdateQueue.updateAttributes).toHaveBeenNthCalledWith(1, firstAttributes);
|
||||
expect(mockUpdateQueue.updateAttributes).toHaveBeenNthCalledWith(2, secondAttributes);
|
||||
expect(mockUpdateQueue.processUpdates).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("processes updates asynchronously", async () => {
|
||||
const attributes = { name: mockAttributes.name };
|
||||
|
||||
// Mock processUpdates to be async
|
||||
mockUpdateQueue.processUpdates.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
})
|
||||
);
|
||||
|
||||
const result = await setAttributes(attributes);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
|
||||
// The function returns before processUpdates completes due to void operator
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { RNConfig } from "@/lib/common/config";
|
||||
import { addUserStateExpiryCheckListener, clearUserStateExpiryCheckListener } from "@/lib/user/state";
|
||||
|
||||
const mockUserId = "user_123";
|
||||
|
||||
vi.mock("@/lib/common/config", () => ({
|
||||
RNConfig: {
|
||||
getInstance: vi.fn(() => ({
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("User State Expiry Check Listener", () => {
|
||||
let mockRNConfig: MockInstance<() => RNConfig>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers(); // Simulate timers
|
||||
|
||||
mockRNConfig = vi.spyOn(RNConfig, "getInstance");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearUserStateExpiryCheckListener(); // Ensure cleanup after each test
|
||||
});
|
||||
|
||||
test("should set an interval if not already set and update user state expiry when userId exists", () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
user: { data: { userId: mockUserId } },
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
|
||||
|
||||
addUserStateExpiryCheckListener();
|
||||
|
||||
// Fast-forward time by 1 minute (60,000 ms)
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
// Ensure config.update was called with extended expiry time
|
||||
expect(mockConfig.update).toHaveBeenCalledWith({
|
||||
user: {
|
||||
data: { userId: mockUserId },
|
||||
expiresAt: expect.any(Date) as Date,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should not update user state expiry if userId does not exist", () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
user: { data: { userId: null } },
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
|
||||
|
||||
addUserStateExpiryCheckListener();
|
||||
vi.advanceTimersByTime(60_000); // Fast-forward 1 minute
|
||||
|
||||
expect(mockConfig.update).not.toHaveBeenCalled(); // Ensures no update when no userId
|
||||
});
|
||||
|
||||
test("should not set multiple intervals if already set", () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
user: { data: { userId: mockUserId } },
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
|
||||
|
||||
addUserStateExpiryCheckListener();
|
||||
addUserStateExpiryCheckListener(); // Call again to check if it prevents multiple intervals
|
||||
|
||||
vi.advanceTimersByTime(60_000); // Fast-forward 1 minute
|
||||
|
||||
expect(mockConfig.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should clear interval when clearUserStateExpiryCheckListener is called", () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
|
||||
|
||||
addUserStateExpiryCheckListener();
|
||||
clearUserStateExpiryCheckListener();
|
||||
|
||||
vi.advanceTimersByTime(60_000); // Fast-forward 1 minute
|
||||
|
||||
expect(mockConfig.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { mockAttributes, mockUserId1, mockUserId2 } from "@/lib/user/tests/__mocks__/update-queue.mock";
|
||||
import { RNConfig } from "@/lib/common/config";
|
||||
import { sendUpdates } from "@/lib/user/update";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/common/config", () => ({
|
||||
RNConfig: {
|
||||
getInstance: vi.fn(() => ({
|
||||
get: vi.fn(() => ({
|
||||
user: {
|
||||
data: {
|
||||
userId: "mock-user-id",
|
||||
},
|
||||
},
|
||||
})),
|
||||
update: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/logger", () => ({
|
||||
Logger: {
|
||||
getInstance: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/user/update", () => ({
|
||||
sendUpdates: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("UpdateQueue", () => {
|
||||
let updateQueue: UpdateQueue;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset singleton instance
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- accessing private static property
|
||||
(UpdateQueue as any).instance = null;
|
||||
updateQueue = UpdateQueue.getInstance();
|
||||
});
|
||||
|
||||
test("getInstance returns singleton instance", () => {
|
||||
const instance1 = UpdateQueue.getInstance();
|
||||
const instance2 = UpdateQueue.getInstance();
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
test("updateUserId sets userId correctly when updates is null", () => {
|
||||
const userId = mockUserId1;
|
||||
updateQueue.updateUserId(userId);
|
||||
expect(updateQueue.getUpdates()).toEqual({
|
||||
userId,
|
||||
attributes: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("updateUserId updates existing userId correctly", () => {
|
||||
const userId1 = mockUserId1;
|
||||
const userId2 = mockUserId2;
|
||||
|
||||
updateQueue.updateUserId(userId1);
|
||||
updateQueue.updateUserId(userId2);
|
||||
|
||||
expect(updateQueue.getUpdates()).toEqual({
|
||||
userId: userId2,
|
||||
attributes: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("updateAttributes sets attributes correctly when updates is null", () => {
|
||||
const attributes = mockAttributes;
|
||||
updateQueue.updateAttributes(attributes);
|
||||
|
||||
expect(updateQueue.getUpdates()).toEqual({
|
||||
userId: "mock-user-id", // from mocked config
|
||||
attributes,
|
||||
});
|
||||
});
|
||||
|
||||
test("updateAttributes merges with existing attributes", () => {
|
||||
updateQueue.updateAttributes({ name: mockAttributes.name });
|
||||
updateQueue.updateAttributes({ email: mockAttributes.email });
|
||||
|
||||
expect(updateQueue.getUpdates()).toEqual({
|
||||
userId: "mock-user-id",
|
||||
attributes: {
|
||||
name: mockAttributes.name,
|
||||
email: mockAttributes.email,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("clearUpdates resets updates to null", () => {
|
||||
updateQueue.updateAttributes({ name: mockAttributes.name });
|
||||
updateQueue.clearUpdates();
|
||||
expect(updateQueue.getUpdates()).toBeNull();
|
||||
});
|
||||
|
||||
test("isEmpty returns true when updates is null", () => {
|
||||
expect(updateQueue.isEmpty()).toBe(true);
|
||||
});
|
||||
|
||||
test("isEmpty returns false when updates exist", () => {
|
||||
updateQueue.updateAttributes({ name: mockAttributes.name });
|
||||
expect(updateQueue.isEmpty()).toBe(false);
|
||||
});
|
||||
|
||||
test("processUpdates debounces multiple calls", async () => {
|
||||
// Call processUpdates multiple times in quick succession
|
||||
|
||||
(sendUpdates as Mock).mockReturnValue({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
updateQueue.updateAttributes({ name: mockAttributes.name });
|
||||
updateQueue.updateAttributes({ email: mockAttributes.email });
|
||||
|
||||
// Wait for debounce timeout
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 600);
|
||||
});
|
||||
|
||||
await updateQueue.processUpdates();
|
||||
|
||||
// Should only be called once with the merged updates
|
||||
expect(sendUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("processUpdates handles language attribute specially when no userId", async () => {
|
||||
const configUpdateMock = vi.fn();
|
||||
(RNConfig.getInstance as Mock).mockImplementation(() => ({
|
||||
get: vi.fn(() => ({
|
||||
user: { data: { userId: "" } },
|
||||
})),
|
||||
update: configUpdateMock,
|
||||
}));
|
||||
|
||||
updateQueue.updateAttributes({ language: "en" });
|
||||
await updateQueue.processUpdates();
|
||||
|
||||
expect(configUpdateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("processUpdates throws error when setting attributes without userId", async () => {
|
||||
(RNConfig.getInstance as Mock).mockImplementation(() => ({
|
||||
get: vi.fn(() => ({
|
||||
user: { data: { userId: "" } },
|
||||
})),
|
||||
}));
|
||||
|
||||
updateQueue.updateAttributes({ name: mockAttributes.name });
|
||||
await expect(updateQueue.processUpdates()).rejects.toThrow(
|
||||
"Formbricks can't set attributes without a userId!"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import {
|
||||
mockAppUrl,
|
||||
mockAttributes,
|
||||
mockEnvironmentId,
|
||||
mockUserId,
|
||||
} from "@/lib/user/tests/__mocks__/update.mock";
|
||||
import { ApiClient } from "@/lib/common/api";
|
||||
import { RNConfig } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { sendUpdates, sendUpdatesToBackend } from "@/lib/user/update";
|
||||
import { type TUpdates } from "@/types/config";
|
||||
import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/common/config", () => ({
|
||||
RNConfig: {
|
||||
getInstance: vi.fn(() => ({
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/logger", () => ({
|
||||
Logger: {
|
||||
getInstance: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/utils", () => ({
|
||||
filterSurveys: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/api", () => ({
|
||||
ApiClient: vi.fn().mockImplementation(() => ({
|
||||
createOrUpdateUser: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("sendUpdatesToBackend", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("sends user updates to backend and returns updated state", async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
data: {
|
||||
state: {
|
||||
data: {
|
||||
userId: mockUserId,
|
||||
attributes: mockAttributes,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
(ApiClient as Mock).mockImplementation(() => ({
|
||||
createOrUpdateUser: vi.fn().mockResolvedValue(mockResponse),
|
||||
}));
|
||||
|
||||
const result = await sendUpdatesToBackend({
|
||||
appUrl: mockAppUrl,
|
||||
environmentId: mockEnvironmentId,
|
||||
updates: { userId: mockUserId, attributes: mockAttributes },
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.state.data).toEqual({ userId: mockUserId, attributes: mockAttributes });
|
||||
}
|
||||
});
|
||||
|
||||
test("returns network error if API call fails", async () => {
|
||||
const mockUpdates: TUpdates = { userId: mockUserId, attributes: mockAttributes };
|
||||
|
||||
(ApiClient as Mock).mockImplementation(() => ({
|
||||
createOrUpdateUser: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
error: { code: "network_error", message: "Request failed", status: 500 },
|
||||
}),
|
||||
}));
|
||||
|
||||
const result = await sendUpdatesToBackend({
|
||||
appUrl: mockAppUrl,
|
||||
environmentId: mockEnvironmentId,
|
||||
updates: mockUpdates,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("network_error");
|
||||
expect(result.error.message).toBe("Error updating user with userId user_123");
|
||||
}
|
||||
});
|
||||
|
||||
test("throws error if network request fails", async () => {
|
||||
const mockUpdates: TUpdates = { userId: mockUserId, attributes: { plan: "premium" } };
|
||||
|
||||
(ApiClient as Mock).mockImplementation(() => ({
|
||||
createOrUpdateUser: vi.fn().mockRejectedValue(new Error("Network error")),
|
||||
}));
|
||||
|
||||
await expect(
|
||||
sendUpdatesToBackend({
|
||||
appUrl: mockAppUrl,
|
||||
environmentId: mockEnvironmentId,
|
||||
updates: mockUpdates,
|
||||
})
|
||||
).rejects.toThrow("Network error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendUpdates", () => {
|
||||
beforeEach(() => {
|
||||
(RNConfig.getInstance as Mock).mockImplementation(() => ({
|
||||
get: vi.fn().mockReturnValue({
|
||||
appUrl: mockAppUrl,
|
||||
environmentId: mockEnvironmentId,
|
||||
environment: {
|
||||
data: {
|
||||
surveys: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
(Logger.getInstance as Mock).mockImplementation(() => ({
|
||||
debug: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
test("successfully processes updates", async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
data: {
|
||||
state: {
|
||||
data: {
|
||||
userId: mockUserId,
|
||||
attributes: mockAttributes,
|
||||
},
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 30),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
(ApiClient as Mock).mockImplementation(() => ({
|
||||
createOrUpdateUser: vi.fn().mockResolvedValue(mockResponse),
|
||||
}));
|
||||
|
||||
const result = await sendUpdates({ updates: { userId: mockUserId, attributes: mockAttributes } });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("handles backend errors", async () => {
|
||||
const mockErrorResponse = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "invalid_request",
|
||||
status: 400,
|
||||
message: "Invalid request",
|
||||
},
|
||||
};
|
||||
|
||||
(ApiClient as Mock).mockImplementation(() => ({
|
||||
createOrUpdateUser: vi.fn().mockResolvedValue(mockErrorResponse),
|
||||
}));
|
||||
|
||||
const result = await sendUpdates({ updates: { userId: mockUserId, attributes: mockAttributes } });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("invalid_request");
|
||||
}
|
||||
});
|
||||
|
||||
test("handles unexpected errors", async () => {
|
||||
(ApiClient as Mock).mockImplementation(() => ({
|
||||
createOrUpdate: vi.fn().mockRejectedValue(new Error("Unexpected error")),
|
||||
}));
|
||||
|
||||
const result = await sendUpdates({ updates: { userId: mockUserId, attributes: mockAttributes } });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("network_error");
|
||||
expect(result.error.status).toBe(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
import { type Mock, type MockInstance, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { RNConfig } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { setup, tearDown } from "@/lib/common/setup";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import { logout, setUserId } from "@/lib/user/user";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/common/config", () => ({
|
||||
RNConfig: {
|
||||
getInstance: vi.fn(() => ({
|
||||
get: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/logger", () => ({
|
||||
Logger: {
|
||||
getInstance: vi.fn(() => ({
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/user/update-queue", () => ({
|
||||
UpdateQueue: {
|
||||
getInstance: vi.fn(() => ({
|
||||
updateUserId: vi.fn(),
|
||||
processUpdates: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/setup", () => ({
|
||||
tearDown: vi.fn(),
|
||||
setup: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("user.ts", () => {
|
||||
const mockUserId = "test-user-123";
|
||||
const mockEnvironmentId = "env-123";
|
||||
const mockAppUrl = "https://test.com";
|
||||
|
||||
let getInstanceConfigMock: MockInstance<() => RNConfig>;
|
||||
let getInstanceLoggerMock: MockInstance<() => Logger>;
|
||||
let getInstanceUpdateQueueMock: MockInstance<() => UpdateQueue>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getInstanceConfigMock = vi.spyOn(RNConfig, "getInstance");
|
||||
getInstanceLoggerMock = vi.spyOn(Logger, "getInstance");
|
||||
getInstanceUpdateQueueMock = vi.spyOn(UpdateQueue, "getInstance");
|
||||
});
|
||||
|
||||
describe("setUserId", () => {
|
||||
test("returns error if userId is already set", async () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
user: {
|
||||
data: {
|
||||
userId: "existing-user",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
|
||||
getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger);
|
||||
|
||||
const result = await setUserId(mockUserId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("forbidden");
|
||||
expect(result.error.status).toBe(403);
|
||||
}
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("successfully sets userId when none exists", async () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
user: {
|
||||
data: {
|
||||
userId: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const mockUpdateQueue = {
|
||||
updateUserId: vi.fn(),
|
||||
processUpdates: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
|
||||
getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger);
|
||||
getInstanceUpdateQueueMock.mockReturnValue(mockUpdateQueue as unknown as UpdateQueue);
|
||||
const result = await setUserId(mockUserId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mockUpdateQueue.updateUserId).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout", () => {
|
||||
test("successfully sets up formbricks after logout", async () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environmentId: mockEnvironmentId,
|
||||
appUrl: mockAppUrl,
|
||||
user: { data: { userId: mockUserId } },
|
||||
}),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
|
||||
|
||||
(setup as Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await logout();
|
||||
|
||||
expect(tearDown).toHaveBeenCalled();
|
||||
expect(setup).toHaveBeenCalledWith({
|
||||
environmentId: mockEnvironmentId,
|
||||
appUrl: mockAppUrl,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("returns error if setup fails", async () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environmentId: mockEnvironmentId,
|
||||
appUrl: mockAppUrl,
|
||||
user: { data: { userId: mockUserId } },
|
||||
}),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
|
||||
|
||||
const mockError = { code: "network_error", message: "Failed to connect" };
|
||||
(setup as Mock).mockRejectedValue(mockError);
|
||||
|
||||
const result = await logout();
|
||||
|
||||
expect(tearDown).toHaveBeenCalled();
|
||||
expect(setup).toHaveBeenCalledWith({
|
||||
environmentId: mockEnvironmentId,
|
||||
appUrl: mockAppUrl,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual(mockError);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-function -- required for singleton pattern */
|
||||
import { RNConfig } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { sendUpdates } from "@/lib/user/update";
|
||||
import type { TAttributes, TUpdates } from "@/types/config";
|
||||
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
export class UpdateQueue {
|
||||
private static instance: UpdateQueue | null = null;
|
||||
private updates: TUpdates | null = null;
|
||||
private debounceTimeout: NodeJS.Timeout | null = null;
|
||||
private readonly DEBOUNCE_DELAY = 500;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): UpdateQueue {
|
||||
if (!UpdateQueue.instance) {
|
||||
UpdateQueue.instance = new UpdateQueue();
|
||||
}
|
||||
|
||||
return UpdateQueue.instance;
|
||||
}
|
||||
|
||||
public updateUserId(userId: string): void {
|
||||
if (!this.updates) {
|
||||
this.updates = {
|
||||
userId,
|
||||
attributes: {},
|
||||
};
|
||||
} else {
|
||||
this.updates = {
|
||||
...this.updates,
|
||||
userId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public updateAttributes(attributes: TAttributes): void {
|
||||
const config = RNConfig.getInstance();
|
||||
// Get userId from updates first, then fallback to config
|
||||
const userId = this.updates?.userId ?? config.get().user.data.userId ?? "";
|
||||
|
||||
if (!this.updates) {
|
||||
this.updates = {
|
||||
userId,
|
||||
attributes,
|
||||
};
|
||||
} else {
|
||||
this.updates = {
|
||||
...this.updates,
|
||||
userId,
|
||||
attributes: { ...this.updates.attributes, ...attributes },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public getUpdates(): TUpdates | null {
|
||||
return this.updates;
|
||||
}
|
||||
|
||||
public clearUpdates(): void {
|
||||
this.updates = null;
|
||||
}
|
||||
|
||||
public isEmpty(): boolean {
|
||||
return !this.updates;
|
||||
}
|
||||
|
||||
public async processUpdates(): Promise<void> {
|
||||
if (!this.updates) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.debounceTimeout) {
|
||||
clearTimeout(this.debounceTimeout);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = async (): Promise<void> => {
|
||||
try {
|
||||
let currentUpdates = { ...this.updates };
|
||||
const config = RNConfig.getInstance();
|
||||
|
||||
if (Object.keys(currentUpdates).length > 0) {
|
||||
// Get userId from either updates or config
|
||||
const effectiveUserId = currentUpdates.userId ?? config.get().user.data.userId;
|
||||
const isLanguageInUpdates = currentUpdates.attributes?.language;
|
||||
|
||||
if (!effectiveUserId && isLanguageInUpdates) {
|
||||
// no user id set but the updates contain a language
|
||||
// we need to set this language in the local config:
|
||||
config.update({
|
||||
...config.get(),
|
||||
user: {
|
||||
...config.get().user,
|
||||
data: {
|
||||
...config.get().user.data,
|
||||
language: currentUpdates.attributes?.language,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug("Updated language successfully");
|
||||
|
||||
const { language: _, ...remainingAttributes } = currentUpdates.attributes ?? {};
|
||||
|
||||
// remove language from attributes
|
||||
currentUpdates = {
|
||||
...currentUpdates,
|
||||
attributes: remainingAttributes,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(currentUpdates.attributes ?? {}).length > 0 && !effectiveUserId) {
|
||||
const errorMessage =
|
||||
"Formbricks can't set attributes without a userId! Please set a userId first with the setUserId function";
|
||||
logger.error(errorMessage);
|
||||
this.clearUpdates();
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Only send updates if we have a userId (either from updates or local storage)
|
||||
if (effectiveUserId) {
|
||||
const result = await sendUpdates({
|
||||
updates: {
|
||||
userId: effectiveUserId,
|
||||
attributes: currentUpdates.attributes ?? {},
|
||||
},
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
logger.debug("Updates sent successfully");
|
||||
} else {
|
||||
logger.error("Failed to send updates");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.clearUpdates();
|
||||
resolve();
|
||||
} catch (error: unknown) {
|
||||
logger.error(
|
||||
`Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
);
|
||||
reject(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
/* eslint-disable no-console -- required for logging errors */
|
||||
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 TUpdates, type TUserState } from "@/types/config";
|
||||
import { type ApiErrorResponse, type Result, err, ok, okVoid } from "@/types/error";
|
||||
|
||||
export const sendUpdatesToBackend = async ({
|
||||
appUrl,
|
||||
environmentId,
|
||||
updates,
|
||||
}: {
|
||||
appUrl: string;
|
||||
environmentId: string;
|
||||
updates: TUpdates;
|
||||
}): Promise<
|
||||
Result<
|
||||
{
|
||||
state: TUserState;
|
||||
messages?: string[];
|
||||
},
|
||||
ApiErrorResponse
|
||||
>
|
||||
> => {
|
||||
const url = `${appUrl}/api/v1/client/${environmentId}/user`;
|
||||
const api = new ApiClient({ appUrl, environmentId, isDebug: false });
|
||||
|
||||
try {
|
||||
const response = await api.createOrUpdateUser({
|
||||
userId: updates.userId,
|
||||
attributes: updates.attributes,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return err({
|
||||
code: response.error.code,
|
||||
status: response.error.status,
|
||||
message: `Error updating user with userId ${updates.userId}`,
|
||||
url: new URL(url),
|
||||
responseMessage: response.error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return ok(response.data);
|
||||
} catch (e: unknown) {
|
||||
const errorTyped = e as { message?: string };
|
||||
|
||||
const error = err({
|
||||
code: "network_error",
|
||||
message: errorTyped.message ?? "Error fetching the person state",
|
||||
status: 500,
|
||||
url: new URL(url),
|
||||
responseMessage: errorTyped.message ?? "Unknown error",
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/only-throw-error -- error.error is an Error object
|
||||
throw error.error;
|
||||
}
|
||||
};
|
||||
|
||||
export const sendUpdates = async ({
|
||||
updates,
|
||||
}: {
|
||||
updates: TUpdates;
|
||||
}): Promise<Result<void, ApiErrorResponse>> => {
|
||||
const config = RNConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
const { appUrl, environmentId } = config.get();
|
||||
// update endpoint call
|
||||
const url = `${appUrl}/api/v1/client/${environmentId}/user`;
|
||||
|
||||
try {
|
||||
const updatesResponse = await sendUpdatesToBackend({ appUrl, environmentId, updates });
|
||||
|
||||
if (updatesResponse.ok) {
|
||||
const userState = updatesResponse.data.state;
|
||||
const filteredSurveys = filterSurveys(config.get().environment, userState);
|
||||
|
||||
// messages => string[] - contains the details of the attributes update
|
||||
// for example, if the attribute "email" was being used for some user or not
|
||||
const messages = updatesResponse.data.messages;
|
||||
|
||||
if (messages && messages.length > 0) {
|
||||
for (const message of messages) {
|
||||
logger.debug(`User update message: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
config.update({
|
||||
...config.get(),
|
||||
user: {
|
||||
...userState,
|
||||
},
|
||||
filteredSurveys,
|
||||
});
|
||||
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
return err(updatesResponse.error);
|
||||
} catch (e) {
|
||||
console.error("error in sending updates: ", e);
|
||||
|
||||
return err({
|
||||
code: "network_error",
|
||||
message: "Error sending updates",
|
||||
status: 500,
|
||||
url: new URL(url),
|
||||
responseMessage: "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import { RNConfig } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { setup, tearDown } from "@/lib/common/setup";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import { type ApiErrorResponse, type NetworkError, type Result, err, okVoid } from "@/types/error";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
|
||||
export const setUserId = async (userId: string): Promise<Result<void, ApiErrorResponse>> => {
|
||||
const appConfig = RNConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
const updateQueue = UpdateQueue.getInstance();
|
||||
|
||||
const {
|
||||
data: { userId: currentUserId },
|
||||
} = appConfig.get().user;
|
||||
|
||||
if (currentUserId) {
|
||||
logger.error(
|
||||
"A userId is already set in formbricks, please first call the logout function and then set a new userId"
|
||||
);
|
||||
return err({
|
||||
code: "forbidden",
|
||||
message: "User already set",
|
||||
responseMessage: "User already set",
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
updateQueue.updateUserId(userId);
|
||||
void updateQueue.processUpdates();
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
export const logout = async (): Promise<Result<void, NetworkError>> => {
|
||||
const logger = Logger.getInstance();
|
||||
const appConfig = RNConfig.getInstance();
|
||||
|
||||
const { userId } = appConfig.get().user.data;
|
||||
|
||||
if (!userId) {
|
||||
logger.debug("No userId is set, please use the setUserId function to set a userId first");
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
logger.debug("Resetting state & getting new state from backend");
|
||||
const initParams = {
|
||||
environmentId: appConfig.get().environmentId,
|
||||
appUrl: appConfig.get().appUrl,
|
||||
};
|
||||
|
||||
// logout the user, remove user state and setup formbricks again
|
||||
await tearDown();
|
||||
|
||||
try {
|
||||
await setup(initParams);
|
||||
return okVoid();
|
||||
} catch (e) {
|
||||
return err(e as NetworkError);
|
||||
}
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import { TUserState } from "@/types/config";
|
||||
import { ApiErrorResponse } from "@/types/error";
|
||||
|
||||
export type ApiResponse = ApiSuccessResponse | ApiErrorResponse;
|
||||
|
||||
export interface ApiSuccessResponse<T = Record<string, unknown>> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface CreateOrUpdateUserResponse {
|
||||
state: TUserState;
|
||||
messages?: string[];
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies -- required for Prisma types */
|
||||
import type { ActionClass, Language, Project, Segment, Survey, SurveyLanguage } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { type TResponseUpdate } from "@/types/response";
|
||||
import { type TFileUploadParams } from "@/types/storage";
|
||||
|
||||
export type TEnvironmentStateSurvey = Pick<
|
||||
Survey,
|
||||
| "id"
|
||||
| "name"
|
||||
| "welcomeCard"
|
||||
| "questions"
|
||||
| "variables"
|
||||
| "type"
|
||||
| "showLanguageSwitch"
|
||||
| "endings"
|
||||
| "autoClose"
|
||||
| "status"
|
||||
| "recontactDays"
|
||||
| "displayLimit"
|
||||
| "displayOption"
|
||||
| "hiddenFields"
|
||||
| "delay"
|
||||
| "projectOverwrites"
|
||||
> & {
|
||||
languages: (SurveyLanguage & { language: Language })[];
|
||||
triggers: { actionClass: ActionClass }[];
|
||||
segment?: Segment;
|
||||
displayPercentage: number;
|
||||
type: "link" | "app";
|
||||
styling?: TSurveyStyling;
|
||||
};
|
||||
|
||||
export type TEnvironmentStateProject = Pick<
|
||||
Project,
|
||||
"id" | "recontactDays" | "clickOutsideClose" | "darkOverlay" | "placement" | "inAppSurveyBranding"
|
||||
> & {
|
||||
styling: TProjectStyling;
|
||||
};
|
||||
|
||||
export type TEnvironmentStateActionClass = Pick<ActionClass, "id" | "key" | "type" | "name" | "noCodeConfig">;
|
||||
|
||||
export interface TEnvironmentState {
|
||||
expiresAt: Date;
|
||||
data: {
|
||||
surveys: TEnvironmentStateSurvey[];
|
||||
actionClasses: TEnvironmentStateActionClass[];
|
||||
project: TEnvironmentStateProject;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TUserState {
|
||||
expiresAt: Date | null;
|
||||
data: {
|
||||
userId: string | null;
|
||||
contactId: string | null;
|
||||
segments: string[];
|
||||
displays: { surveyId: string; createdAt: Date }[];
|
||||
responses: string[];
|
||||
lastDisplayAt: Date | null;
|
||||
language?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TConfig {
|
||||
environmentId: string;
|
||||
appUrl: string;
|
||||
environment: TEnvironmentState;
|
||||
user: TUserState;
|
||||
filteredSurveys: TEnvironmentStateSurvey[];
|
||||
status: {
|
||||
value: "success" | "error";
|
||||
expiresAt: Date | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type TConfigUpdateInput = Omit<TConfig, "status"> & {
|
||||
status?: {
|
||||
value: "success" | "error";
|
||||
expiresAt: Date | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type TAttributes = Record<string, string>;
|
||||
|
||||
export interface TConfigInput {
|
||||
environmentId: string;
|
||||
appUrl: string;
|
||||
}
|
||||
|
||||
export interface TStylingColor {
|
||||
light: string;
|
||||
dark?: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface TBaseStyling {
|
||||
brandColor?: TStylingColor | null;
|
||||
questionColor?: TStylingColor | null;
|
||||
inputColor?: TStylingColor | null;
|
||||
inputBorderColor?: TStylingColor | null;
|
||||
cardBackgroundColor?: TStylingColor | null;
|
||||
cardBorderColor?: TStylingColor | null;
|
||||
cardShadowColor?: TStylingColor | null;
|
||||
highlightBorderColor?: TStylingColor | null;
|
||||
isDarkModeEnabled?: boolean | null;
|
||||
roundness?: number | null;
|
||||
cardArrangement?: {
|
||||
linkSurveys: "casual" | "straight" | "simple";
|
||||
appSurveys: "casual" | "straight" | "simple";
|
||||
} | null;
|
||||
background?: {
|
||||
bg?: string | null;
|
||||
bgType?: "animation" | "color" | "image" | "upload" | null;
|
||||
brightness?: number | null;
|
||||
} | null;
|
||||
hideProgressBar?: boolean | null;
|
||||
isLogoHidden?: boolean | null;
|
||||
}
|
||||
|
||||
export interface TProjectStyling extends TBaseStyling {
|
||||
allowStyleOverwrite: boolean;
|
||||
}
|
||||
|
||||
export interface TSurveyStyling extends TBaseStyling {
|
||||
overwriteThemeStyling?: boolean | null;
|
||||
}
|
||||
|
||||
export interface TWebViewOnMessageData {
|
||||
onFinished?: boolean | null;
|
||||
onDisplay?: boolean | null;
|
||||
onResponse?: boolean | null;
|
||||
responseUpdate?: TResponseUpdate | null;
|
||||
onRetry?: boolean | null;
|
||||
onClose?: boolean | null;
|
||||
onFileUpload?: boolean | null;
|
||||
fileUploadParams?: TFileUploadParams | null;
|
||||
uploadId?: string | null;
|
||||
}
|
||||
|
||||
export const ZJsRNWebViewOnMessageData = z.object({
|
||||
onFinished: z.boolean().nullish(),
|
||||
onDisplayCreated: z.boolean().nullish(),
|
||||
onResponseCreated: z.boolean().nullish(),
|
||||
onClose: z.boolean().nullish(),
|
||||
onFilePick: z.boolean().nullish(),
|
||||
fileUploadParams: z
|
||||
.object({
|
||||
allowedFileExtensions: z.string().nullish(),
|
||||
allowMultipleFiles: z.boolean().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
onOpenExternalURL: z.boolean().nullish(),
|
||||
onOpenExternalURLParams: z
|
||||
.object({
|
||||
url: z.string(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
export interface TUpdates {
|
||||
userId: string;
|
||||
attributes?: TAttributes;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
export interface ResultError<T> {
|
||||
ok: false;
|
||||
error: T;
|
||||
}
|
||||
|
||||
export interface ResultOk<T> {
|
||||
ok: true;
|
||||
value: T;
|
||||
}
|
||||
|
||||
export type Result<T, E = Error> = { ok: true; data: T } | { ok: false; error: E };
|
||||
|
||||
export const ok = <T, E>(data: T): Result<T, E> => ({ ok: true, data });
|
||||
|
||||
export const okVoid = <E>(): Result<void, E> => ({ ok: true, data: undefined });
|
||||
|
||||
export const err = <E = Error>(error: E): ResultError<E> => ({
|
||||
ok: false,
|
||||
error,
|
||||
});
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
code:
|
||||
| "not_found"
|
||||
| "gone"
|
||||
| "bad_request"
|
||||
| "internal_server_error"
|
||||
| "unauthorized"
|
||||
| "method_not_allowed"
|
||||
| "not_authenticated"
|
||||
| "forbidden"
|
||||
| "network_error"
|
||||
| "too_many_requests";
|
||||
message: string;
|
||||
status: number;
|
||||
url?: URL;
|
||||
details?: Record<string, string | string[] | number | number[] | boolean | boolean[]>;
|
||||
responseMessage?: string;
|
||||
}
|
||||
|
||||
export interface MissingFieldError {
|
||||
code: "missing_field";
|
||||
field: string;
|
||||
}
|
||||
|
||||
export interface MissingPersonError {
|
||||
code: "missing_person";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface NetworkError {
|
||||
code: "network_error";
|
||||
status: number;
|
||||
message: string;
|
||||
url: URL;
|
||||
responseMessage: string;
|
||||
}
|
||||
export interface NotSetupError {
|
||||
code: "not_setup";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface InvalidCodeError {
|
||||
code: "invalid_code";
|
||||
message: string;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export type TResponseData = Record<string, string | number | string[] | Record<string, string>>;
|
||||
|
||||
export type TResponseTtc = Record<string, number>;
|
||||
|
||||
export type TResponseVariables = Record<string, string | number>;
|
||||
|
||||
export type TResponseHiddenFieldValue = Record<string, string | number | string[]>;
|
||||
|
||||
export interface TResponseUpdate {
|
||||
finished: boolean;
|
||||
data: TResponseData;
|
||||
language?: string;
|
||||
variables?: TResponseVariables;
|
||||
ttc?: TResponseTtc;
|
||||
meta?: { url?: string; source?: string; action?: string };
|
||||
hiddenFields?: TResponseHiddenFieldValue;
|
||||
displayId?: string | null;
|
||||
endingId?: string | null;
|
||||
}
|
||||
|
||||
export const ZResponseData = z.record(z.union([z.string(), z.number(), z.array(z.string())]));
|
||||
export const ZResponseVariables = z.record(z.union([z.string(), z.number()]));
|
||||
export const ZResponseTtc = z.record(z.number());
|
||||
export const ZResponseHiddenFieldValue = z.record(z.union([z.string(), z.number(), z.array(z.string())]));
|
||||
|
||||
export const ZResponseUpdate = z.object({
|
||||
finished: z.boolean(),
|
||||
data: ZResponseData,
|
||||
language: z.string().optional(),
|
||||
variables: ZResponseVariables.optional(),
|
||||
ttc: ZResponseTtc.optional(),
|
||||
meta: z
|
||||
.object({
|
||||
url: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
hiddenFields: ZResponseHiddenFieldValue.optional(),
|
||||
displayId: z.string().nullish(),
|
||||
endingId: z.string().nullish(),
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export interface TUploadFileConfig {
|
||||
allowedFileExtensions?: string[] | undefined;
|
||||
surveyId?: string | undefined;
|
||||
}
|
||||
|
||||
export interface TUploadFileResponse {
|
||||
data: {
|
||||
signedUrl: string;
|
||||
fileUrl: string;
|
||||
signingData: {
|
||||
signature: string;
|
||||
timestamp: number;
|
||||
uuid: string;
|
||||
} | null;
|
||||
updatedFileName: string;
|
||||
presignedFields?: Record<string, string> | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TFileUploadParams {
|
||||
file: { type: string; name: string; base64: string };
|
||||
params: TUploadFileConfig;
|
||||
}
|
||||
|
||||
export const ZUploadFileConfig = z.object({
|
||||
allowedFileExtensions: z.array(z.string()).optional(),
|
||||
surveyId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZFileUploadParams = z.object({
|
||||
file: z.object({ type: z.string(), name: z.string(), base64: z.string() }),
|
||||
params: ZUploadFileConfig,
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { TEnvironmentStateSurvey, TProjectStyling, TSurveyStyling } from "@/types/config";
|
||||
import type { TResponseData, TResponseUpdate } from "@/types/response";
|
||||
import type { TFileUploadParams, TUploadFileConfig } from "@/types/storage";
|
||||
import { type TJsFileUploadParams } from "../../../types/js";
|
||||
|
||||
export interface SurveyBaseProps {
|
||||
survey: TEnvironmentStateSurvey;
|
||||
styling: TSurveyStyling | TProjectStyling;
|
||||
isBrandingEnabled: boolean;
|
||||
getSetIsError?: (getSetError: (value: boolean) => void) => void;
|
||||
getSetIsResponseSendingFinished?: (getSetIsResponseSendingFinished: (value: boolean) => void) => void;
|
||||
getSetQuestionId?: (getSetQuestionId: (value: string) => void) => void;
|
||||
getSetResponseData?: (getSetResponseData: (value: TResponseData) => void) => void;
|
||||
onDisplay?: () => void;
|
||||
onResponse?: (response: TResponseUpdate) => void;
|
||||
onFinished?: () => void;
|
||||
onClose?: () => void;
|
||||
onRetry?: () => void;
|
||||
autoFocus?: boolean;
|
||||
isRedirectDisabled?: boolean;
|
||||
prefillResponseData?: TResponseData;
|
||||
skipPrefilled?: boolean;
|
||||
languageCode: string;
|
||||
onFileUpload: (file: TFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
|
||||
responseCount?: number;
|
||||
isCardBorderVisible?: boolean;
|
||||
startAtQuestionId?: string;
|
||||
clickOutside?: boolean;
|
||||
darkOverlay?: boolean;
|
||||
hiddenFieldsRecord?: TResponseData;
|
||||
shouldResetQuestionId?: boolean;
|
||||
fullSizeCards?: boolean;
|
||||
}
|
||||
|
||||
export interface SurveyInlineProps extends SurveyBaseProps {
|
||||
containerId: string;
|
||||
placement: "bottomLeft" | "bottomRight" | "topLeft" | "topRight" | "center";
|
||||
}
|
||||
|
||||
export interface SurveyContainerProps extends Omit<SurveyBaseProps, "onFileUpload"> {
|
||||
appUrl?: string;
|
||||
environmentId?: string;
|
||||
userId?: string;
|
||||
contactId?: string;
|
||||
onDisplayCreated?: () => void | Promise<void>;
|
||||
onResponseCreated?: () => void | Promise<void>;
|
||||
onFileUpload?: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
|
||||
onOpenExternalURL?: (url: string) => void | Promise<void>;
|
||||
mode?: "modal" | "inline";
|
||||
containerId?: string;
|
||||
clickOutside?: boolean;
|
||||
darkOverlay?: boolean;
|
||||
placement?: "bottomLeft" | "bottomRight" | "topLeft" | "topRight" | "center";
|
||||
action?: string;
|
||||
singleUseId?: string;
|
||||
singleUseResponseId?: string;
|
||||
isWebEnvironment?: boolean;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"strict": true
|
||||
},
|
||||
"exclude": ["dist", "build", "node_modules"],
|
||||
"extends": "@formbricks/config-typescript/react-native-library.json",
|
||||
"include": [".", "../database/src/index.ts"]
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { resolve } from "node:path";
|
||||
import { type UserConfig, defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
const config = (): UserConfig => {
|
||||
return defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ["react-native"],
|
||||
},
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
minify: "terser",
|
||||
rollupOptions: {
|
||||
external: [
|
||||
"react",
|
||||
"react-native",
|
||||
"react-dom",
|
||||
"react-native-webview",
|
||||
"@react-native-async-storage/async-storage",
|
||||
],
|
||||
},
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.ts"),
|
||||
name: "formbricksReactNative",
|
||||
formats: ["es", "cjs"],
|
||||
fileName: "index",
|
||||
},
|
||||
},
|
||||
plugins: [dts({ rollupTypes: true, bundledPackages: ["@formbricks/types"] })],
|
||||
test: {
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
include: ["src/lib/**/*.ts"],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,28 +0,0 @@
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Mock react-native
|
||||
vi.mock("react-native", () => ({
|
||||
Platform: { OS: "ios" },
|
||||
}));
|
||||
|
||||
// Mock react-native-webview
|
||||
vi.mock("react-native-webview", () => ({
|
||||
WebView: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user