Skip to content

Commit 651f80e

Browse files
authored
fix: fixes survey placement and other cosmetic issues (#35)
* fixes QA bugs * feedback * fix: tests * fix: test * fix: test * fix: test
1 parent c6f7233 commit 651f80e

9 files changed

Lines changed: 204 additions & 28 deletions

File tree

Sources/FormbricksSDK/Extension/Calendar+DaysBetween.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ extension Calendar {
88
let numberOfDays = dateComponents([.day], from: fromDate, to: toDate)
99

1010
guard let day = numberOfDays.day else { return 0 }
11-
return abs(day + 1)
11+
return max(0, day)
1212
}
1313
}

Sources/FormbricksSDK/Manager/PresentSurveyManager.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ final class PresentSurveyManager {
1313
private weak var viewController: UIViewController?
1414

1515
/// Present the webview
16-
func present(environmentResponse: EnvironmentResponse, id: String) {
16+
func present(environmentResponse: EnvironmentResponse, id: String, completion: ((Bool) -> Void)? = nil) {
1717
DispatchQueue.main.async { [weak self] in
1818
guard let self = self else { return }
1919
if let window = UIApplication.safeKeyWindow {
@@ -25,7 +25,11 @@ final class PresentSurveyManager {
2525
presentationController.detents = [.large()]
2626
}
2727
self.viewController = vc
28-
window.rootViewController?.present(vc, animated: true, completion: nil)
28+
window.rootViewController?.present(vc, animated: true, completion: {
29+
completion?(true)
30+
})
31+
} else {
32+
completion?(false)
2933
}
3034
}
3135
}

Sources/FormbricksSDK/Manager/SurveyManager.swift

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,21 @@ final class SurveyManager {
8282

8383
let actionClasses = environmentResponse?.data.data.actionClasses ?? []
8484
let codeActionClasses = actionClasses.filter { $0.type == "code" }
85-
let actionClass = codeActionClasses.first { $0.key == action }
85+
guard let actionClass = codeActionClasses.first(where: { $0.key == action }) else {
86+
Formbricks.logger?.error("\(action) action unknown. Please add this action in Formbricks first in order to use it in your code.")
87+
return
88+
}
89+
8690
let firstSurveyWithActionClass = filteredSurveys.first { survey in
87-
return survey.triggers?.contains(where: { $0.actionClass?.name == actionClass?.name }) ?? false
91+
return survey.triggers?.contains(where: { $0.actionClass?.name == actionClass.name }) ?? false
8892
}
8993

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

94102
if isMultiLangSurvey {
@@ -103,12 +111,25 @@ final class SurveyManager {
103111
}
104112

105113
// Display and delay it if needed
106-
if let surveyId = firstSurveyWithActionClass?.id, shouldDisplay {
114+
if let survey = firstSurveyWithActionClass, shouldDisplay {
107115
isShowingSurvey = true
108-
let timeout = firstSurveyWithActionClass?.delay ?? 0
116+
let timeout = survey.delay ?? 0
117+
if timeout > 0 {
118+
Formbricks.logger?.info("Delaying survey \(survey.name) by \(timeout) seconds")
119+
}
109120
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { [weak self] in
110-
self?.showSurvey(withId: surveyId)
111-
completion?()
121+
guard let self = self else { return }
122+
if let environmentResponse = self.environmentResponse {
123+
self.presentSurveyManager.present(environmentResponse: environmentResponse, id: survey.id) { success in
124+
if !success {
125+
self.isShowingSurvey = false
126+
}
127+
completion?()
128+
}
129+
} else {
130+
self.isShowingSurvey = false
131+
completion?()
132+
}
112133
}
113134
}
114135
}
@@ -199,8 +220,9 @@ private extension SurveyManager {
199220
/// Decides if the survey should be displayed based on the display percentage.
200221
internal func shouldDisplayBasedOnPercentage(_ displayPercentage: Double?) -> Bool {
201222
guard let displayPercentage = displayPercentage else { return true }
202-
let randomNum = Double(Int.random(in: 0..<10000)) / 100.0
203-
return randomNum <= displayPercentage
223+
let clampedPercentage = min(max(displayPercentage, 0), 100)
224+
let draw = Double.random(in: 0..<100)
225+
return draw < clampedPercentage
204226
}
205227
}
206228

@@ -273,7 +295,9 @@ extension SurveyManager {
273295
let recontactDays = survey.recontactDays ?? defaultRecontactDays
274296

275297
if let recontactDays = recontactDays {
276-
return Calendar.current.numberOfDaysBetween(Date(), and: lastDisplayedAt) >= recontactDays
298+
let secondsElapsed = Date().timeIntervalSince(lastDisplayedAt)
299+
let daysBetween = Int(secondsElapsed / 86_400)
300+
return daysBetween >= recontactDays
277301
}
278302

279303
return true

Sources/FormbricksSDK/Model/Environment/Survey.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,24 @@ struct LanguageDetail: Codable {
2525
let projectId: String
2626
}
2727

28+
// MARK: - New types for projectOverwrites
29+
30+
enum Placement: String, Codable {
31+
case topLeft = "topLeft"
32+
case topRight = "topRight"
33+
case bottomLeft = "bottomLeft"
34+
case bottomRight = "bottomRight"
35+
case center = "center"
36+
}
37+
38+
struct ProjectOverwrites: Codable {
39+
let brandColor: String?
40+
let highlightBorderColor: String?
41+
let placement: Placement?
42+
let clickOutsideClose: Bool?
43+
let darkOverlay: Bool?
44+
}
45+
2846
struct Survey: Codable {
2947
let id: String
3048
let name: String
@@ -37,4 +55,5 @@ struct Survey: Codable {
3755
let segment: Segment?
3856
let styling: Styling?
3957
let languages: [SurveyLanguage]?
58+
let projectOverwrites: ProjectOverwrites?
4059
}

Sources/FormbricksSDK/Networking/Queue/UpdateQueue.swift

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,10 @@ final class UpdateQueue {
8383

8484
private extension UpdateQueue {
8585
func startDebounceTimer() {
86-
timer?.invalidate()
87-
timer = nil
88-
8986
DispatchQueue.main.async { [weak self] in
9087
guard let self = self else { return }
88+
self.timer?.invalidate()
89+
self.timer = nil
9190
self.timer = Timer.scheduledTimer(timeInterval: UpdateQueue.debounceInterval,
9291
target: self,
9392
selector: #selector(self.commit),
@@ -97,16 +96,23 @@ private extension UpdateQueue {
9796
}
9897

9998
@objc func commit() {
100-
let effectiveUserId: String? = self.userId ?? Formbricks.userManager?.userId ?? nil
101-
99+
var effectiveUserId: String?
100+
var effectiveAttributes: [String: String]?
101+
102+
// Capture a consistent snapshot under the sync queue
103+
syncQueue.sync {
104+
effectiveUserId = self.userId ?? Formbricks.userManager?.userId
105+
effectiveAttributes = self.attributes
106+
}
107+
102108
guard let userId = effectiveUserId else {
103109
let error = FormbricksSDKError(type: .userIdIsNotSetYet)
104110
Formbricks.logger?.error(error.message)
105111
return
106112
}
107113

108-
Formbricks.logger?.debug("UpdateQueue - commit() called on UpdateQueue with \(userId) and \(attributes ?? [:])")
109-
userManager?.syncUser(withId: userId, attributes: attributes)
114+
Formbricks.logger?.debug("UpdateQueue - commit() called on UpdateQueue with \(userId) and \(effectiveAttributes ?? [:])")
115+
userManager?.syncUser(withId: userId, attributes: effectiveAttributes)
110116
}
111117
}
112118

Sources/FormbricksSDK/WebView/FormbricksViewModel.swift

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ private extension FormbricksViewModel {
6767
onClose,
6868
onOpenExternalURL,
6969
};
70-
7170
window.formbricksSurveys.renderSurvey(surveyProps);
7271
}
7372
@@ -92,23 +91,34 @@ private class WebViewData {
9291
var data: [String: Any] = [:]
9392

9493
init(environmentResponse: EnvironmentResponse, surveyId: String) {
94+
let matchedSurvey = environmentResponse.data.data.surveys?.first(where: {$0.id == surveyId})
95+
let project = environmentResponse.data.data.project
96+
9597
data["survey"] = environmentResponse.getSurveyJson(forSurveyId: surveyId)
9698
data["appUrl"] = Formbricks.appUrl
9799
data["environmentId"] = Formbricks.environmentId
98100
data["contactId"] = Formbricks.userManager?.contactId
99101
data["isWebEnvironment"] = false
100-
data["isBrandingEnabled"] = environmentResponse.data.data.project.inAppSurveyBranding ?? true
102+
data["isBrandingEnabled"] = project.inAppSurveyBranding ?? true
103+
104+
if let placementEnum = matchedSurvey?.projectOverwrites?.placement {
105+
data["placement"] = placementEnum.rawValue
106+
} else {
107+
data["placement"] = project.placement
108+
}
109+
110+
data["darkOverlay"] = matchedSurvey?.projectOverwrites?.darkOverlay ?? project.darkOverlay
101111

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

104114
if isMultiLangSurvey {
105115
data["languageCode"] = Formbricks.language
106116
} else {
107117
data["languageCode"] = "default"
108118
}
109119

110-
let hasCustomStyling = environmentResponse.data.data.surveys?.first(where: { $0.id == surveyId })?.styling != nil
111-
let enabled = environmentResponse.data.data.project.styling?.allowStyleOverwrite ?? false
120+
let hasCustomStyling = matchedSurvey?.styling != nil
121+
let enabled = project.styling?.allowStyleOverwrite ?? false
112122

113123
data["styling"] = hasCustomStyling && enabled ? environmentResponse.getSurveyStylingJson(forSurveyId: surveyId): environmentResponse.getProjectStylingJson()
114124
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import XCTest
2+
@testable import FormbricksSDK
3+
4+
final class CalendarDaysBetweenTests: XCTestCase {
5+
private func makeDate(year: Int, month: Int, day: Int, hour: Int = 12, minute: Int = 0, second: Int = 0) -> Date {
6+
let calendar = Calendar.current
7+
var comps = DateComponents()
8+
comps.calendar = calendar
9+
comps.timeZone = calendar.timeZone
10+
comps.year = year
11+
comps.month = month
12+
comps.day = day
13+
comps.hour = hour
14+
comps.minute = minute
15+
comps.second = second
16+
return calendar.date(from: comps) ?? Date()
17+
}
18+
19+
func testSameDayReturnsZero() {
20+
let from = makeDate(year: 2025, month: 1, day: 15, hour: 10, minute: 30)
21+
let to = makeDate(year: 2025, month: 1, day: 15, hour: 22, minute: 15)
22+
let days = Calendar.current.numberOfDaysBetween(from, and: to)
23+
XCTAssertEqual(days, 0)
24+
}
25+
26+
func testNextDayReturnsOne() {
27+
let from = makeDate(year: 2025, month: 1, day: 15, hour: 10)
28+
let to = makeDate(year: 2025, month: 1, day: 16, hour: 9)
29+
let days = Calendar.current.numberOfDaysBetween(from, and: to)
30+
XCTAssertEqual(days, 1)
31+
}
32+
33+
func testMultipleDays() {
34+
let from = makeDate(year: 2025, month: 1, day: 10, hour: 10)
35+
let to = makeDate(year: 2025, month: 1, day: 13, hour: 9)
36+
let days = Calendar.current.numberOfDaysBetween(from, and: to)
37+
XCTAssertEqual(days, 3)
38+
}
39+
40+
func testReverseOrderClampsToZero() {
41+
let from = makeDate(year: 2025, month: 1, day: 20, hour: 12)
42+
let to = makeDate(year: 2025, month: 1, day: 18, hour: 12)
43+
let days = Calendar.current.numberOfDaysBetween(from, and: to)
44+
XCTAssertEqual(days, 0)
45+
}
46+
47+
func testAcrossMidnightCountsAsOne() {
48+
let from = makeDate(year: 2025, month: 3, day: 10, hour: 23, minute: 59)
49+
let to = makeDate(year: 2025, month: 3, day: 11, hour: 0, minute: 1)
50+
let days = Calendar.current.numberOfDaysBetween(from, and: to)
51+
XCTAssertEqual(days, 1)
52+
}
53+
}
54+
55+

Tests/FormbricksSDKTests/FormbricksSDKTests.swift

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ final class FormbricksSDKTests: XCTestCase {
112112

113113
wait(for: [trackExpectation])
114114

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

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

162163
wait(for: [thirdTrackExpectation])
163164

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

166168
// Test the cleanup
167169
Formbricks.cleanup()
@@ -242,7 +244,8 @@ final class FormbricksSDKTests: XCTestCase {
242244
SurveyLanguage(enabled: true, isDefault: true, language: LanguageDetail(id: "1", code: "en", alias: "english", projectId: "p1")),
243245
SurveyLanguage(enabled: true, isDefault: false, language: LanguageDetail(id: "2", code: "de", alias: "german", projectId: "p1")),
244246
SurveyLanguage(enabled: false, isDefault: false, language: LanguageDetail(id: "3", code: "fr", alias: nil, projectId: "p1"))
245-
]
247+
],
248+
projectOverwrites: nil
246249
)
247250
// No language provided
248251
XCTAssertEqual(manager.getLanguageCode(survey: survey, language: nil), "default")
@@ -257,4 +260,53 @@ final class FormbricksSDKTests: XCTestCase {
257260
// Alias not found
258261
XCTAssertNil(manager.getLanguageCode(survey: survey, language: "spanish"))
259262
}
263+
264+
func testWebViewDataUsesSurveyOverwrites() {
265+
// Setup SDK with mock service loading Environment.json (which now includes projectOverwrites)
266+
let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId)
267+
.setLogLevel(.debug)
268+
.service(mockService)
269+
.build()
270+
Formbricks.setup(with: config)
271+
272+
// Force refresh and wait briefly for async fetch
273+
Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true)
274+
let expectation = self.expectation(description: "Env loaded")
275+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() }
276+
wait(for: [expectation])
277+
278+
guard let env = Formbricks.surveyManager?.environmentResponse else {
279+
XCTFail("Missing environmentResponse")
280+
return
281+
}
282+
283+
// Build the view model to produce WEBVIEW_DATA
284+
let vm = FormbricksViewModel(environmentResponse: env, surveyId: surveyID)
285+
guard let html = vm.htmlString else {
286+
XCTFail("Missing htmlString")
287+
return
288+
}
289+
290+
// Extract the JSON payload between backticks in `const json = `...``
291+
guard let markerRange = html.range(of: "const json = `") else {
292+
XCTFail("Marker not found")
293+
return
294+
}
295+
let start = markerRange.upperBound
296+
guard let end = html[start...].firstIndex(of: "`") else {
297+
XCTFail("End backtick not found")
298+
return
299+
}
300+
let jsonSubstring = html[start..<end]
301+
let jsonString = String(jsonSubstring)
302+
guard let data = jsonString.data(using: .utf8),
303+
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
304+
XCTFail("Invalid JSON in WEBVIEW_DATA")
305+
return
306+
}
307+
308+
// placement should come from survey.projectOverwrites (center), and darkOverlay true
309+
XCTAssertEqual(object["placement"] as? String, "center")
310+
XCTAssertEqual(object["darkOverlay"] as? Bool, true)
311+
}
260312
}

Tests/FormbricksSDKTests/Mock/Environment.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,13 @@
5555
"isBackButtonHidden": false,
5656
"languages": [],
5757
"name": "Start from scratch",
58-
"projectOverwrites": null,
58+
"projectOverwrites": {
59+
"placement": "center",
60+
"darkOverlay": true,
61+
"clickOutsideClose": false,
62+
"brandColor": "#ff0000",
63+
"highlightBorderColor": "#00ff00"
64+
},
5965
"questions": [
6066
{
6167
"allowMultipleFiles": true,

0 commit comments

Comments
 (0)