Skip to content

Commit 78cbcab

Browse files
committed
feat(flows): add date/datetime picker webview for get user data step
1 parent d09ec48 commit 78cbcab

40 files changed

Lines changed: 2653 additions & 45 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, test } from "vitest"
2+
import { toSelectedValueIso } from "@/features/get-user-data-webview/lib/value-conversion"
3+
4+
describe("toSelectedValueIso", () => {
5+
test("returns null when no date is picked", () => {
6+
expect(toSelectedValueIso(undefined, "date")).toBeNull()
7+
})
8+
9+
test("date mode converts the picked local calendar day to UTC midnight", () => {
10+
// A contact in GMT+7 picks August 21, 2026 — the picker component gives
11+
// us a local Date object for that calendar day (local midnight-ish, per
12+
// the vendored DateTimePicker). asIsoDate on the typed-reply path parses
13+
// a bare "2026-08-21" string as UTC midnight, so the webview path must
14+
// match that exactly or the stored day would shift backward for
15+
// contacts east of UTC.
16+
const pickedLocalDate = new Date(2026, 7, 21, 0, 0, 0) // Aug 21, 2026 local midnight
17+
18+
const result = toSelectedValueIso(pickedLocalDate, "date")
19+
20+
expect(result).toBe("2026-08-21T00:00:00.000Z")
21+
})
22+
23+
test("date mode ignores any local time-of-day component and keeps the calendar day", () => {
24+
const pickedLocalDate = new Date(2026, 7, 21, 23, 45, 0)
25+
26+
const result = toSelectedValueIso(pickedLocalDate, "date")
27+
28+
expect(result).toBe("2026-08-21T00:00:00.000Z")
29+
})
30+
31+
test("datetime mode keeps the picked local instant as-is (ISO instant conversion)", () => {
32+
const pickedLocalDate = new Date(2026, 7, 21, 14, 30, 0)
33+
34+
const result = toSelectedValueIso(pickedLocalDate, "datetime")
35+
36+
expect(result).toBe(pickedLocalDate.toISOString())
37+
})
38+
})
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// @vitest-environment node
2+
3+
import { beforeEach, describe, expect, test, vi } from "vitest"
4+
5+
const mocks = vi.hoisted(() => ({
6+
findById: vi.fn(),
7+
verifyUserDataWebviewToken: vi.fn(),
8+
loadServableWorkspace: vi.fn(),
9+
dateTimePickerForm: vi.fn(() => null),
10+
}))
11+
12+
vi.mock("next/script", () => ({
13+
default: () => null,
14+
}))
15+
16+
vi.mock("next-intl/server", () => ({
17+
getTranslations: vi.fn(async () => (key: string) => key),
18+
}))
19+
20+
vi.mock("@chatbotx.io/business", () => ({
21+
workspaceService: {
22+
findById: mocks.findById,
23+
},
24+
}))
25+
26+
vi.mock("@chatbotx.io/encryption", () => ({
27+
verifyUserDataWebviewToken: mocks.verifyUserDataWebviewToken,
28+
}))
29+
30+
vi.mock("@/lib/workspace/load-servable-workspace", () => ({
31+
loadServableWorkspace: mocks.loadServableWorkspace,
32+
}))
33+
34+
vi.mock(
35+
"@/features/get-user-data-webview/components/date-time-picker-form",
36+
() => ({
37+
DateTimePickerForm: mocks.dateTimePickerForm,
38+
}),
39+
)
40+
41+
const { default: DateTimePickerPage } = await import(
42+
"../src/app/extensions/datetime-picker/page"
43+
)
44+
45+
const VALID_PAYLOAD = {
46+
workspaceId: "workspace-1",
47+
conversationId: "conversation-1",
48+
contactInboxId: "contact-inbox-1",
49+
contactId: "contact-1",
50+
channel: "messenger",
51+
flowId: "flow-1",
52+
flowVersionId: "flow-version-1",
53+
stepId: "step-1",
54+
nodeId: "node-1",
55+
challengeId: "challenge-1",
56+
outputFieldId: "field-1",
57+
replyFormat: "date" as const,
58+
expiresAt: Date.now() + 60_000,
59+
}
60+
61+
type ReactElementLike = {
62+
type: unknown
63+
props: { children?: unknown }
64+
}
65+
66+
function isReactElementLike(value: unknown): value is ReactElementLike {
67+
return (
68+
typeof value === "object" &&
69+
value !== null &&
70+
"type" in value &&
71+
"props" in value
72+
)
73+
}
74+
75+
// The page component is never rendered by a renderer in this test (only
76+
// `DateTimePickerPage(...)` is invoked directly), so `DateTimePickerForm`
77+
// itself is never called — React.createElement just builds a plain element
78+
// tree. Walk that tree to find the DateTimePickerForm element and inspect
79+
// the props it was given, instead of asserting on the mock function call.
80+
function findFormElementProps(node: unknown): Record<string, unknown> | null {
81+
if (!isReactElementLike(node)) {
82+
return null
83+
}
84+
if (node.type === mocks.dateTimePickerForm) {
85+
return node.props as Record<string, unknown>
86+
}
87+
const children = node.props.children
88+
const childList = Array.isArray(children) ? children : [children]
89+
for (const child of childList) {
90+
const found = findFormElementProps(child)
91+
if (found) {
92+
return found
93+
}
94+
}
95+
return null
96+
}
97+
98+
describe("datetime picker webview page", () => {
99+
beforeEach(() => {
100+
vi.clearAllMocks()
101+
mocks.loadServableWorkspace.mockResolvedValue({
102+
servable: true,
103+
workspace: { id: "workspace-1", language: "en" },
104+
})
105+
mocks.findById.mockResolvedValue({ id: "workspace-1", language: "en" })
106+
})
107+
108+
test("renders an error message when token is missing", async () => {
109+
const result = await DateTimePickerPage({
110+
searchParams: Promise.resolve({}),
111+
})
112+
113+
expect(mocks.verifyUserDataWebviewToken).not.toHaveBeenCalled()
114+
expect(mocks.dateTimePickerForm).not.toHaveBeenCalled()
115+
expect(result).toBeTruthy()
116+
})
117+
118+
test("renders an error message when the token fails verification", async () => {
119+
mocks.verifyUserDataWebviewToken.mockRejectedValue(
120+
new Error("expired token"),
121+
)
122+
123+
await DateTimePickerPage({
124+
searchParams: Promise.resolve({ token: "bad-token" }),
125+
})
126+
127+
expect(mocks.loadServableWorkspace).not.toHaveBeenCalled()
128+
expect(mocks.dateTimePickerForm).not.toHaveBeenCalled()
129+
})
130+
131+
test("renders the picker in date mode for a valid date-format token", async () => {
132+
mocks.verifyUserDataWebviewToken.mockResolvedValue({
133+
...VALID_PAYLOAD,
134+
replyFormat: "date",
135+
})
136+
137+
const result = await DateTimePickerPage({
138+
searchParams: Promise.resolve({ token: "token-1" }),
139+
})
140+
141+
expect(mocks.loadServableWorkspace).toHaveBeenCalledWith("workspace-1")
142+
expect(mocks.findById).toHaveBeenCalledWith({ id: "workspace-1" })
143+
expect(findFormElementProps(result)).toEqual({
144+
mode: "date",
145+
token: "token-1",
146+
})
147+
})
148+
149+
test("renders the picker in datetime mode for a valid datetime-format token", async () => {
150+
mocks.verifyUserDataWebviewToken.mockResolvedValue({
151+
...VALID_PAYLOAD,
152+
replyFormat: "datetime",
153+
})
154+
155+
const result = await DateTimePickerPage({
156+
searchParams: Promise.resolve({ token: "token-1" }),
157+
})
158+
159+
expect(findFormElementProps(result)).toEqual({
160+
mode: "datetime",
161+
token: "token-1",
162+
})
163+
})
164+
})
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// @vitest-environment node
2+
3+
import { beforeEach, describe, expect, test, vi } from "vitest"
4+
5+
const mocks = vi.hoisted(() => ({
6+
integrationQueueAdd: vi.fn(),
7+
verifyUserDataWebviewToken: vi.fn(),
8+
}))
9+
10+
vi.mock("@/lib/safe-action", () => {
11+
const chain: Record<string, unknown> = {}
12+
chain.inputSchema = vi.fn(() => chain)
13+
chain.action = vi.fn((handler: unknown) => handler)
14+
return { actionClient: chain }
15+
})
16+
17+
vi.mock("@chatbotx.io/encryption", () => ({
18+
verifyUserDataWebviewToken: mocks.verifyUserDataWebviewToken,
19+
}))
20+
21+
vi.mock("@chatbotx.io/flow-config", () => ({
22+
GET_USER_DATA_WEBVIEW_SELECTION_PAYLOAD_TYPE: "getUserDataWebviewSelection",
23+
}))
24+
25+
vi.mock("@chatbotx.io/worker-config", () => ({
26+
IntegrationJobAction: { sendFlow: "sendFlow" },
27+
integrationQueue: { add: mocks.integrationQueueAdd },
28+
}))
29+
30+
const { submitDateTime, submitDateTimeRequestSchema } = await import(
31+
"../src/app/extensions/datetime-picker/actions/submit-date-time.action"
32+
)
33+
34+
const VALID_PAYLOAD = {
35+
workspaceId: "workspace-1",
36+
conversationId: "conversation-1",
37+
contactInboxId: "contact-inbox-1",
38+
contactId: "contact-1",
39+
channel: "messenger",
40+
flowId: "flow-1",
41+
flowVersionId: "flow-version-1",
42+
stepId: "step-1",
43+
nodeId: "node-1",
44+
challengeId: "challenge-1",
45+
outputFieldId: "field-1",
46+
replyFormat: "date",
47+
expiresAt: Date.now() + 60_000,
48+
}
49+
50+
describe("submitDateTimeAction", () => {
51+
beforeEach(() => {
52+
vi.clearAllMocks()
53+
mocks.verifyUserDataWebviewToken.mockResolvedValue(VALID_PAYLOAD)
54+
mocks.integrationQueueAdd.mockResolvedValue(undefined)
55+
})
56+
57+
test("enqueues sendFlow with the exact metadata payload from the verified token", async () => {
58+
const result = await submitDateTime({
59+
token: "token-1",
60+
selectedValue: "2026-08-21T00:00:00.000Z",
61+
})
62+
63+
expect(mocks.verifyUserDataWebviewToken).toHaveBeenCalledWith("token-1")
64+
expect(mocks.integrationQueueAdd).toHaveBeenCalledTimes(1)
65+
expect(mocks.integrationQueueAdd).toHaveBeenCalledWith("sendFlow", {
66+
type: "sendFlow",
67+
data: {
68+
conversationId: "conversation-1",
69+
contactInboxId: "contact-inbox-1",
70+
flowId: "flow-1",
71+
flowVersionId: "flow-version-1",
72+
nodeId: "node-1",
73+
startFromStepId: "step-1",
74+
metadata: {
75+
type: "getUserDataWebviewSelection",
76+
stepId: "step-1",
77+
challengeId: "challenge-1",
78+
contactInboxId: "contact-inbox-1",
79+
selectedValue: "2026-08-21T00:00:00.000Z",
80+
},
81+
origin: "channel",
82+
},
83+
})
84+
expect(result).toEqual({ completed: true })
85+
})
86+
87+
test("rejects a non-ISO-datetime selectedValue", () => {
88+
expect(
89+
submitDateTimeRequestSchema.safeParse({
90+
token: "token-1",
91+
selectedValue: "not-a-date",
92+
}).success,
93+
).toBe(false)
94+
95+
expect(
96+
submitDateTimeRequestSchema.safeParse({
97+
token: "token-1",
98+
selectedValue: "2026-08-21T00:00:00.000Z",
99+
}).success,
100+
).toBe(true)
101+
})
102+
103+
test("does not enqueue when the token fails verification (tampered/expired token)", async () => {
104+
mocks.verifyUserDataWebviewToken.mockRejectedValue(
105+
new Error("invalid token"),
106+
)
107+
108+
await expect(
109+
submitDateTime({
110+
token: "tampered-token",
111+
selectedValue: "2026-08-21T00:00:00.000Z",
112+
}),
113+
).rejects.toThrow("invalid token")
114+
115+
expect(mocks.integrationQueueAdd).not.toHaveBeenCalled()
116+
})
117+
})

apps/builder/messages/ar.json

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2325,7 +2325,8 @@
23252325
},
23262326
"sendText": {
23272327
"tiktokTitleTruncated": "عند إرفاق أزرار، يرسل TikTok أول 40 حرفًا فقط من هذه الرسالة، وسيتم قص الباقي."
2328-
}
2328+
},
2329+
"dataWasSaved": "تم حفظ البيانات"
23292330
},
23302331
"folders": {
23312332
"heading": {
@@ -4891,6 +4892,28 @@
48914892
"timezoneHint": "تُعرض الأوقات بتوقيت {timezone}."
48924893
}
48934894
},
4895+
"userDataWebview": {
4896+
"metadataTitle": "اختيار التاريخ",
4897+
"date": {
4898+
"title": "اختر تاريخًا"
4899+
},
4900+
"datetime": {
4901+
"title": "اختر تاريخًا ووقتًا"
4902+
},
4903+
"noSelection": "اختر تاريخًا",
4904+
"actions": {
4905+
"submit": "إرسال"
4906+
},
4907+
"success": {
4908+
"title": "تم حفظ البيانات",
4909+
"description": "يمكنك الآن إغلاق هذه العلامة."
4910+
},
4911+
"errors": {
4912+
"invalidTokenTitle": "هذا الرابط غير متاح.",
4913+
"invalidTokenDescription": "قد يكون الرابط غير صالح أو منتهي الصلاحية. يُرجى طلب رابط جديد.",
4914+
"submitFailed": "تعذّر إتمام هذا الطلب. يُرجى المحاولة مرة أخرى."
4915+
}
4916+
},
48944917
"appointments": {
48954918
"title": "المواعيد",
48964919
"statuses": {

apps/builder/messages/da.json

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2251,7 +2251,8 @@
22512251
},
22522252
"sendText": {
22532253
"tiktokTitleTruncated": "Når der er tilføjet knapper, sender TikTok kun de første 40 tegn af denne besked — resten bliver klippet væk."
2254-
}
2254+
},
2255+
"dataWasSaved": "Data blev gemt"
22552256
},
22562257
"folders": {
22572258
"heading": {
@@ -4891,6 +4892,28 @@
48914892
"timezoneHint": "Tidspunkter vises i {timezone}."
48924893
}
48934894
},
4895+
"userDataWebview": {
4896+
"metadataTitle": "Vælg dato",
4897+
"date": {
4898+
"title": "Vælg en dato"
4899+
},
4900+
"datetime": {
4901+
"title": "Vælg en dato og et klokkeslæt"
4902+
},
4903+
"noSelection": "Vælg en dato",
4904+
"actions": {
4905+
"submit": "Send"
4906+
},
4907+
"success": {
4908+
"title": "Data blev gemt",
4909+
"description": "Du kan lukke denne fane nu."
4910+
},
4911+
"errors": {
4912+
"invalidTokenTitle": "Dette link er ikke tilgængeligt.",
4913+
"invalidTokenDescription": "Linket kan være ugyldigt eller udløbet. Bed om et nyt link.",
4914+
"submitFailed": "Vi kunne ikke gennemføre denne anmodning. Prøv venligst igen."
4915+
}
4916+
},
48944917
"appointments": {
48954918
"title": "Aftaler",
48964919
"statuses": {

0 commit comments

Comments
 (0)