Skip to content

Commit 0bc792f

Browse files
authored
feat(bot-fields): add bot fields with typed value normalization (#1052)
1 parent 4caf7e8 commit 0bc792f

218 files changed

Lines changed: 13091 additions & 1393 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/builder/__tests__/contact-filter-condition-draft.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ const booleanCustomFieldConfig: FieldConfig = {
8181
group: "customFields",
8282
}
8383

84+
const numberBotFieldConfig: FieldConfig = {
85+
name: "botField:bf-1",
86+
botFieldId: "bf-1",
87+
customFieldType: "number",
88+
formField: formFieldTypes.enum.number,
89+
group: "botFields",
90+
}
91+
8492
const couponTopicConfig: FieldConfig = {
8593
name: "couponTopic:topic-1",
8694
topicId: "topic-1",
@@ -145,6 +153,26 @@ describe("buildConditionDraft", () => {
145153
})
146154
})
147155

156+
test("maps bot field config to dynamic botField condition shape", () => {
157+
expect(
158+
buildConditionDraft(
159+
{
160+
field: "botField:bf-1",
161+
operator: operatorTypes.enum.gt,
162+
value: "10",
163+
},
164+
numberBotFieldConfig,
165+
),
166+
).toEqual({
167+
field: "botField",
168+
botFieldId: "bf-1",
169+
botFieldType: "number",
170+
valueType: formFieldTypes.enum.number,
171+
operator: operatorTypes.enum.gt,
172+
value: "10",
173+
})
174+
})
175+
148176
test("omits empty value for coupon topic used conditions", () => {
149177
expect(
150178
buildConditionDraft(
@@ -345,6 +373,21 @@ describe("round-trip editing", () => {
345373
condition,
346374
)
347375
})
376+
377+
test("keeps a bot field comparison unchanged", () => {
378+
const condition: ContactFilterCondition = {
379+
field: "botField",
380+
botFieldId: "bf-1",
381+
botFieldType: "number",
382+
valueType: formFieldTypes.enum.number,
383+
operator: operatorTypes.enum.gt,
384+
value: "10",
385+
}
386+
387+
expect(roundTripCondition(condition, numberBotFieldConfig)).toEqual(
388+
condition,
389+
)
390+
})
348391
})
349392

350393
describe("getResetDraftForField", () => {
@@ -432,6 +475,21 @@ describe("getConditionOptionsForConfig", () => {
432475
)
433476
})
434477

478+
test("routes bot-field configs through the SAME custom-field condition options helper (reused, not forked)", () => {
479+
expect(
480+
getConditionOptionsForConfig(numberBotFieldConfig, conditionOptions),
481+
).toEqual(
482+
getCustomFieldConditionOptions(numberBotFieldConfig, conditionOptions),
483+
)
484+
// Bot field and custom field of the same underlying type produce
485+
// identical operator availability.
486+
expect(
487+
getConditionOptionsForConfig(numberBotFieldConfig, conditionOptions),
488+
).toEqual(
489+
getConditionOptionsForConfig(numberCustomFieldConfig, conditionOptions),
490+
)
491+
})
492+
435493
test("returns no operator options without a selected field", () => {
436494
expect(getConditionOptionsForConfig(undefined, conditionOptions)).toEqual(
437495
[],
@@ -465,4 +523,12 @@ describe("getDefaultConditionValue", () => {
465523
),
466524
)
467525
})
526+
527+
test("routes bot-field default values through the SAME custom-field defaults helper (reused, not forked)", () => {
528+
expect(
529+
getDefaultConditionValue(numberBotFieldConfig, operatorTypes.enum.gt),
530+
).toEqual(
531+
getDefaultCustomFieldValue(numberBotFieldConfig, operatorTypes.enum.gt),
532+
)
533+
})
468534
})

apps/builder/__tests__/contact-filter-config.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,53 @@ describe("contact filter field config helpers", () => {
184184
)
185185
})
186186

187+
test("omits bot field configs by default (opt-in gating)", () => {
188+
const configs = getFieldConfigs({
189+
t,
190+
tagOptions: [],
191+
inboxOptions: [],
192+
flowVersionOptions: [],
193+
customFields: [],
194+
botFields: [{ id: "bf-1", name: "Greeting", type: "shortText" }],
195+
})
196+
197+
expect(
198+
configs.find((config) => config.name === "botField:bf-1"),
199+
).toBeUndefined()
200+
})
201+
202+
test("maps workspace bot fields to botField configs when includeBotFields is true", () => {
203+
const configs = getFieldConfigs({
204+
t,
205+
tagOptions: [],
206+
inboxOptions: [],
207+
flowVersionOptions: [],
208+
customFields: [],
209+
botFields: [
210+
{ id: "bf-1", name: "Greeting", type: "shortText" },
211+
{ id: "bf-2", name: "Order Count", type: "number" },
212+
],
213+
includeBotFields: true,
214+
})
215+
216+
expect(configs).toContainEqual(
217+
expect.objectContaining({
218+
name: "botField:bf-1",
219+
botFieldId: "bf-1",
220+
customFieldType: "shortText",
221+
label: "Greeting",
222+
formField: formFieldTypes.enum.text,
223+
group: "botFields",
224+
}),
225+
)
226+
expect(configs).toContainEqual(
227+
expect.objectContaining({
228+
name: "botField:bf-2",
229+
formField: formFieldTypes.enum.number,
230+
}),
231+
)
232+
})
233+
187234
test("assigns supported static fields to their configured option groups", () => {
188235
const configs = getFieldConfigs({
189236
t,
@@ -483,6 +530,39 @@ describe("contact filter field config helpers", () => {
483530
)
484531
})
485532

533+
test("renders the Bot Fields group immediately after Custom Fields", () => {
534+
const configs = getFieldConfigs({
535+
t,
536+
tagOptions: [],
537+
inboxOptions: [],
538+
flowVersionOptions: [],
539+
customFields: [{ id: "cf-1", name: "Plan", type: "shortText" }],
540+
botFields: [{ id: "bf-1", name: "Greeting", type: "shortText" }],
541+
includeBotFields: true,
542+
})
543+
544+
const options = getFieldOptions(configs, t)
545+
const groupOrder = options
546+
.filter((opt) => opt.value.startsWith("group-"))
547+
.map((opt) => opt.value)
548+
const customFieldsIndex = groupOrder.indexOf("group-customFields")
549+
const botFieldsIndex = groupOrder.indexOf("group-botFields")
550+
551+
expect(customFieldsIndex).toBeGreaterThanOrEqual(0)
552+
expect(botFieldsIndex).toBe(customFieldsIndex + 1)
553+
expect(options).toContainEqual(
554+
expect.objectContaining({
555+
value: "group-botFields",
556+
children: [
557+
{
558+
label: "Greeting",
559+
value: "botField:bf-1",
560+
},
561+
],
562+
}),
563+
)
564+
})
565+
486566
test("preserves configured field order inside each group", () => {
487567
const configs: FieldConfig[] = [
488568
{

apps/builder/__tests__/contact-filter-schema.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "@chatbotx.io/database/partials"
88
import { describe, expect, test } from "vitest"
99
import {
10+
botFieldConditionSchema,
1011
contactFilterCriteriaSchema,
1112
couponTopicConditionSchema,
1213
customFieldConditionSchema,
@@ -424,6 +425,89 @@ describe("customFieldConditionSchema", () => {
424425
})
425426
})
426427

428+
describe("botFieldConditionSchema", () => {
429+
test.each([
430+
[
431+
"short text search",
432+
{
433+
botFieldType: customFieldTypes.enum.shortText,
434+
valueType: formFieldTypes.enum.text,
435+
operator: operatorTypes.enum.contains,
436+
value: "vip",
437+
},
438+
true,
439+
],
440+
[
441+
"number range",
442+
{
443+
botFieldType: customFieldTypes.enum.number,
444+
valueType: formFieldTypes.enum.number,
445+
operator: operatorTypes.enum.isBetween,
446+
value: ["1", "10"],
447+
},
448+
true,
449+
],
450+
[
451+
"datetime range",
452+
{
453+
botFieldType: customFieldTypes.enum.datetime,
454+
valueType: formFieldTypes.enum.datetime,
455+
operator: operatorTypes.enum.isBetween,
456+
value: ["2026-05-01T00:00:00Z", "2026-05-31T23:59:59Z"],
457+
},
458+
true,
459+
],
460+
[
461+
"boolean valueless",
462+
{
463+
botFieldType: customFieldTypes.enum.boolean,
464+
valueType: formFieldTypes.enum.boolean,
465+
operator: operatorTypes.enum.isNotEmpty,
466+
},
467+
true,
468+
],
469+
[
470+
"long text search disabled",
471+
{
472+
botFieldType: customFieldTypes.enum.longText,
473+
valueType: formFieldTypes.enum.text,
474+
operator: operatorTypes.enum.contains,
475+
value: "vip",
476+
},
477+
false,
478+
],
479+
])("validates %s", (_name, values, expected) => {
480+
const result = botFieldConditionSchema.safeParse({
481+
field: "botField",
482+
botFieldId: "bf-1",
483+
...values,
484+
})
485+
486+
expect(result.success).toBe(expected)
487+
})
488+
489+
test("requires botFieldId and two values for interval operators", () => {
490+
expect(
491+
botFieldConditionSchema.safeParse({
492+
field: "botField",
493+
valueType: formFieldTypes.enum.text,
494+
operator: operatorTypes.enum.eq,
495+
value: "vip",
496+
}).success,
497+
).toBe(false)
498+
499+
expect(
500+
botFieldConditionSchema.safeParse({
501+
field: "botField",
502+
botFieldId: "bf-1",
503+
valueType: formFieldTypes.enum.number,
504+
operator: operatorTypes.enum.isBetween,
505+
value: ["1"],
506+
}).success,
507+
).toBe(false)
508+
})
509+
})
510+
427511
describe("contact filter union schemas", () => {
428512
test("accepts static and custom field conditions", () => {
429513
expect(
@@ -445,6 +529,18 @@ describe("contact filter union schemas", () => {
445529
).toBe(true)
446530
})
447531

532+
test("accepts bot field conditions", () => {
533+
expect(
534+
singleContactFilterConditionSchema.safeParse({
535+
field: "botField",
536+
botFieldId: "bf-1",
537+
valueType: formFieldTypes.enum.text,
538+
operator: operatorTypes.enum.eq,
539+
value: "vip",
540+
}).success,
541+
).toBe(true)
542+
})
543+
448544
test("rejects bogus public field names", () => {
449545
expect(
450546
contactFilterCriteriaSchema.safeParse({

apps/builder/__tests__/contact-filter-value-inputs.test.tsx

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,13 @@ const configs = {
221221
formField: formFieldTypes.enum.text,
222222
group: "topicCoupon",
223223
},
224+
customBoolean: {
225+
name: "customField:cf-1",
226+
customFieldId: "cf-1",
227+
customFieldType: "boolean",
228+
formField: formFieldTypes.enum.boolean,
229+
group: "customFields",
230+
},
224231
} as const satisfies Record<string, FieldConfig>
225232

226233
let container: HTMLDivElement
@@ -312,13 +319,68 @@ describe("ContactFilterConditionDialog value inputs", () => {
312319
expect(container.querySelector('[data-testid="input-value"]')).toBeNull()
313320
})
314321

322+
// Custom/bot boolean fields mirror the Set Custom Field step: free text
323+
// input (picker opens on click) instead of the static-field select.
324+
test("renders a free text input for a custom boolean field", () => {
325+
renderDialog({ config: configs.customBoolean, value: "true" })
326+
327+
expect(
328+
container.querySelector('[data-testid="input-value"]'),
329+
).not.toBeNull()
330+
expect(container.querySelector('[data-testid="select-value"]')).toBeNull()
331+
})
332+
333+
test("canonicalizes a typed boolean literal to true/false on submit", async () => {
334+
const { onSubmit } = renderDialog({
335+
config: configs.customBoolean,
336+
value: "Yes",
337+
})
338+
339+
act(() => {
340+
container
341+
.querySelector("form")
342+
?.dispatchEvent(
343+
new SubmitEvent("submit", { bubbles: true, cancelable: true }),
344+
)
345+
})
346+
347+
await vi.waitFor(() => {
348+
expect(onSubmit).toHaveBeenCalledWith(
349+
expect.objectContaining({ value: "true" }),
350+
)
351+
})
352+
})
353+
354+
test("disables save for an unrecognized boolean value", () => {
355+
const { onSubmit } = renderDialog({
356+
config: configs.customBoolean,
357+
value: "maybe",
358+
})
359+
360+
expect(
361+
(
362+
container.querySelector(
363+
'button[type="submit"]',
364+
) as HTMLButtonElement | null
365+
)?.disabled,
366+
).toBe(true)
367+
act(() => {
368+
container
369+
.querySelector("form")
370+
?.dispatchEvent(
371+
new SubmitEvent("submit", { bubbles: true, cancelable: true }),
372+
)
373+
})
374+
expect(onSubmit).not.toHaveBeenCalled()
375+
})
376+
315377
test("keeps boolean and option fields on select controls", () => {
316378
renderDialog({ config: configs.boolean, value: "true" })
317379
expect(
318380
container.querySelector('[data-testid="select-value"]'),
319381
).not.toBeNull()
320-
expect(container.textContent).toContain("Yes")
321-
expect(container.textContent).toContain("No")
382+
expect(container.textContent).toContain("fields.boolean.true")
383+
expect(container.textContent).toContain("fields.boolean.false")
322384

323385
renderDialog({ config: configs.select, value: "female" })
324386
expect(

0 commit comments

Comments
 (0)