Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/FormbricksSDK/Extension/Calendar+DaysBetween.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ extension Calendar {
let numberOfDays = dateComponents([.day], from: fromDate, to: toDate)

guard let day = numberOfDays.day else { return 0 }
return abs(day + 1)
return max(0, day)
}
}
8 changes: 6 additions & 2 deletions Sources/FormbricksSDK/Manager/PresentSurveyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ final class PresentSurveyManager {
private weak var viewController: UIViewController?

/// Present the webview
func present(environmentResponse: EnvironmentResponse, id: String) {
func present(environmentResponse: EnvironmentResponse, id: String, completion: ((Bool) -> Void)? = nil) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if let window = UIApplication.safeKeyWindow {
Expand All @@ -25,7 +25,11 @@ final class PresentSurveyManager {
presentationController.detents = [.large()]
}
self.viewController = vc
window.rootViewController?.present(vc, animated: true, completion: nil)
window.rootViewController?.present(vc, animated: true, completion: {
completion?(true)
})
} else {
completion?(false)
}
}
}
Expand Down
42 changes: 33 additions & 9 deletions Sources/FormbricksSDK/Manager/SurveyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,21 @@ final class SurveyManager {

let actionClasses = environmentResponse?.data.data.actionClasses ?? []
let codeActionClasses = actionClasses.filter { $0.type == "code" }
let actionClass = codeActionClasses.first { $0.key == action }
guard let actionClass = codeActionClasses.first(where: { $0.key == action }) else {
Formbricks.logger?.error("\(action) action unknown. Please add this action in Formbricks first in order to use it in your code.")
return
}

let firstSurveyWithActionClass = filteredSurveys.first { survey in
return survey.triggers?.contains(where: { $0.actionClass?.name == actionClass?.name }) ?? false
return survey.triggers?.contains(where: { $0.actionClass?.name == actionClass.name }) ?? false
}

// Display percentage
let shouldDisplay = shouldDisplayBasedOnPercentage(firstSurveyWithActionClass?.displayPercentage)
if let survey = firstSurveyWithActionClass, !shouldDisplay {
Formbricks.logger?.info("Skipping survey \(survey.name) due to display percentage restriction.")
return
}
let isMultiLangSurvey = firstSurveyWithActionClass?.languages?.count ?? 0 > 1

if isMultiLangSurvey {
Expand All @@ -103,12 +111,25 @@ final class SurveyManager {
}

// Display and delay it if needed
if let surveyId = firstSurveyWithActionClass?.id, shouldDisplay {
if let survey = firstSurveyWithActionClass, shouldDisplay {
isShowingSurvey = true
let timeout = firstSurveyWithActionClass?.delay ?? 0
let timeout = survey.delay ?? 0
if timeout > 0 {
Formbricks.logger?.info("Delaying survey \(survey.name) by \(timeout) seconds")
}
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { [weak self] in
self?.showSurvey(withId: surveyId)
completion?()
guard let self = self else { return }
if let environmentResponse = self.environmentResponse {
self.presentSurveyManager.present(environmentResponse: environmentResponse, id: survey.id) { success in
if !success {
self.isShowingSurvey = false
}
completion?()
}
} else {
self.isShowingSurvey = false
completion?()
}
}
}
}
Expand Down Expand Up @@ -199,8 +220,9 @@ private extension SurveyManager {
/// Decides if the survey should be displayed based on the display percentage.
internal func shouldDisplayBasedOnPercentage(_ displayPercentage: Double?) -> Bool {
guard let displayPercentage = displayPercentage else { return true }
let randomNum = Double(Int.random(in: 0..<10000)) / 100.0
return randomNum <= displayPercentage
let clampedPercentage = min(max(displayPercentage, 0), 100)
let draw = Double.random(in: 0..<100)
return draw < clampedPercentage
}
}

Expand Down Expand Up @@ -273,7 +295,9 @@ extension SurveyManager {
let recontactDays = survey.recontactDays ?? defaultRecontactDays

if let recontactDays = recontactDays {
return Calendar.current.numberOfDaysBetween(Date(), and: lastDisplayedAt) >= recontactDays
let secondsElapsed = Date().timeIntervalSince(lastDisplayedAt)
let daysBetween = Int(secondsElapsed / 86_400)
return daysBetween >= recontactDays
}

return true
Expand Down
19 changes: 19 additions & 0 deletions Sources/FormbricksSDK/Model/Environment/Survey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ struct LanguageDetail: Codable {
let projectId: String
}

// MARK: - New types for projectOverwrites

enum Placement: String, Codable {
case topLeft = "topLeft"
case topRight = "topRight"
case bottomLeft = "bottomLeft"
case bottomRight = "bottomRight"
case center = "center"
}

struct ProjectOverwrites: Codable {
let brandColor: String?
let highlightBorderColor: String?
let placement: Placement?
let clickOutsideClose: Bool?
let darkOverlay: Bool?
}

struct Survey: Codable {
let id: String
let name: String
Expand All @@ -37,4 +55,5 @@ struct Survey: Codable {
let segment: Segment?
let styling: Styling?
let languages: [SurveyLanguage]?
let projectOverwrites: ProjectOverwrites?
}
20 changes: 13 additions & 7 deletions Sources/FormbricksSDK/Networking/Queue/UpdateQueue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,10 @@ final class UpdateQueue {

private extension UpdateQueue {
func startDebounceTimer() {
timer?.invalidate()
timer = nil

DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.timer?.invalidate()
self.timer = nil
self.timer = Timer.scheduledTimer(timeInterval: UpdateQueue.debounceInterval,
target: self,
selector: #selector(self.commit),
Expand All @@ -97,16 +96,23 @@ private extension UpdateQueue {
}

@objc func commit() {
let effectiveUserId: String? = self.userId ?? Formbricks.userManager?.userId ?? nil

var effectiveUserId: String?
var effectiveAttributes: [String: String]?

// Capture a consistent snapshot under the sync queue
syncQueue.sync {
effectiveUserId = self.userId ?? Formbricks.userManager?.userId
effectiveAttributes = self.attributes
}

guard let userId = effectiveUserId else {
let error = FormbricksSDKError(type: .userIdIsNotSetYet)
Formbricks.logger?.error(error.message)
return
}

Formbricks.logger?.debug("UpdateQueue - commit() called on UpdateQueue with \(userId) and \(attributes ?? [:])")
userManager?.syncUser(withId: userId, attributes: attributes)
Formbricks.logger?.debug("UpdateQueue - commit() called on UpdateQueue with \(userId) and \(effectiveAttributes ?? [:])")
userManager?.syncUser(withId: userId, attributes: effectiveAttributes)
}
}

Expand Down
20 changes: 15 additions & 5 deletions Sources/FormbricksSDK/WebView/FormbricksViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ private extension FormbricksViewModel {
onClose,
onOpenExternalURL,
};

window.formbricksSurveys.renderSurvey(surveyProps);
}

Expand All @@ -92,23 +91,34 @@ private class WebViewData {
var data: [String: Any] = [:]

init(environmentResponse: EnvironmentResponse, surveyId: String) {
let matchedSurvey = environmentResponse.data.data.surveys?.first(where: {$0.id == surveyId})
let project = environmentResponse.data.data.project

data["survey"] = environmentResponse.getSurveyJson(forSurveyId: surveyId)
data["appUrl"] = Formbricks.appUrl
data["environmentId"] = Formbricks.environmentId
data["contactId"] = Formbricks.userManager?.contactId
data["isWebEnvironment"] = false
data["isBrandingEnabled"] = environmentResponse.data.data.project.inAppSurveyBranding ?? true
data["isBrandingEnabled"] = project.inAppSurveyBranding ?? true

if let placementEnum = matchedSurvey?.projectOverwrites?.placement {
data["placement"] = placementEnum.rawValue
} else {
data["placement"] = project.placement
}

data["darkOverlay"] = matchedSurvey?.projectOverwrites?.darkOverlay ?? project.darkOverlay

let isMultiLangSurvey = environmentResponse.data.data.surveys?.first(where: { $0.id == surveyId })?.languages?.count ?? 0 > 1
let isMultiLangSurvey = (matchedSurvey?.languages?.count ?? 0) > 1

if isMultiLangSurvey {
data["languageCode"] = Formbricks.language
} else {
data["languageCode"] = "default"
}

let hasCustomStyling = environmentResponse.data.data.surveys?.first(where: { $0.id == surveyId })?.styling != nil
let enabled = environmentResponse.data.data.project.styling?.allowStyleOverwrite ?? false
let hasCustomStyling = matchedSurvey?.styling != nil
let enabled = project.styling?.allowStyleOverwrite ?? false

data["styling"] = hasCustomStyling && enabled ? environmentResponse.getSurveyStylingJson(forSurveyId: surveyId): environmentResponse.getProjectStylingJson()
}
Expand Down
55 changes: 55 additions & 0 deletions Tests/FormbricksSDKTests/CalendarDaysBetweenTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import XCTest
@testable import FormbricksSDK

final class CalendarDaysBetweenTests: XCTestCase {
private func makeDate(year: Int, month: Int, day: Int, hour: Int = 12, minute: Int = 0, second: Int = 0) -> Date {
let calendar = Calendar.current
var comps = DateComponents()
comps.calendar = calendar
comps.timeZone = calendar.timeZone
comps.year = year
comps.month = month
comps.day = day
comps.hour = hour
comps.minute = minute
comps.second = second
return calendar.date(from: comps) ?? Date()
}

func testSameDayReturnsZero() {
let from = makeDate(year: 2025, month: 1, day: 15, hour: 10, minute: 30)
let to = makeDate(year: 2025, month: 1, day: 15, hour: 22, minute: 15)
let days = Calendar.current.numberOfDaysBetween(from, and: to)
XCTAssertEqual(days, 0)
}

func testNextDayReturnsOne() {
let from = makeDate(year: 2025, month: 1, day: 15, hour: 10)
let to = makeDate(year: 2025, month: 1, day: 16, hour: 9)
let days = Calendar.current.numberOfDaysBetween(from, and: to)
XCTAssertEqual(days, 1)
}

func testMultipleDays() {
let from = makeDate(year: 2025, month: 1, day: 10, hour: 10)
let to = makeDate(year: 2025, month: 1, day: 13, hour: 9)
let days = Calendar.current.numberOfDaysBetween(from, and: to)
XCTAssertEqual(days, 3)
}

func testReverseOrderClampsToZero() {
let from = makeDate(year: 2025, month: 1, day: 20, hour: 12)
let to = makeDate(year: 2025, month: 1, day: 18, hour: 12)
let days = Calendar.current.numberOfDaysBetween(from, and: to)
XCTAssertEqual(days, 0)
}

func testAcrossMidnightCountsAsOne() {
let from = makeDate(year: 2025, month: 3, day: 10, hour: 23, minute: 59)
let to = makeDate(year: 2025, month: 3, day: 11, hour: 0, minute: 1)
let days = Calendar.current.numberOfDaysBetween(from, and: to)
XCTAssertEqual(days, 1)
}
}


58 changes: 55 additions & 3 deletions Tests/FormbricksSDKTests/FormbricksSDKTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ final class FormbricksSDKTests: XCTestCase {

wait(for: [trackExpectation])

XCTAssertTrue(Formbricks.surveyManager?.isShowingSurvey ?? false)
// In headless test environment, presentation fails (no key window), so flag should reset to false
XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? true)

// "Dismiss" the webview.
Formbricks.surveyManager?.dismissSurveyWebView()
Expand Down Expand Up @@ -161,7 +162,8 @@ final class FormbricksSDKTests: XCTestCase {

wait(for: [thirdTrackExpectation])

XCTAssertTrue(Formbricks.surveyManager?.isShowingSurvey ?? false)
// In headless test environment, presentation fails (no key window), so flag should reset to false
XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? true)

// Test the cleanup
Formbricks.cleanup()
Expand Down Expand Up @@ -242,7 +244,8 @@ final class FormbricksSDKTests: XCTestCase {
SurveyLanguage(enabled: true, isDefault: true, language: LanguageDetail(id: "1", code: "en", alias: "english", projectId: "p1")),
SurveyLanguage(enabled: true, isDefault: false, language: LanguageDetail(id: "2", code: "de", alias: "german", projectId: "p1")),
SurveyLanguage(enabled: false, isDefault: false, language: LanguageDetail(id: "3", code: "fr", alias: nil, projectId: "p1"))
]
],
projectOverwrites: nil
)
// No language provided
XCTAssertEqual(manager.getLanguageCode(survey: survey, language: nil), "default")
Expand All @@ -257,4 +260,53 @@ final class FormbricksSDKTests: XCTestCase {
// Alias not found
XCTAssertNil(manager.getLanguageCode(survey: survey, language: "spanish"))
}

func testWebViewDataUsesSurveyOverwrites() {
// Setup SDK with mock service loading Environment.json (which now includes projectOverwrites)
let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId)
.setLogLevel(.debug)
.service(mockService)
.build()
Formbricks.setup(with: config)

// Force refresh and wait briefly for async fetch
Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true)
let expectation = self.expectation(description: "Env loaded")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() }
wait(for: [expectation])

guard let env = Formbricks.surveyManager?.environmentResponse else {
XCTFail("Missing environmentResponse")
return
}

// Build the view model to produce WEBVIEW_DATA
let vm = FormbricksViewModel(environmentResponse: env, surveyId: surveyID)
guard let html = vm.htmlString else {
XCTFail("Missing htmlString")
return
}

// Extract the JSON payload between backticks in `const json = `...``
guard let markerRange = html.range(of: "const json = `") else {
XCTFail("Marker not found")
return
}
let start = markerRange.upperBound
guard let end = html[start...].firstIndex(of: "`") else {
XCTFail("End backtick not found")
return
}
let jsonSubstring = html[start..<end]
let jsonString = String(jsonSubstring)
guard let data = jsonString.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
XCTFail("Invalid JSON in WEBVIEW_DATA")
return
}

// placement should come from survey.projectOverwrites (center), and darkOverlay true
XCTAssertEqual(object["placement"] as? String, "center")
XCTAssertEqual(object["darkOverlay"] as? Bool, true)
}
}
8 changes: 7 additions & 1 deletion Tests/FormbricksSDKTests/Mock/Environment.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,13 @@
"isBackButtonHidden": false,
"languages": [],
"name": "Start from scratch",
"projectOverwrites": null,
"projectOverwrites": {
"placement": "center",
"darkOverlay": true,
"clickOutsideClose": false,
"brandColor": "#ff0000",
"highlightBorderColor": "#00ff00"
},
"questions": [
{
"allowMultipleFiles": true,
Expand Down