mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 18:30:32 -06:00
Merge branch 'main' into vaxi/mobile-sdk-sonar-fixes
# Conflicts: # packages/ios/Demo/Demo/AppDelegate.swift # packages/ios/FormbricksSDK/FormbricksSDK/Networking/Base/APIClient.swift
This commit is contained in:
@@ -22,7 +22,7 @@ export const OrganizationSettingsNavbar = ({
|
||||
loading,
|
||||
}: OrganizationSettingsNavbarProps) => {
|
||||
const pathname = usePathname();
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
const { isMember, isOwner } = getAccessFlags(membershipRole);
|
||||
const isPricingDisabled = isMember;
|
||||
const { t } = useTranslate();
|
||||
|
||||
@@ -59,6 +59,7 @@ export const OrganizationSettingsNavbar = ({
|
||||
label: t("common.api_keys"),
|
||||
href: `/environments/${environmentId}/settings/api-keys`,
|
||||
current: pathname?.includes("/api-keys"),
|
||||
hidden: !isOwner,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export const deleteApiKeyAction = authenticatedActionClient
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -47,7 +47,7 @@ export const createApiKeyAction = authenticatedActionClient
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -69,7 +69,7 @@ export const updateApiKeyAction = authenticatedActionClient
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmen
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { getProjectsByOrganizationId } from "@/modules/organization/settings/api-keys/lib/projects";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
@@ -19,7 +18,9 @@ export const APIKeysPage = async (props) => {
|
||||
|
||||
const projects = await getProjectsByOrganizationId(organization.id);
|
||||
|
||||
const isReadOnly = currentUserMembership.role !== "owner" && currentUserMembership.role !== "manager";
|
||||
const isNotOwner = currentUserMembership.role !== "owner";
|
||||
|
||||
if (isNotOwner) throw new Error(t("common.not_authorized"));
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -31,22 +32,16 @@ export const APIKeysPage = async (props) => {
|
||||
activeId="api-keys"
|
||||
/>
|
||||
</PageHeader>
|
||||
{isReadOnly ? (
|
||||
<Alert variant="warning">
|
||||
{t("environments.settings.api_keys.only_organization_owners_and_managers_can_manage_api_keys")}
|
||||
</Alert>
|
||||
) : (
|
||||
<SettingsCard
|
||||
title={t("common.api_keys")}
|
||||
description={t("environments.settings.api_keys.api_keys_description")}>
|
||||
<ApiKeyList
|
||||
organizationId={organization.id}
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
projects={projects}
|
||||
/>
|
||||
</SettingsCard>
|
||||
)}
|
||||
<SettingsCard
|
||||
title={t("common.api_keys")}
|
||||
description={t("environments.settings.api_keys.api_keys_description")}>
|
||||
<ApiKeyList
|
||||
organizationId={organization.id}
|
||||
locale={locale}
|
||||
isReadOnly={isNotOwner}
|
||||
projects={projects}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,18 +3,8 @@ import FormbricksSDK
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
let config = FormbricksConfig.Builder(appUrl: "[appUrl]", environmentId: "[environmentId]")
|
||||
.setLogLevel(.debug)
|
||||
.build()
|
||||
|
||||
Formbricks.setup(with: config)
|
||||
|
||||
|
||||
Formbricks.logout()
|
||||
Formbricks.setUserId(UUID().uuidString)
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import SwiftUI
|
||||
import FormbricksSDK
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Click me!") {
|
||||
Formbricks.track("click_demo_button")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
@@ -7,7 +7,7 @@ struct DemoApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
SetupView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
72
packages/ios/Demo/Demo/SetupView.swift
Normal file
72
packages/ios/Demo/Demo/SetupView.swift
Normal file
@@ -0,0 +1,72 @@
|
||||
import SwiftUI
|
||||
import FormbricksSDK
|
||||
|
||||
struct SetupView: View {
|
||||
@State private var isSetup = false
|
||||
@State private var isLoading = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
if !isSetup {
|
||||
if isLoading {
|
||||
ProgressView("Setting up...")
|
||||
.padding()
|
||||
} else {
|
||||
Button("Setup Formbricks SDK") {
|
||||
isLoading = true
|
||||
let config = FormbricksConfig.Builder(appUrl: "[appUrl]", environmentId: "[environmentId]")
|
||||
.setLogLevel(.debug)
|
||||
.build()
|
||||
// Simulate async setup delay
|
||||
DispatchQueue.global().async {
|
||||
Formbricks.setup(with: config)
|
||||
Formbricks.setUserId(UUID().uuidString)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
isSetup = true
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
} else {
|
||||
Button("Call Formbricks.track") {
|
||||
Formbricks.track("click_demo_button")
|
||||
}
|
||||
.padding()
|
||||
|
||||
Button("Call Formbricks.setAttribute") {
|
||||
Formbricks.setAttribute("test@example.com", forKey: "user_email")
|
||||
}
|
||||
.padding()
|
||||
|
||||
Button("Call Formbricks.setAttributes") {
|
||||
Formbricks.setAttributes(["user_name": "John Doe", "user_age": "30"])
|
||||
}
|
||||
.padding()
|
||||
|
||||
Button("Call Formbricks.setLanguage") {
|
||||
Formbricks.setLanguage("vi")
|
||||
}.padding()
|
||||
|
||||
Button("Call Formbricks.logout") {
|
||||
Formbricks.logout()
|
||||
}.padding()
|
||||
|
||||
Button("Call Formbricks.cleanup") {
|
||||
Formbricks.cleanup(waitForOperations: true) {
|
||||
print(">>> Cleanup complete")
|
||||
isSetup = false
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Setup SDK")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SetupView()
|
||||
}
|
||||
@@ -9,9 +9,12 @@ import Network
|
||||
static internal var language: String = "default"
|
||||
static internal var isInitialized: Bool = false
|
||||
|
||||
static internal var apiQueue = OperationQueue()
|
||||
static internal var logger = Logger()
|
||||
static internal var service = FormbricksService()
|
||||
static internal var userManager: UserManager?
|
||||
static internal var presentSurveyManager: PresentSurveyManager?
|
||||
static internal var surveyManager: SurveyManager?
|
||||
static internal var apiQueue: OperationQueue? = OperationQueue()
|
||||
static internal var logger: Logger?
|
||||
static internal var service = FormbricksService()
|
||||
|
||||
// make this class not instantiatable outside of the SDK
|
||||
internal override init() {
|
||||
@@ -36,30 +39,39 @@ import Network
|
||||
Formbricks.setup(with: config)
|
||||
```
|
||||
*/
|
||||
@objc public static func setup(with config: FormbricksConfig) {
|
||||
@objc public static func setup(with config: FormbricksConfig, force: Bool = false) {
|
||||
logger = Logger()
|
||||
|
||||
if (force == true) {
|
||||
isInitialized = false
|
||||
}
|
||||
guard !isInitialized else {
|
||||
Formbricks.logger.error(FormbricksSDKError(type: .sdkIsAlreadyInitialized).message)
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsAlreadyInitialized).message)
|
||||
return
|
||||
}
|
||||
|
||||
self.appUrl = config.appUrl
|
||||
self.environmentId = config.environmentId
|
||||
self.logger.logLevel = config.logLevel
|
||||
self.logger?.logLevel = config.logLevel
|
||||
|
||||
userManager = UserManager()
|
||||
if let userId = config.userId {
|
||||
UserManager.shared.set(userId: userId)
|
||||
userManager?.set(userId: userId)
|
||||
}
|
||||
if let attributes = config.attributes {
|
||||
UserManager.shared.set(attributes: attributes)
|
||||
userManager?.set(attributes: attributes)
|
||||
}
|
||||
if let language = config.attributes?["language"] {
|
||||
UserManager.shared.set(language: language)
|
||||
userManager?.set(language: language)
|
||||
self.language = language
|
||||
}
|
||||
|
||||
presentSurveyManager = PresentSurveyManager()
|
||||
surveyManager = SurveyManager.create(userManager: userManager!, presentSurveyManager: presentSurveyManager!)
|
||||
userManager?.surveyManager = surveyManager
|
||||
|
||||
SurveyManager.shared.refreshEnvironmentIfNeeded()
|
||||
UserManager.shared.syncUserStateIfNeeded()
|
||||
|
||||
surveyManager?.refreshEnvironmentIfNeeded(force: force)
|
||||
userManager?.syncUserStateIfNeeded()
|
||||
|
||||
self.isInitialized = true
|
||||
}
|
||||
@@ -75,11 +87,11 @@ import Network
|
||||
*/
|
||||
@objc public static func setUserId(_ userId: String) {
|
||||
guard Formbricks.isInitialized else {
|
||||
Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
return
|
||||
}
|
||||
|
||||
UserManager.shared.set(userId: userId)
|
||||
userManager?.set(userId: userId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,11 +105,11 @@ import Network
|
||||
*/
|
||||
@objc public static func setAttribute(_ attribute: String, forKey key: String) {
|
||||
guard Formbricks.isInitialized else {
|
||||
Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
return
|
||||
}
|
||||
|
||||
UserManager.shared.add(attribute: attribute, forKey: key)
|
||||
userManager?.add(attribute: attribute, forKey: key)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,11 +123,11 @@ import Network
|
||||
*/
|
||||
@objc public static func setAttributes(_ attributes: [String : String]) {
|
||||
guard Formbricks.isInitialized else {
|
||||
Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
return
|
||||
}
|
||||
|
||||
UserManager.shared.set(attributes: attributes)
|
||||
userManager?.set(attributes: attributes)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,12 +141,16 @@ import Network
|
||||
*/
|
||||
@objc public static func setLanguage(_ language: String) {
|
||||
guard Formbricks.isInitialized else {
|
||||
Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
return
|
||||
}
|
||||
|
||||
if (Formbricks.language == language) {
|
||||
return
|
||||
}
|
||||
|
||||
Formbricks.language = language
|
||||
UserManager.shared.set(language: language)
|
||||
userManager?.set(language: language)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,15 +164,15 @@ import Network
|
||||
*/
|
||||
@objc public static func track(_ action: String) {
|
||||
guard Formbricks.isInitialized else {
|
||||
Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
return
|
||||
}
|
||||
|
||||
Formbricks.isInternetAvailabile { available in
|
||||
if available {
|
||||
SurveyManager.shared.track(action)
|
||||
surveyManager?.track(action)
|
||||
} else {
|
||||
Formbricks.logger.warning(FormbricksSDKError.init(type: .networkError).message)
|
||||
Formbricks.logger?.warning(FormbricksSDKError.init(type: .networkError).message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,11 +189,58 @@ import Network
|
||||
*/
|
||||
@objc public static func logout() {
|
||||
guard Formbricks.isInitialized else {
|
||||
Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
return
|
||||
}
|
||||
|
||||
UserManager.shared.logout()
|
||||
userManager?.logout()
|
||||
}
|
||||
|
||||
/**
|
||||
Cleans up the SDK. This will clear the user attributes, the user id and the environment state.
|
||||
The SDK must be initialized before calling this method.
|
||||
If `waitForOperations` is set to `true`, it will wait for all operations to finish before cleaning up.
|
||||
If `waitForOperations` is set to `false`, it will clean up immediately.
|
||||
You can also provide a completion block that will be called when the cleanup is finished.
|
||||
|
||||
Example:
|
||||
```swift
|
||||
Formbricks.cleanup()
|
||||
|
||||
Formbricks.cleanup(waitForOperations: true) {
|
||||
// Cleanup completed
|
||||
}
|
||||
```
|
||||
*/
|
||||
|
||||
@objc public static func cleanup(waitForOperations: Bool = false, completion: (() -> Void)? = nil) {
|
||||
if waitForOperations, let queue = apiQueue {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
queue.waitUntilAllOperationsAreFinished()
|
||||
performCleanup()
|
||||
DispatchQueue.main.async {
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
apiQueue?.cancelAllOperations()
|
||||
performCleanup()
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
private static func performCleanup() {
|
||||
userManager?.cleanupUpdateQueue()
|
||||
presentSurveyManager?.dismissView()
|
||||
presentSurveyManager = nil
|
||||
userManager = nil
|
||||
surveyManager = nil
|
||||
apiQueue = nil
|
||||
isInitialized = false
|
||||
appUrl = nil
|
||||
environmentId = nil
|
||||
logger = nil
|
||||
language = "default"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@ private extension Logger {
|
||||
logString.append(messageListString)
|
||||
}
|
||||
if logLevel == .error || logLevel.rawValue >= self.logLevel.rawValue {
|
||||
DispatchQueue.main.async {
|
||||
let str = logString + "\(self.emoji)\n"
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
let str = logString + "\(self?.emoji ?? "")\n"
|
||||
print(str)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@ import SwiftUI
|
||||
|
||||
/// Presents a survey webview to the window's root
|
||||
final class PresentSurveyManager {
|
||||
static let shared = PresentSurveyManager()
|
||||
private init() {
|
||||
/*
|
||||
init() {
|
||||
/*
|
||||
This empty initializer prevents external instantiation of the PresentSurveyManager class.
|
||||
The class serves as a namespace for the present method, so instance creation is not needed and should be restricted.
|
||||
*/
|
||||
@@ -15,9 +14,10 @@ final class PresentSurveyManager {
|
||||
|
||||
/// Present the webview
|
||||
func present(environmentResponse: EnvironmentResponse, id: String) {
|
||||
DispatchQueue.main.async {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if let window = UIApplication.safeKeyWindow {
|
||||
let view = FormbricksView(viewModel: FormbricksViewModel(environmentResponse: environmentResponse, surveyId: id))
|
||||
let view = FormbricksView(viewModel: FormbricksViewModel(environmentResponse: environmentResponse, surveyId: id))
|
||||
let vc = UIHostingController(rootView: view)
|
||||
vc.modalPresentationStyle = .overCurrentContext
|
||||
vc.view.backgroundColor = UIColor.gray.withAlphaComponent(0.6)
|
||||
@@ -34,4 +34,8 @@ final class PresentSurveyManager {
|
||||
func dismissView() {
|
||||
viewController?.dismiss(animated: true)
|
||||
}
|
||||
|
||||
deinit {
|
||||
Formbricks.logger?.debug("Deinitializing \(self)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,16 @@ import SwiftUI
|
||||
/// The SurveyManager is responsible for managing the surveys that are displayed to the user.
|
||||
/// Filtering surveys based on the user's segments, responses, and displays.
|
||||
final class SurveyManager {
|
||||
static let shared = SurveyManager()
|
||||
private init() {
|
||||
/*
|
||||
This empty initializer prevents external instantiation of the SurveyManager class.
|
||||
The class serves as a namespace for the shared instance, so instance creation is not needed and should be restricted.
|
||||
*/
|
||||
private let userManager: UserManager
|
||||
private let presentSurveyManager: PresentSurveyManager
|
||||
|
||||
private init(userManager: UserManager, presentSurveyManager: PresentSurveyManager) {
|
||||
self.userManager = userManager
|
||||
self.presentSurveyManager = presentSurveyManager
|
||||
}
|
||||
|
||||
static func create(userManager: UserManager, presentSurveyManager: PresentSurveyManager) -> SurveyManager {
|
||||
return SurveyManager(userManager: userManager, presentSurveyManager: presentSurveyManager)
|
||||
}
|
||||
|
||||
private static let environmentResponseObjectKey = "environmentResponseObjectKey"
|
||||
@@ -26,15 +30,15 @@ final class SurveyManager {
|
||||
guard let environment = environmentResponse else { return }
|
||||
guard let surveys = environment.data.data.surveys else { return }
|
||||
|
||||
let displays = UserManager.shared.displays ?? []
|
||||
let responses = UserManager.shared.responses ?? []
|
||||
let segments = UserManager.shared.segments ?? []
|
||||
let displays = userManager.displays ?? []
|
||||
let responses = userManager.responses ?? []
|
||||
let segments = userManager.segments ?? []
|
||||
|
||||
filteredSurveys = filterSurveysBasedOnDisplayType(surveys, displays: displays, responses: responses)
|
||||
filteredSurveys = filterSurveysBasedOnRecontactDays(filteredSurveys, defaultRecontactDays: environment.data.data.project.recontactDays)
|
||||
|
||||
|
||||
// If we have a user, we do more filtering
|
||||
if UserManager.shared.userId != nil {
|
||||
if userManager.userId != nil {
|
||||
if segments.isEmpty {
|
||||
filteredSurveys = []
|
||||
return
|
||||
@@ -48,22 +52,23 @@ final class SurveyManager {
|
||||
/// Handles the display percentage and the delay of the survey.
|
||||
func track(_ action: String) {
|
||||
guard !isShowingSurvey else { return }
|
||||
|
||||
let actionClasses = environmentResponse?.data.data.actionClasses ?? []
|
||||
let codeActionClasses = actionClasses.filter { $0.type == "code" }
|
||||
let actionClass = codeActionClasses.first { $0.key == action }
|
||||
let firstSurveyWithActionClass = filteredSurveys.first { survey in
|
||||
return survey.triggers?.contains(where: { $0.actionClass?.name == actionClass?.name }) ?? false
|
||||
}
|
||||
|
||||
|
||||
// Display percentage
|
||||
let shouldDisplay = shouldDisplayBasedOnPercentage(firstSurveyWithActionClass?.displayPercentage)
|
||||
|
||||
|
||||
// Display and delay it if needed
|
||||
if let surveyId = firstSurveyWithActionClass?.id, shouldDisplay {
|
||||
isShowingSurvey = true
|
||||
let timeout = firstSurveyWithActionClass?.delay ?? 0
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) {
|
||||
self.showSurvey(withId: surveyId)
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { [weak self] in
|
||||
self?.showSurvey(withId: surveyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,7 +79,7 @@ extension SurveyManager {
|
||||
/// Checks if the environment state needs to be refreshed based on its `expiresAt` property, and if so, refreshes it, starts the refresh timer, and filters the surveys.
|
||||
func refreshEnvironmentIfNeeded(force: Bool = false) {
|
||||
if let environmentResponse = environmentResponse, environmentResponse.data.expiresAt.timeIntervalSinceNow > 0, !force {
|
||||
Formbricks.logger.debug("Environment state is still valid until \(environmentResponse.data.expiresAt)")
|
||||
Formbricks.logger?.debug("Environment state is still valid until \(environmentResponse.data.expiresAt)")
|
||||
filterSurveys()
|
||||
return
|
||||
}
|
||||
@@ -88,7 +93,7 @@ extension SurveyManager {
|
||||
self?.filterSurveys()
|
||||
case .failure:
|
||||
self?.hasApiError = true
|
||||
Formbricks.logger.error(FormbricksSDKError(type: .unableToRefreshEnvironment).message)
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .unableToRefreshEnvironment).message)
|
||||
self?.startErrorTimer()
|
||||
}
|
||||
}
|
||||
@@ -96,12 +101,12 @@ extension SurveyManager {
|
||||
|
||||
/// Posts a survey response to the Formbricks API.
|
||||
func postResponse(surveyId: String) {
|
||||
UserManager.shared.onResponse(surveyId: surveyId)
|
||||
userManager.onResponse(surveyId: surveyId)
|
||||
}
|
||||
|
||||
/// Creates a new display for the survey. It is called when the survey is displayed to the user.
|
||||
func onNewDisplay(surveyId: String) {
|
||||
UserManager.shared.onDisplay(surveyId: surveyId)
|
||||
userManager.onDisplay(surveyId: surveyId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +115,7 @@ extension SurveyManager {
|
||||
/// Dismisses the presented survey window.
|
||||
func dismissSurveyWebView() {
|
||||
isShowingSurvey = false
|
||||
PresentSurveyManager.shared.dismissView()
|
||||
presentSurveyManager.dismissView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +125,7 @@ private extension SurveyManager {
|
||||
/// The view controller is presented over the current context.
|
||||
func showSurvey(withId id: String) {
|
||||
if let environmentResponse = environmentResponse {
|
||||
PresentSurveyManager.shared.present(environmentResponse: environmentResponse, id: id)
|
||||
presentSurveyManager.present(environmentResponse: environmentResponse, id: id)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -142,9 +147,9 @@ private extension SurveyManager {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
|
||||
Formbricks.logger.debug("Refreshing environment state.")
|
||||
self.refreshEnvironmentIfNeeded(force: true)
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak self] in
|
||||
Formbricks.logger?.debug("Refreshing environment state.")
|
||||
self?.refreshEnvironmentIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +171,7 @@ extension SurveyManager {
|
||||
if let data = UserDefaults.standard.data(forKey: SurveyManager.environmentResponseObjectKey) {
|
||||
return try? JSONDecoder().decode(EnvironmentResponse.self, from: data)
|
||||
} else {
|
||||
Formbricks.logger.error(FormbricksSDKError(type: .unableToRetrieveEnvironment).message)
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .unableToRetrieveEnvironment).message)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -175,7 +180,7 @@ extension SurveyManager {
|
||||
UserDefaults.standard.set(data, forKey: SurveyManager.environmentResponseObjectKey)
|
||||
backingEnvironmentResponse = newValue
|
||||
} else {
|
||||
Formbricks.logger.error(FormbricksSDKError(type: .unableToPersistEnvironment).message)
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .unableToPersistEnvironment).message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,7 +212,7 @@ private extension SurveyManager {
|
||||
}
|
||||
|
||||
default:
|
||||
Formbricks.logger.error(FormbricksSDKError(type: .invalidDisplayOption).message)
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .invalidDisplayOption).message)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -218,7 +223,7 @@ private extension SurveyManager {
|
||||
/// Filters the surveys based on the recontact days and the `lastDisplayedAt` date.
|
||||
func filterSurveysBasedOnRecontactDays(_ surveys: [Survey], defaultRecontactDays: Int?) -> [Survey] {
|
||||
surveys.filter { survey in
|
||||
guard let lastDisplayedAt = UserManager.shared.lastDisplayedAt else { return true }
|
||||
guard let lastDisplayedAt = userManager.lastDisplayedAt else { return true }
|
||||
let recontactDays = survey.recontactDays ?? defaultRecontactDays
|
||||
|
||||
if let recontactDays = recontactDays {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
/// Store and manage user state and sync with the server when needed.
|
||||
final class UserManager {
|
||||
static let shared = UserManager()
|
||||
private init() {
|
||||
/*
|
||||
This empty initializer prevents external instantiation of the UserManager class.
|
||||
The class serves as a namespace for the user state, so instance creation is not needed and should be restricted.
|
||||
*/
|
||||
final class UserManager: UserManagerSyncable {
|
||||
weak var surveyManager: SurveyManager?
|
||||
|
||||
init(surveyManager: SurveyManager? = nil) {
|
||||
self.surveyManager = surveyManager
|
||||
}
|
||||
|
||||
private static let userIdKey = "userIdKey"
|
||||
@@ -28,26 +26,30 @@ final class UserManager {
|
||||
private var backingLastDisplayedAt: Date?
|
||||
private var backingExpiresAt: Date?
|
||||
|
||||
lazy private var updateQueue: UpdateQueue? = {
|
||||
return UpdateQueue(userManager: self)
|
||||
}()
|
||||
|
||||
internal var syncTimer: Timer?
|
||||
|
||||
/// Starts an update queue with the given user id.
|
||||
func set(userId: String) {
|
||||
UpdateQueue.current.set(userId: userId)
|
||||
updateQueue?.set(userId: userId)
|
||||
}
|
||||
|
||||
/// Starts an update queue with the given attribute.
|
||||
func add(attribute: String, forKey key: String) {
|
||||
UpdateQueue.current.add(attribute: attribute, forKey: key)
|
||||
updateQueue?.add(attribute: attribute, forKey: key)
|
||||
}
|
||||
|
||||
/// Starts an update queue with the given attributes.
|
||||
func set(attributes: [String: String]) {
|
||||
UpdateQueue.current.set(attributes: attributes)
|
||||
updateQueue?.set(attributes: attributes)
|
||||
}
|
||||
|
||||
/// Starts an update queue with the given language..
|
||||
func set(language: String) {
|
||||
UpdateQueue.current.set(language: language)
|
||||
updateQueue?.set(language: language)
|
||||
}
|
||||
|
||||
/// Saves `surveyId` to the `displays` property and the current date to the `lastDisplayedAt` property.
|
||||
@@ -57,7 +59,7 @@ final class UserManager {
|
||||
newDisplays.append(Display(surveyId: surveyId, createdAt: DateFormatter.isoFormatter.string(from: lastDisplayedAt)))
|
||||
displays = newDisplays
|
||||
self.lastDisplayedAt = lastDisplayedAt
|
||||
SurveyManager.shared.filterSurveys()
|
||||
surveyManager?.filterSurveys()
|
||||
}
|
||||
|
||||
/// Saves `surveyId` to the `responses` property.
|
||||
@@ -65,7 +67,7 @@ final class UserManager {
|
||||
var newResponses = responses ?? []
|
||||
newResponses.append(surveyId)
|
||||
responses = newResponses
|
||||
SurveyManager.shared.filterSurveys()
|
||||
surveyManager?.filterSurveys()
|
||||
}
|
||||
|
||||
/// Syncs the user state with the server if the user id is set and the expiration date has passed.
|
||||
@@ -80,7 +82,7 @@ final class UserManager {
|
||||
syncUser(withId: id)
|
||||
}
|
||||
|
||||
/// Syncs the user state with the server, calls the `SurveyManager.shared.filterSurveys()` method and starts the sync timer.
|
||||
/// Syncs the user state with the server, calls the `self?.surveyManager?.filterSurveys()` method and starts the sync timer.
|
||||
func syncUser(withId id: String, attributes: [String: String]? = nil) {
|
||||
service.postUser(id: id, attributes: attributes) { [weak self] result in
|
||||
switch result {
|
||||
@@ -92,11 +94,12 @@ final class UserManager {
|
||||
self?.responses = userResponse.data.state?.data?.responses
|
||||
self?.lastDisplayedAt = userResponse.data.state?.data?.lastDisplayAt
|
||||
self?.expiresAt = userResponse.data.state?.expiresAt
|
||||
UpdateQueue.current.reset()
|
||||
SurveyManager.shared.filterSurveys()
|
||||
|
||||
self?.updateQueue?.reset()
|
||||
self?.surveyManager?.filterSurveys()
|
||||
self?.startSyncTimer()
|
||||
case .failure(let error):
|
||||
Formbricks.logger.error(error)
|
||||
Formbricks.logger?.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,18 +107,31 @@ final class UserManager {
|
||||
/// Logs out the user and clears the user state.
|
||||
func logout() {
|
||||
UserDefaults.standard.removeObject(forKey: UserManager.userIdKey)
|
||||
UserDefaults.standard.removeObject(forKey: UserManager.contactIdKey)
|
||||
UserDefaults.standard.removeObject(forKey: UserManager.segmentsKey)
|
||||
UserDefaults.standard.removeObject(forKey: UserManager.displaysKey)
|
||||
UserDefaults.standard.removeObject(forKey: UserManager.responsesKey)
|
||||
UserDefaults.standard.removeObject(forKey: UserManager.lastDisplayedAtKey)
|
||||
UserDefaults.standard.removeObject(forKey: UserManager.expiresAtKey)
|
||||
backingUserId = nil
|
||||
backingContactId = nil
|
||||
backingSegments = nil
|
||||
backingDisplays = nil
|
||||
backingResponses = nil
|
||||
backingLastDisplayedAt = nil
|
||||
backingExpiresAt = nil
|
||||
UpdateQueue.current.reset()
|
||||
updateQueue?.reset()
|
||||
|
||||
Formbricks.logger?.debug("Successfully logged out user and reset the user state.")
|
||||
}
|
||||
|
||||
func cleanupUpdateQueue() {
|
||||
updateQueue?.cleanup()
|
||||
updateQueue = nil // Release the instance so memory can be reclaimed.
|
||||
}
|
||||
|
||||
deinit {
|
||||
Formbricks.logger?.debug("Deinitializing \(self)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,68 +1,122 @@
|
||||
import Foundation
|
||||
|
||||
protocol UserManagerSyncable: AnyObject {
|
||||
func syncUser(withId id: String, attributes: [String: String]?)
|
||||
}
|
||||
|
||||
/// Update queue. This class is used to queue updates to the user.
|
||||
/// The given properties will be sent to the backend and updated in the user object when the debounce interval is reached.
|
||||
final class UpdateQueue {
|
||||
|
||||
private static var debounceInterval: TimeInterval = 0.5
|
||||
static var current = UpdateQueue()
|
||||
|
||||
private let semaphore = DispatchSemaphore(value: 1)
|
||||
private let syncQueue = DispatchQueue(label: "com.formbricks.updateQueue")
|
||||
private var userId: String?
|
||||
private var attributes: [String : String]?
|
||||
private var language: String?
|
||||
private var timer: Timer?
|
||||
|
||||
private weak var userManager: UserManagerSyncable?
|
||||
|
||||
init(userManager: UserManagerSyncable) {
|
||||
self.userManager = userManager
|
||||
}
|
||||
|
||||
func set(userId: String) {
|
||||
semaphore.wait()
|
||||
self.userId = userId
|
||||
startDebounceTimer()
|
||||
syncQueue.sync {
|
||||
self.userId = userId
|
||||
startDebounceTimer()
|
||||
}
|
||||
}
|
||||
|
||||
func set(attributes: [String : String]) {
|
||||
semaphore.wait()
|
||||
self.attributes = attributes
|
||||
startDebounceTimer()
|
||||
syncQueue.sync {
|
||||
self.attributes = attributes
|
||||
startDebounceTimer()
|
||||
}
|
||||
}
|
||||
|
||||
func add(attribute: String, forKey key: String) {
|
||||
semaphore.wait()
|
||||
if var attr = self.attributes {
|
||||
attr[key] = attribute
|
||||
self.attributes = attr
|
||||
} else {
|
||||
self.attributes = [key: attribute]
|
||||
}
|
||||
startDebounceTimer()
|
||||
syncQueue.sync {
|
||||
if var attr = self.attributes {
|
||||
attr[key] = attribute
|
||||
self.attributes = attr
|
||||
} else {
|
||||
self.attributes = [key: attribute]
|
||||
}
|
||||
startDebounceTimer()
|
||||
}
|
||||
}
|
||||
|
||||
func set(language: String) {
|
||||
semaphore.wait()
|
||||
add(attribute: "language", forKey: language)
|
||||
startDebounceTimer()
|
||||
syncQueue.sync {
|
||||
self.language = language
|
||||
|
||||
// Check if we have an effective userId
|
||||
let effectiveUserId = self.userId ?? Formbricks.userManager?.userId
|
||||
|
||||
if effectiveUserId != nil {
|
||||
// If we have a userId, set attributes
|
||||
self.attributes = ["language": language]
|
||||
} else {
|
||||
// If no userId, just update locally without API call
|
||||
Formbricks.logger?.debug("UpdateQueue - updating language locally: \(language)")
|
||||
}
|
||||
|
||||
startDebounceTimer()
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
userId = nil
|
||||
attributes = nil
|
||||
language = nil
|
||||
syncQueue.sync {
|
||||
userId = nil
|
||||
attributes = nil
|
||||
language = nil
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
Formbricks.logger?.debug("Deinitializing \(self)")
|
||||
}
|
||||
}
|
||||
|
||||
private extension UpdateQueue {
|
||||
func startDebounceTimer() {
|
||||
timer?.invalidate()
|
||||
timer = Timer.scheduledTimer(timeInterval: UpdateQueue.debounceInterval, target: self, selector: #selector(commit), userInfo: nil, repeats: false)
|
||||
semaphore.signal()
|
||||
timer = nil
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.timer = Timer.scheduledTimer(timeInterval: UpdateQueue.debounceInterval,
|
||||
target: self,
|
||||
selector: #selector(self.commit),
|
||||
userInfo: nil,
|
||||
repeats: false)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func commit() {
|
||||
guard let userId = userId else {
|
||||
Formbricks.logger.error(FormbricksSDKError(type: .userIdIsNotSetYet).message)
|
||||
let effectiveUserId: String? = self.userId ?? Formbricks.userManager?.userId ?? nil
|
||||
|
||||
guard let userId = effectiveUserId else {
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .userIdIsNotSetYet).message)
|
||||
return
|
||||
}
|
||||
|
||||
Formbricks.logger.debug("UpdateQueue - commit() called on UpdateQueue with \(userId) and \(attributes ?? [:])")
|
||||
UserManager.shared.syncUser(withId: userId, attributes: attributes)
|
||||
Formbricks.logger?.debug("UpdateQueue - commit() called on UpdateQueue with \(userId) and \(attributes ?? [:])")
|
||||
userManager?.syncUser(withId: userId, attributes: attributes)
|
||||
}
|
||||
}
|
||||
|
||||
// Add a function to to stop the timer for cleanup
|
||||
extension UpdateQueue {
|
||||
func cleanup() {
|
||||
syncQueue.sync {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
userId = nil
|
||||
attributes = nil
|
||||
language = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,6 @@ private extension FormbricksService {
|
||||
/// Creates the APIClient operation and adds it to the queue
|
||||
func execute<Request: CodableRequest>(_ request: Request, withCompletion completion: @escaping (ResultType<Request.Response>) -> Void) {
|
||||
let operation = APIClient(request: request, completion: completion)
|
||||
Formbricks.apiQueue.addOperation(operation)
|
||||
Formbricks.apiQueue?.addOperation(operation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ private extension FormbricksViewModel {
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = "\(FormbricksEnvironment.surveyScriptUrl)";
|
||||
script.src = "\(Formbricks.appUrl ?? "http://localhost:3000")/js/surveys.umd.cjs";
|
||||
script.async = true;
|
||||
script.onload = () => loadSurvey();
|
||||
script.onerror = (error) => {
|
||||
@@ -89,7 +89,7 @@ private class WebViewData {
|
||||
data["languageCode"] = Formbricks.language
|
||||
data["appUrl"] = Formbricks.appUrl
|
||||
data["environmentId"] = Formbricks.environmentId
|
||||
data["contactId"] = UserManager.shared.contactId
|
||||
data["contactId"] = Formbricks.userManager?.contactId
|
||||
data["isWebEnvironment"] = false
|
||||
|
||||
let hasCustomStyling = environmentResponse.data.data.surveys?.first(where: { $0.id == surveyId })?.styling != nil
|
||||
@@ -103,7 +103,7 @@ private class WebViewData {
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: data, options: [])
|
||||
return String(data: jsonData, encoding: .utf8)?.replacingOccurrences(of: "\\\"", with: "'")
|
||||
} catch {
|
||||
Formbricks.logger.error(error.message)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,16 @@ struct SurveyWebView: UIViewRepresentable {
|
||||
return Coordinator()
|
||||
}
|
||||
|
||||
/// Called automatically by SwiftUI when the view is torn down.
|
||||
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
|
||||
let userContentController = uiView.configuration.userContentController
|
||||
userContentController.removeScriptMessageHandler(forName: "logging")
|
||||
userContentController.removeScriptMessageHandler(forName: "jsMessage")
|
||||
|
||||
uiView.navigationDelegate = nil
|
||||
uiView.uiDelegate = nil
|
||||
Formbricks.logger?.debug("SurveyWebView: Dismantled")
|
||||
}
|
||||
|
||||
/// Clean up cookies and website data.
|
||||
func clean() {
|
||||
@@ -98,22 +108,22 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
Formbricks.logger.debug(message.body)
|
||||
Formbricks.logger?.debug(message.body)
|
||||
|
||||
if let body = message.body as? String, let data = body.data(using: .utf8), let obj = try? JSONDecoder().decode(JsMessageData.self, from: data) {
|
||||
|
||||
switch obj.event {
|
||||
/// Happens when the user submits an answer.
|
||||
case .onResponseCreated:
|
||||
SurveyManager.shared.postResponse(surveyId: surveyId)
|
||||
Formbricks.surveyManager?.postResponse(surveyId: surveyId)
|
||||
|
||||
/// Happens when a survey is shown.
|
||||
case .onDisplayCreated:
|
||||
SurveyManager.shared.onNewDisplay(surveyId: surveyId)
|
||||
Formbricks.surveyManager?.onNewDisplay(surveyId: surveyId)
|
||||
|
||||
/// Happens when the user closes the survey view with the close button.
|
||||
case .onClose:
|
||||
SurveyManager.shared.dismissSurveyWebView()
|
||||
Formbricks.surveyManager?.dismissSurveyWebView()
|
||||
|
||||
/// Happens when the survey wants to open an external link in the default browser.
|
||||
case .onOpenExternalURL:
|
||||
@@ -123,11 +133,11 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
|
||||
/// Happens when the survey library fails to load.
|
||||
case .onSurveyLibraryLoadError:
|
||||
SurveyManager.shared.dismissSurveyWebView()
|
||||
Formbricks.surveyManager?.dismissSurveyWebView()
|
||||
}
|
||||
|
||||
} else {
|
||||
Formbricks.logger.error("\(FormbricksSDKError(type: .invalidJavascriptMessage).message): \(message.body)")
|
||||
Formbricks.logger?.error("\(FormbricksSDKError(type: .invalidJavascriptMessage).message): \(message.body)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,7 +146,7 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
/// Handle and send console.log messages from the Javascript to the local logger.
|
||||
final class LoggingMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
Formbricks.logger.debug(message.body)
|
||||
Formbricks.logger?.debug(message.body)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
//
|
||||
// FormbricksSDKTests.swift
|
||||
// FormbricksSDKTests
|
||||
//
|
||||
// Created by Peter Pesti-Varga on 2025. 02. 03..
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import FormbricksSDK
|
||||
|
||||
@@ -15,128 +8,153 @@ final class FormbricksSDKTests: XCTestCase {
|
||||
let userId = "6CCCE716-6783-4D0F-8344-9C7DFA43D8F7"
|
||||
let surveyID = "cm6ovw6j7000gsf0kduf4oo4i"
|
||||
let mockService = MockFormbricksService()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
UserManager.shared.logout()
|
||||
SurveyManager.shared.service = mockService
|
||||
UserManager.shared.service = mockService
|
||||
SurveyManager.shared.environmentResponse = nil
|
||||
}
|
||||
|
||||
func testFormbricks() throws {
|
||||
// Everything should be in the default state
|
||||
// Everything should be in the default state before initialization.
|
||||
XCTAssertFalse(Formbricks.isInitialized)
|
||||
XCTAssertEqual(SurveyManager.shared.filteredSurveys.count, 0)
|
||||
XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
|
||||
XCTAssertNil(SurveyManager.shared.environmentResponse)
|
||||
XCTAssertNil(UserManager.shared.syncTimer)
|
||||
XCTAssertNil(UserManager.shared.userId)
|
||||
XCTAssertNil(Formbricks.surveyManager)
|
||||
XCTAssertNil(Formbricks.userManager)
|
||||
XCTAssertEqual(Formbricks.language, "default")
|
||||
|
||||
// Use methods before init should have no effect
|
||||
// User manager default state: there is no user yet.
|
||||
XCTAssertNil(Formbricks.userManager?.displays)
|
||||
XCTAssertNil(Formbricks.userManager?.responses)
|
||||
XCTAssertNil(Formbricks.userManager?.segments)
|
||||
|
||||
// Use methods before init should have no effect.
|
||||
Formbricks.setUserId("userId")
|
||||
Formbricks.setLanguage("de")
|
||||
Formbricks.setAttributes(["testA" : "testB"])
|
||||
Formbricks.setAttribute("test", forKey: "testKey")
|
||||
XCTAssertNil(UserManager.shared.userId)
|
||||
XCTAssertNil(Formbricks.userManager?.userId)
|
||||
XCTAssertEqual(Formbricks.language, "default")
|
||||
|
||||
// Setup the SDK using your new instance-based design.
|
||||
// This creates new instances for both the UserManager and SurveyManager.
|
||||
Formbricks.setup(with: FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId)
|
||||
.set(attributes: ["a": "b"])
|
||||
.add(attribute: "test", forKey: "key")
|
||||
.setLogLevel(.debug)
|
||||
.build())
|
||||
|
||||
// Set up the service dependency on both managers.
|
||||
Formbricks.userManager?.service = mockService
|
||||
Formbricks.surveyManager?.service = mockService
|
||||
|
||||
// Call the setup and initialize the SDK
|
||||
Formbricks.setup(with:
|
||||
FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId)
|
||||
.set(attributes: ["a":"b"])
|
||||
.add(attribute: "test", forKey: "key")
|
||||
.setLogLevel(.debug)
|
||||
.build()
|
||||
)
|
||||
// Should be ignored, becuase we don't have user ID yet
|
||||
Formbricks.setAttributes(["testA" : "testB"])
|
||||
Formbricks.setAttribute("test", forKey: "testKey")
|
||||
XCTAssertNil(UserManager.shared.userId)
|
||||
|
||||
// Verify the base variables are set properly
|
||||
XCTAssertTrue(Formbricks.isInitialized)
|
||||
XCTAssertEqual(Formbricks.appUrl, appUrl)
|
||||
XCTAssertEqual(Formbricks.environmentId, environmentId)
|
||||
|
||||
// User manager default state. There is no user yet.
|
||||
XCTAssertNil(FormbricksSDK.UserManager.shared.displays)
|
||||
XCTAssertNil(FormbricksSDK.UserManager.shared.responses)
|
||||
XCTAssertNil(FormbricksSDK.UserManager.shared.segments)
|
||||
|
||||
// Check error state handling
|
||||
mockService.isErrorResponseNeeded = true
|
||||
XCTAssertFalse(SurveyManager.shared.hasApiError)
|
||||
SurveyManager.shared.refreshEnvironmentIfNeeded(force: true)
|
||||
XCTAssertTrue(SurveyManager.shared.hasApiError)
|
||||
mockService.isErrorResponseNeeded = false
|
||||
|
||||
// Authenticate the user
|
||||
Formbricks.setUserId(userId)
|
||||
_ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 2.0)
|
||||
XCTAssertEqual(UserManager.shared.userId, userId)
|
||||
// User refresh timer should be set
|
||||
XCTAssertNotNil(UserManager.shared.syncTimer)
|
||||
|
||||
// The environment should be fetched already
|
||||
XCTAssertNotNil(SurveyManager.shared.environmentResponse)
|
||||
|
||||
// Check if the filter method works properly
|
||||
XCTAssertEqual(SurveyManager.shared.filteredSurveys.count, 1)
|
||||
|
||||
// Make sure we don't show any survey
|
||||
XCTAssertNotNil(SurveyManager.shared.filteredSurveys)
|
||||
XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
|
||||
|
||||
// Track an unknown event, shouldn't show the survey
|
||||
Formbricks.track("unknown_event")
|
||||
XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
|
||||
|
||||
// Track a known event, thus, the survey should be shown.
|
||||
Formbricks.track("click_demo_button")
|
||||
_ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
|
||||
XCTAssertTrue(SurveyManager.shared.isShowingSurvey)
|
||||
|
||||
// "Dismiss" the webview
|
||||
SurveyManager.shared.dismissSurveyWebView()
|
||||
XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
|
||||
|
||||
// Validate display and response
|
||||
SurveyManager.shared.postResponse(surveyId: surveyID)
|
||||
SurveyManager.shared.onNewDisplay(surveyId: surveyID)
|
||||
XCTAssertEqual(UserManager.shared.responses?.count, 1)
|
||||
XCTAssertEqual(UserManager.shared.displays?.count, 1)
|
||||
|
||||
// Track a valid event, but the survey should not shown, because we already gave a response.
|
||||
Formbricks.track("click_demo_button")
|
||||
_ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
|
||||
XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
|
||||
|
||||
// Validate logout
|
||||
XCTAssertNotNil(UserManager.shared.userId)
|
||||
XCTAssertNotNil(UserManager.shared.lastDisplayedAt)
|
||||
XCTAssertNotNil(UserManager.shared.responses)
|
||||
XCTAssertNotNil(UserManager.shared.displays)
|
||||
XCTAssertNotNil(UserManager.shared.segments)
|
||||
XCTAssertNotNil(UserManager.shared.expiresAt)
|
||||
Formbricks.logout()
|
||||
XCTAssertNil(UserManager.shared.userId)
|
||||
XCTAssertNil(UserManager.shared.lastDisplayedAt)
|
||||
XCTAssertNil(UserManager.shared.responses)
|
||||
XCTAssertNil(UserManager.shared.displays)
|
||||
XCTAssertNil(UserManager.shared.segments)
|
||||
XCTAssertNil(UserManager.shared.expiresAt)
|
||||
|
||||
// Clear the responses
|
||||
Formbricks.logout()
|
||||
SurveyManager.shared.filterSurveys()
|
||||
|
||||
Formbricks.track("click_demo_button")
|
||||
_ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
|
||||
XCTAssertTrue(SurveyManager.shared.isShowingSurvey)
|
||||
|
||||
SurveyManager.shared.dismissSurveyWebView()
|
||||
XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
|
||||
XCTAssertTrue(Formbricks.isInitialized)
|
||||
XCTAssertEqual(Formbricks.appUrl, appUrl)
|
||||
XCTAssertEqual(Formbricks.environmentId, environmentId)
|
||||
|
||||
// Check error state handling.
|
||||
mockService.isErrorResponseNeeded = true
|
||||
XCTAssertFalse(Formbricks.surveyManager?.hasApiError ?? false)
|
||||
Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true)
|
||||
XCTAssertTrue(Formbricks.surveyManager?.hasApiError ?? false)
|
||||
|
||||
mockService.isErrorResponseNeeded = false
|
||||
Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true)
|
||||
|
||||
// Authenticate the user.
|
||||
Formbricks.setUserId(userId)
|
||||
_ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 2.0)
|
||||
XCTAssertEqual(Formbricks.userManager?.userId, userId)
|
||||
// User refresh timer should be set.
|
||||
XCTAssertNotNil(Formbricks.userManager?.syncTimer)
|
||||
|
||||
// The environment should be fetched.
|
||||
XCTAssertNotNil(Formbricks.surveyManager?.environmentResponse)
|
||||
|
||||
// Check if the filter method works properly.
|
||||
XCTAssertEqual(Formbricks.surveyManager?.filteredSurveys.count, 1)
|
||||
|
||||
// Verify that we’re not showing any survey initially.
|
||||
XCTAssertNotNil(Formbricks.surveyManager?.filteredSurveys)
|
||||
XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false)
|
||||
|
||||
// Track an unknown event—survey should not be shown.
|
||||
Formbricks.track("unknown_event")
|
||||
XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false)
|
||||
|
||||
// Track a known event—the survey should be shown.
|
||||
Formbricks.track("click_demo_button")
|
||||
_ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
|
||||
XCTAssertTrue(Formbricks.surveyManager?.isShowingSurvey ?? false)
|
||||
|
||||
// "Dismiss" the webview.
|
||||
Formbricks.surveyManager?.dismissSurveyWebView()
|
||||
XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false)
|
||||
|
||||
// Validate display and response.
|
||||
Formbricks.surveyManager?.postResponse(surveyId: surveyID)
|
||||
Formbricks.surveyManager?.onNewDisplay(surveyId: surveyID)
|
||||
XCTAssertEqual(Formbricks.userManager?.responses?.count, 1)
|
||||
XCTAssertEqual(Formbricks.userManager?.displays?.count, 1)
|
||||
|
||||
// Track a valid event, but survey should not be shown because a response was already submitted.
|
||||
Formbricks.track("click_demo_button")
|
||||
_ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
|
||||
XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false)
|
||||
|
||||
// Validate logout.
|
||||
XCTAssertNotNil(Formbricks.userManager?.userId)
|
||||
XCTAssertNotNil(Formbricks.userManager?.lastDisplayedAt)
|
||||
XCTAssertNotNil(Formbricks.userManager?.responses)
|
||||
XCTAssertNotNil(Formbricks.userManager?.displays)
|
||||
XCTAssertNotNil(Formbricks.userManager?.segments)
|
||||
XCTAssertNotNil(Formbricks.userManager?.expiresAt)
|
||||
Formbricks.logout()
|
||||
XCTAssertNil(Formbricks.userManager?.userId)
|
||||
XCTAssertNil(Formbricks.userManager?.lastDisplayedAt)
|
||||
XCTAssertNil(Formbricks.userManager?.responses)
|
||||
XCTAssertNil(Formbricks.userManager?.displays)
|
||||
XCTAssertNil(Formbricks.userManager?.segments)
|
||||
XCTAssertNil(Formbricks.userManager?.expiresAt)
|
||||
|
||||
// Clear the responses and verify survey behavior.
|
||||
Formbricks.logout()
|
||||
Formbricks.surveyManager?.filterSurveys()
|
||||
|
||||
Formbricks.track("click_demo_button")
|
||||
_ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
|
||||
XCTAssertTrue(Formbricks.surveyManager?.isShowingSurvey ?? false)
|
||||
|
||||
// Test the cleanup
|
||||
Formbricks.cleanup()
|
||||
XCTAssertNil(Formbricks.userManager)
|
||||
XCTAssertNil(Formbricks.surveyManager)
|
||||
XCTAssertNil(Formbricks.apiQueue)
|
||||
XCTAssertNil(Formbricks.presentSurveyManager)
|
||||
XCTAssertFalse(Formbricks.isInitialized)
|
||||
XCTAssertNil(Formbricks.appUrl)
|
||||
XCTAssertNil(Formbricks.environmentId)
|
||||
XCTAssertNil(Formbricks.logger)
|
||||
}
|
||||
|
||||
func testCleanupWithCompletion() {
|
||||
// Setup the SDK
|
||||
let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId)
|
||||
.setLogLevel(.debug)
|
||||
.build()
|
||||
Formbricks.setup(with: config)
|
||||
XCTAssertTrue(Formbricks.isInitialized)
|
||||
|
||||
// Set expectation for cleanup completion
|
||||
let cleanupExpectation = expectation(description: "Cleanup completed")
|
||||
|
||||
Formbricks.cleanup(waitForOperations: true) {
|
||||
cleanupExpectation.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [cleanupExpectation], timeout: 2.0)
|
||||
|
||||
// Validate cleanup: all main properties should be nil or false
|
||||
XCTAssertNil(Formbricks.userManager)
|
||||
XCTAssertNil(Formbricks.surveyManager)
|
||||
XCTAssertNil(Formbricks.presentSurveyManager)
|
||||
XCTAssertNil(Formbricks.apiQueue)
|
||||
XCTAssertFalse(Formbricks.isInitialized)
|
||||
XCTAssertNil(Formbricks.appUrl)
|
||||
XCTAssertNil(Formbricks.environmentId)
|
||||
XCTAssertNil(Formbricks.logger)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ To run the project, open `Formbricks.xcworkspace` in **Xcode**. The workspace co
|
||||
- **FormbricksSDK**: The SDK package.
|
||||
- **Demo**: A demo application to exercise the SDK.
|
||||
|
||||
Before launching the `Demo` app, update the mandatory variables in `AppDelegate`:
|
||||
Before launching the `Demo` app, update the mandatory variables in `SetupView`:
|
||||
|
||||
```swift
|
||||
let config = FormbricksConfig.Builder(appUrl: "[APP_URL]", environmentId: "[ENVIRONMENT_ID]")
|
||||
@@ -13,15 +13,9 @@ let config = FormbricksConfig.Builder(appUrl: "[APP_URL]", environmentId: "[ENVI
|
||||
.build()
|
||||
```
|
||||
|
||||
Once these values are properly set, the demo app can be launched.
|
||||
The demo app consists of a single view, `ContentView`. It is a SwiftUI view with a single button.
|
||||
The button's action should be updated according to the survey actions:
|
||||
Once these values are properly set, launch the demo app. The demo app uses a single view, `SetupView`, as the initial screen. In this view, tap the **Setup Formbricks SDK** button to initialize the SDK.
|
||||
|
||||
```swift
|
||||
Formbricks.track("click_demo_button")
|
||||
```
|
||||
|
||||
Replace `"click_demo_button"` with the desired action.
|
||||
Once setup is complete, you can use the **Call Formbricks.track** button to trigger your survey and the **Call Formbricks.cleanup** button to reset the SDK and return to the setup screen.
|
||||
|
||||
---
|
||||
|
||||
@@ -33,9 +27,9 @@ You can generate developer documentation for the SDK by pressing **Shift + Comma
|
||||
|
||||
## Unit Tests
|
||||
|
||||
The SDK includes a unit test to verify the Manager's functionality. To run it:
|
||||
The SDK includes unit tests to verify the functionality of various components. To run them:
|
||||
|
||||
1. Select the `Test Navigator` tab in Xcode.
|
||||
2. Run the `testFormbricks()` method.
|
||||
2. Run the desired test methods.
|
||||
|
||||
The coverage report can be found in the `Report Navigator` tab.
|
||||
|
||||
@@ -478,7 +478,6 @@
|
||||
"password_changed_email_heading": "Passwort geändert",
|
||||
"password_changed_email_text": "Dein Passwort wurde erfolgreich geändert.",
|
||||
"password_reset_notify_email_subject": "Dein Formbricks-Passwort wurde geändert",
|
||||
"powered_by_formbricks": "Unterstützt von Formbricks",
|
||||
"privacy_policy": "Datenschutzerklärung",
|
||||
"reject": "Ablehnen",
|
||||
"render_email_response_value_file_upload_response_link_not_included": "Link zur hochgeladenen Datei ist aus Datenschutzgründen nicht enthalten",
|
||||
@@ -995,8 +994,7 @@
|
||||
"api_keys": {
|
||||
"add_api_key": "API-Schlüssel hinzufügen",
|
||||
"add_permission": "Berechtigung hinzufügen",
|
||||
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen",
|
||||
"only_organization_owners_and_managers_can_manage_api_keys": "Nur Organisationsinhaber und -manager können API-Schlüssel verwalten"
|
||||
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10,000 monatliche Antworten",
|
||||
|
||||
@@ -478,7 +478,6 @@
|
||||
"password_changed_email_heading": "Password changed",
|
||||
"password_changed_email_text": "Your password has been changed successfully.",
|
||||
"password_reset_notify_email_subject": "Your Formbricks password has been changed",
|
||||
"powered_by_formbricks": "Powered by Formbricks",
|
||||
"privacy_policy": "Privacy Policy",
|
||||
"reject": "Reject",
|
||||
"render_email_response_value_file_upload_response_link_not_included": "Link to uploaded file is not included for data privacy reasons",
|
||||
@@ -995,8 +994,7 @@
|
||||
"api_keys": {
|
||||
"add_api_key": "Add API key",
|
||||
"add_permission": "Add permission",
|
||||
"api_keys_description": "Manage API keys to access Formbricks management APIs",
|
||||
"only_organization_owners_and_managers_can_manage_api_keys": "Only organization owners and managers can manage API keys"
|
||||
"api_keys_description": "Manage API keys to access Formbricks management APIs"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10000 Monthly Responses",
|
||||
|
||||
@@ -478,7 +478,6 @@
|
||||
"password_changed_email_heading": "Mot de passe changé",
|
||||
"password_changed_email_text": "Votre mot de passe a été changé avec succès.",
|
||||
"password_reset_notify_email_subject": "Ton mot de passe Formbricks a été changé",
|
||||
"powered_by_formbricks": "Propulsé par Formbricks",
|
||||
"privacy_policy": "Politique de confidentialité",
|
||||
"reject": "Rejeter",
|
||||
"render_email_response_value_file_upload_response_link_not_included": "Le lien vers le fichier téléchargé n'est pas inclus pour des raisons de confidentialité des données",
|
||||
@@ -995,8 +994,7 @@
|
||||
"api_keys": {
|
||||
"add_api_key": "Ajouter une clé API",
|
||||
"add_permission": "Ajouter une permission",
|
||||
"api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks",
|
||||
"only_organization_owners_and_managers_can_manage_api_keys": "Seuls les propriétaires et les gestionnaires de l'organisation peuvent gérer les clés API"
|
||||
"api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10000 Réponses Mensuelles",
|
||||
|
||||
@@ -478,7 +478,6 @@
|
||||
"password_changed_email_heading": "Senha alterada",
|
||||
"password_changed_email_text": "Sua senha foi alterada com sucesso.",
|
||||
"password_reset_notify_email_subject": "Sua senha Formbricks foi alterada",
|
||||
"powered_by_formbricks": "Desenvolvido por Formbricks",
|
||||
"privacy_policy": "Política de Privacidade",
|
||||
"reject": "Rejeitar",
|
||||
"render_email_response_value_file_upload_response_link_not_included": "O link para o arquivo enviado não está incluído por motivos de privacidade de dados",
|
||||
@@ -995,8 +994,7 @@
|
||||
"api_keys": {
|
||||
"add_api_key": "Adicionar chave de API",
|
||||
"add_permission": "Adicionar permissão",
|
||||
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks",
|
||||
"only_organization_owners_and_managers_can_manage_api_keys": "Apenas proprietários e gerentes da organização podem gerenciar chaves de API"
|
||||
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10000 Respostas Mensais",
|
||||
|
||||
@@ -478,7 +478,6 @@
|
||||
"password_changed_email_heading": "Palavra-passe alterada",
|
||||
"password_changed_email_text": "A sua palavra-passe foi alterada com sucesso.",
|
||||
"password_reset_notify_email_subject": "A sua palavra-passe do Formbricks foi alterada",
|
||||
"powered_by_formbricks": "Desenvolvido por Formbricks",
|
||||
"privacy_policy": "Política de Privacidade",
|
||||
"reject": "Rejeitar",
|
||||
"render_email_response_value_file_upload_response_link_not_included": "O link para o ficheiro carregado não está incluído por razões de privacidade de dados",
|
||||
@@ -995,8 +994,7 @@
|
||||
"api_keys": {
|
||||
"add_api_key": "Adicionar chave API",
|
||||
"add_permission": "Adicionar permissão",
|
||||
"api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks",
|
||||
"only_organization_owners_and_managers_can_manage_api_keys": "Apenas os proprietários e gestores da organização podem gerir chaves API"
|
||||
"api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10000 Respostas Mensais",
|
||||
|
||||
@@ -478,7 +478,6 @@
|
||||
"password_changed_email_heading": "密碼已變更",
|
||||
"password_changed_email_text": "您的密碼已成功變更。",
|
||||
"password_reset_notify_email_subject": "您的 Formbricks 密碼已變更",
|
||||
"powered_by_formbricks": "由 Formbricks 提供技術支援",
|
||||
"privacy_policy": "隱私權政策",
|
||||
"reject": "拒絕",
|
||||
"render_email_response_value_file_upload_response_link_not_included": "由於資料隱私原因,未包含上傳檔案的連結",
|
||||
@@ -995,8 +994,7 @@
|
||||
"api_keys": {
|
||||
"add_api_key": "新增 API 金鑰",
|
||||
"add_permission": "新增權限",
|
||||
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API",
|
||||
"only_organization_owners_and_managers_can_manage_api_keys": "只有組織擁有者和管理員才能管理 API 金鑰"
|
||||
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10000 個每月回應",
|
||||
|
||||
Reference in New Issue
Block a user