diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/ai/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/ai/page.tsx index 8004ebfeb81c..4840b4f9575c 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/ai/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/ai/page.tsx @@ -5,6 +5,7 @@ import { CreateSurveyWithAIOnboarding } from "@/app/(app)/(onboarding)/organizat import { DEFAULT_LOCALE } from "@/lib/constants"; import { getUserLocale } from "@/lib/user/service"; import { getOrganizationAuth } from "@/modules/organization/lib/utils"; +import { TemplateCreateQueryClientProvider } from "@/modules/survey/components/template-list/query-client-provider"; import { getSurveyAIAvailability } from "@/modules/survey/lib/get-survey-ai-availability"; interface AIOnboardingPageProps { @@ -42,7 +43,9 @@ const Page = async (props: AIOnboardingPageProps) => { return (
What is your score?
" } }] }, + { id: "b2", elements: [{ id: "q2", headline: { en: "Nested #recall:q1/fallback:there#" } }] }, + ], + variables: [{ id: "v1", name: "plan" }], + hiddenFields: { enabled: true, fieldIds: ["userId"] }, + endings: [ + { id: "end-1", type: "endScreen", headline: { en: "Thanks! Score: #recall:q1/fallback:none#" } }, + // target deleted -> fallback text, never the raw cuid + { id: "end-2", type: "endScreen", headline: { en: "Bye #recall:gone/fallback:friend#" } }, + { id: "end-3", type: "endScreen", headline: { en: "Plan #recall:v1/fallback:free#" } }, + { id: "end-4", type: "endScreen", headline: { en: "User #recall:userId/fallback:anon#" } }, + // recalling an element whose own headline recalls -> inner token blanked, not looped on + { id: "end-5", type: "endScreen", headline: { en: "Q2: #recall:q2/fallback:x#" } }, + // no recall -> unchanged + { id: "end-6", type: "endScreen", headline: { en: "Plain thanks" } }, + ], + }, + }) + ); + + const { result } = renderHook(() => useWorkflowSurveyEndings("survey_1"), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.endings).toEqual([ + { id: "end-1", label: "Thanks! Score: @What is your score?" }, + { id: "end-2", label: "Bye friend" }, + { id: "end-3", label: "Plan @plan" }, + { id: "end-4", label: "User @userId" }, + { id: "end-5", label: "Q2: @Nested ___" }, + { id: "end-6", label: "Plain thanks" }, + ]); + }); + + // The three recall sources are separate maps merged into one, so a shared id is resolved by merge + // order alone. Distinct ids in the fixture above cannot catch a reordering; these collisions can. + test("resolves a colliding recall id by source precedence: hidden field > element > variable", async () => { + const fetchMock = vi.mocked(global.fetch); + fetchMock.mockResolvedValueOnce( + jsonResponse({ + data: { + defaultLanguage: "en", + blocks: [ + { + id: "b1", + elements: [ + { id: "shared_qv", headline: { en: "Element headline" } }, + { id: "shared_qh", headline: { en: "Element loses to hidden field" } }, + ], + }, + ], + variables: [ + { id: "shared_qv", name: "variable name" }, + { id: "shared_vh", name: "variable loses to hidden field" }, + ], + hiddenFields: { enabled: true, fieldIds: ["shared_qh", "shared_vh"] }, + endings: [ + { id: "end-1", type: "endScreen", headline: { en: "A #recall:shared_qv/fallback:x#" } }, + { id: "end-2", type: "endScreen", headline: { en: "B #recall:shared_qh/fallback:x#" } }, + { id: "end-3", type: "endScreen", headline: { en: "C #recall:shared_vh/fallback:x#" } }, + ], + }, + }) + ); + + const { result } = renderHook(() => useWorkflowSurveyEndings("survey_1"), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.endings).toEqual([ + // element over variable + { id: "end-1", label: "A @Element headline" }, + // hidden field (which recalls as its own id) over element + { id: "end-2", label: "B @shared_qh" }, + // hidden field over variable + { id: "end-3", label: "C @shared_vh" }, + ]); + }); + + test("falls back to the ending id when a dangling recall leaves the headline empty", async () => { + const fetchMock = vi.mocked(global.fetch); + fetchMock.mockResolvedValueOnce( + jsonResponse({ + data: { + defaultLanguage: "en", + endings: [{ id: "end-1", type: "endScreen", headline: { en: "#recall:gone/fallback:#" } }], + }, + }) + ); + + const { result } = renderHook(() => useWorkflowSurveyEndings("survey_1"), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.endings).toEqual([{ id: "end-1", label: "end-1" }]); + }); + // Errors rather than resolving to []: an empty success is indistinguishable from "every ending // was deleted", and callers that prune stored ids against this list would delete a valid // selection. Landing in the error branch keeps them on the "leave it alone" path. diff --git a/apps/web/modules/ee/workflows/hooks/use-trigger-survey-picker.ts b/apps/web/modules/ee/workflows/hooks/use-trigger-survey-picker.ts index 8938ba02f000..44c44fd9d2b5 100644 --- a/apps/web/modules/ee/workflows/hooks/use-trigger-survey-picker.ts +++ b/apps/web/modules/ee/workflows/hooks/use-trigger-survey-picker.ts @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { getTextContent } from "@formbricks/types/surveys/validation"; +import { extractFallbackValue, extractId, extractRecallInfo } from "@/lib/utils/recall"; import { parseV3ApiError } from "@/modules/api/lib/v3-client"; import { initialFilters } from "@/modules/survey/list/lib/constants"; import { listSurveys } from "@/modules/survey/list/lib/v3-surveys-client"; @@ -42,12 +43,108 @@ const pickDefaultLanguageString = (value: unknown, defaultLanguage: string): str return null; }; -const endingDisplayLabel = (raw: RawEnding, defaultLanguage: string): string => { +/** + * What each recall-able id in the survey renders as: element headlines, variable names, and hidden + * field ids — the same three sources `getRecallItemLabel` resolves against in the survey editor. + * Built from the v3 payload rather than reusing `recallToHeadline`, which expects the canonical + * `TSurvey` i18n shape (`headline.default`) and not the language-keyed map v3 serializes. + */ +type RecallLabels = Record+ {typeName ?? "\u00a0"} + {optionCount ? ` · ${optionCount}` : ""} +
+{draft.name}
+ ) : ( ++ {t("workspace.surveys.ai_create.your_prompt")}: + {/* + The prompt this draft came from, not the one being typed. Edit prompt keeps the draft, so + the live text would label an old draft with words that had no part in producing it. + */} + {submittedPrompt} +
+