Skip to content

Commit 1649f3f

Browse files
authored
feat: adds support for attribute data types (#49)
* adds support for attribute data types * fixes * updates lock
1 parent c0a9d6f commit 1649f3f

15 files changed

Lines changed: 1716 additions & 1693 deletions

File tree

packages/react-native/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ export const setUserId = async (userId: string): Promise<void> => {
1818
await queue.wait();
1919
};
2020

21-
export const setAttribute = async (key: string, value: string): Promise<void> => {
21+
export const setAttribute = async (key: string, value: string | number | Date): Promise<void> => {
2222
queue.add(Attributes.setAttributes, true, { [key]: value });
2323
await queue.wait();
2424
};
2525

26-
export const setAttributes = async (attributes: Record<string, string>): Promise<void> => {
26+
export const setAttributes = async (attributes: Record<string, string | number | Date>): Promise<void> => {
2727
queue.add(Attributes.setAttributes, true, attributes);
2828
await queue.wait();
2929
};

packages/react-native/src/lib/common/api.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,21 +76,17 @@ export class ApiClient {
7676

7777
async createOrUpdateUser(userUpdateInput: {
7878
userId: string;
79-
attributes?: Record<string, string>;
79+
attributes?: Record<string, string | number>;
8080
}): Promise<Result<CreateOrUpdateUserResponse, ApiErrorResponse>> {
81-
// transform all attributes to string if attributes are present into a new attributes copy
82-
const attributes: Record<string, string> = {};
83-
for (const key in userUpdateInput.attributes) {
84-
attributes[key] = String(userUpdateInput.attributes[key]);
85-
}
86-
81+
// Pass attributes as-is to preserve number types
82+
// The backend will use the JS type to determine the attribute data type
8783
return makeRequest(
8884
this.appUrl,
8985
`/api/v2/client/${this.environmentId}/user`,
9086
"POST",
9187
{
9288
userId: userUpdateInput.userId,
93-
attributes,
89+
attributes: userUpdateInput.attributes,
9490
},
9591
this.isDebug
9692
);

packages/react-native/src/lib/survey/action.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export const track = async (
114114
if (!actionClass) {
115115
return err({
116116
code: "invalid_code",
117-
message: `${code} action unknown. Please add this action in Formbricks first in order to use it in your code.`,
117+
message: `Action with identifier '${code}' is unknown. Please add this action in Formbricks in order to use it via the SDK action tracking.`,
118118
});
119119
}
120120

packages/react-native/src/lib/survey/tests/action.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ describe("survey/action.ts", () => {
185185
if (!result.ok) {
186186
expect(result.error.code).toBe("invalid_code");
187187
expect(result.error.message).toBe(
188-
"invalidCode action unknown. Please add this action in Formbricks first in order to use it in your code."
188+
"Action with identifier 'invalidCode' is unknown. Please add this action in Formbricks in order to use it via the SDK action tracking."
189189
);
190190
}
191191
});
Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,38 @@
11
import { UpdateQueue } from "@/lib/user/update-queue";
22
import { type NetworkError, type Result, okVoid } from "@/types/error";
33

4+
/**
5+
* Sets attributes on the current user/contact.
6+
*
7+
* Attribute types are determined by the JavaScript value type:
8+
* - String values -> string attribute
9+
* - Number values -> number attribute
10+
* - Date objects -> date attribute (converted to ISO string)
11+
* - ISO 8601 date strings -> date attribute
12+
*
13+
* On first write to a new attribute, the type is set based on the JS value type.
14+
* On subsequent writes, the value must match the existing attribute type.
15+
*
16+
* @param attributes - Key-value pairs where values can be strings, numbers, or Date objects
17+
*/
418
export const setAttributes = async (
5-
attributes: Record<string, string>
19+
attributes: Record<string, string | number | Date>
620
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
721
): Promise<Result<void, NetworkError>> => {
22+
// Normalize values: convert Date to ISO string, preserve numbers as numbers
23+
const normalizedAttributes: Record<string, string | number> = {};
24+
for (const [key, value] of Object.entries(attributes)) {
25+
if (value instanceof Date) {
26+
// Date objects become ISO strings (backend will detect as date type)
27+
normalizedAttributes[key] = value.toISOString();
28+
} else {
29+
// Preserve strings as strings, numbers as numbers
30+
normalizedAttributes[key] = value;
31+
}
32+
}
33+
834
const updateQueue = UpdateQueue.getInstance();
9-
await updateQueue.updateAttributes(attributes);
35+
await updateQueue.updateAttributes(normalizedAttributes);
1036
void updateQueue.processUpdates();
1137
return okVoid();
1238
};

packages/react-native/src/lib/user/tests/attribute.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,5 +80,55 @@ describe("User Attributes", () => {
8080
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
8181
// The function returns before processUpdates completes due to void operator
8282
});
83+
84+
test("converts Date values to ISO strings", async () => {
85+
const testDate = new Date("2026-01-15T10:30:00.000Z");
86+
const attributes = { createdAt: testDate };
87+
88+
await setAttributes(attributes);
89+
90+
expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith({
91+
createdAt: "2026-01-15T10:30:00.000Z",
92+
});
93+
});
94+
95+
test("preserves number values as numbers", async () => {
96+
const attributes = { age: 25, score: 99.5 };
97+
98+
await setAttributes(attributes);
99+
100+
expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith({
101+
age: 25,
102+
score: 99.5,
103+
});
104+
});
105+
106+
test("preserves string values as strings", async () => {
107+
const attributes = { name: "Alice", role: "admin" };
108+
109+
await setAttributes(attributes);
110+
111+
expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith({
112+
name: "Alice",
113+
role: "admin",
114+
});
115+
});
116+
117+
test("normalizes mixed attribute types correctly", async () => {
118+
const testDate = new Date("2026-06-01T00:00:00.000Z");
119+
const attributes = {
120+
name: "Bob",
121+
age: 30,
122+
joinedAt: testDate,
123+
};
124+
125+
await setAttributes(attributes);
126+
127+
expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith({
128+
name: "Bob",
129+
age: 30,
130+
joinedAt: "2026-06-01T00:00:00.000Z",
131+
});
132+
});
83133
});
84134
});

packages/react-native/src/lib/user/tests/update-queue.test.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,16 @@ vi.mock("@/lib/common/config", () => ({
2424
},
2525
}));
2626

27+
const { mockLogger } = vi.hoisted(() => ({
28+
mockLogger: {
29+
debug: vi.fn(),
30+
error: vi.fn(),
31+
},
32+
}));
33+
2734
vi.mock("@/lib/common/logger", () => ({
2835
Logger: {
29-
getInstance: vi.fn(() => ({
30-
debug: vi.fn(),
31-
error: vi.fn(),
32-
})),
36+
getInstance: vi.fn(() => mockLogger),
3337
},
3438
}));
3539

@@ -119,6 +123,7 @@ describe("UpdateQueue", () => {
119123

120124
(sendUpdates as Mock).mockReturnValue({
121125
ok: true,
126+
data: { hasWarnings: false },
122127
});
123128

124129
await updateQueue.updateAttributes({ name: mockAttributes.name });
@@ -162,4 +167,42 @@ describe("UpdateQueue", () => {
162167
"Formbricks can't set attributes without a userId!"
163168
);
164169
});
170+
171+
test("processUpdates logs error when sendUpdates fails", async () => {
172+
(sendUpdates as Mock).mockResolvedValue({
173+
ok: false,
174+
error: { message: "Server error" },
175+
});
176+
177+
await updateQueue.updateAttributes({ name: mockAttributes.name });
178+
179+
await new Promise((resolve) => {
180+
setTimeout(resolve, 600);
181+
});
182+
183+
await updateQueue.processUpdates();
184+
185+
expect(mockLogger.error).toHaveBeenCalledWith(
186+
"Failed to send updates: Server error"
187+
);
188+
});
189+
190+
test("processUpdates suppresses success log when hasWarnings is true", async () => {
191+
(sendUpdates as Mock).mockResolvedValue({
192+
ok: true,
193+
data: { hasWarnings: true },
194+
});
195+
196+
await updateQueue.updateAttributes({ name: mockAttributes.name });
197+
198+
await new Promise((resolve) => {
199+
setTimeout(resolve, 600);
200+
});
201+
202+
await updateQueue.processUpdates();
203+
204+
expect(mockLogger.debug).not.toHaveBeenCalledWith(
205+
"Updates sent successfully"
206+
);
207+
});
165208
});

packages/react-native/src/lib/user/tests/update.test.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ vi.mock("@/lib/common/logger", () => ({
2424
Logger: {
2525
getInstance: vi.fn(() => ({
2626
debug: vi.fn(),
27+
error: vi.fn(),
2728
})),
2829
},
2930
}));
@@ -104,7 +105,7 @@ describe("sendUpdatesToBackend", () => {
104105
if (!result.ok) {
105106
expect(result.error.code).toBe("network_error");
106107
expect(result.error.message).toBe(
107-
"Error updating user with userId user_123"
108+
"Error updating user with userId user_123",
108109
);
109110
}
110111
});
@@ -128,7 +129,7 @@ describe("sendUpdatesToBackend", () => {
128129
appUrl: mockAppUrl,
129130
environmentId: mockEnvironmentId,
130131
updates: mockUpdates,
131-
})
132+
}),
132133
).rejects.toThrow("Network error");
133134
});
134135
});
@@ -150,6 +151,7 @@ describe("sendUpdates", () => {
150151

151152
(Logger.getInstance as Mock).mockImplementation(() => ({
152153
debug: vi.fn(),
154+
error: vi.fn(),
153155
}));
154156
});
155157

@@ -176,6 +178,9 @@ describe("sendUpdates", () => {
176178
});
177179

178180
expect(result.ok).toBe(true);
181+
if (result.ok) {
182+
expect(result.data.hasWarnings).toBe(false);
183+
}
179184
});
180185

181186
test("handles backend errors", async () => {
@@ -204,6 +209,74 @@ describe("sendUpdates", () => {
204209
}
205210
});
206211

212+
test("returns hasWarnings true when backend returns errors", async () => {
213+
const mockResponse = {
214+
ok: true,
215+
data: {
216+
state: {
217+
data: {
218+
userId: mockUserId,
219+
attributes: mockAttributes,
220+
},
221+
expiresAt: new Date(Date.now() + 1000 * 60 * 30),
222+
},
223+
errors: ["Attribute 'invalidKey' has an invalid key format"],
224+
},
225+
};
226+
227+
(ApiClient as Mock).mockImplementation(function () {
228+
return { createOrUpdateUser: vi.fn().mockResolvedValue(mockResponse) };
229+
});
230+
231+
const result = await sendUpdates({
232+
updates: { userId: mockUserId, attributes: mockAttributes },
233+
});
234+
235+
expect(result.ok).toBe(true);
236+
if (result.ok) {
237+
expect(result.data.hasWarnings).toBe(true);
238+
}
239+
});
240+
241+
test("logs backend errors at error level and messages at debug level", async () => {
242+
const mockError = vi.fn();
243+
const mockDebug = vi.fn();
244+
(Logger.getInstance as Mock).mockImplementation(() => ({
245+
debug: mockDebug,
246+
error: mockError,
247+
}));
248+
249+
const mockResponse = {
250+
ok: true,
251+
data: {
252+
state: {
253+
data: {
254+
userId: mockUserId,
255+
attributes: mockAttributes,
256+
},
257+
expiresAt: new Date(Date.now() + 1000 * 60 * 30),
258+
},
259+
messages: ["Email attribute already exists"],
260+
errors: ["Attribute 'badKey' has an invalid format"],
261+
},
262+
};
263+
264+
(ApiClient as Mock).mockImplementation(function () {
265+
return { createOrUpdateUser: vi.fn().mockResolvedValue(mockResponse) };
266+
});
267+
268+
await sendUpdates({
269+
updates: { userId: mockUserId, attributes: mockAttributes },
270+
});
271+
272+
expect(mockError).toHaveBeenCalledWith(
273+
"Attribute 'badKey' has an invalid format",
274+
);
275+
expect(mockDebug).toHaveBeenCalledWith(
276+
"User update message: Email attribute already exists",
277+
);
278+
});
279+
207280
test("handles unexpected errors", async () => {
208281
(ApiClient as Mock).mockImplementation(function () {
209282
return {

0 commit comments

Comments
 (0)