feat: Add hiddenFields to app & website surveys (#2628)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Piyush Gupta
2024-06-04 16:49:47 +05:30
committed by GitHub
parent 681c559c79
commit bbfdba7615
23 changed files with 216 additions and 61 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -0,0 +1,79 @@
import { MdxImage } from "@/components/MdxImage";
import FilledHiddenFields from "./filled-hidden-fields.webp";
import HiddenFieldResponses from "./hidden-field-responses.webp";
import HiddenFields from "./hidden-fields.webp";
import InputHiddenFields from "./input-hidden-fields.webp";
export const metadata = {
title: "Hidden Fields",
description: "Add hidden fields to your surveys to capture additional data without requiring user inputs!",
};
# Hidden Fields
Hidden fields are a powerful feature in Formbricks that allows you to add data to a submission without asking the user to type it in. This feature is especially useful when you already have information about a user that you want to use in the analysis of the survey results (e.g. `payment plan` or `email`)
<Note>Hidden fields are now available in the Formbricks in-app and website surveys as well</Note>
## How to Add Hidden Fields
### Enable them in the Survey Builder
1. Edit the survey you want to add hidden fields to & switch to the Questions tab and scroll down to the bottom of the page. You will see a section called **Hidden Fields**. Make sure to enable it by toggling the switch.
<MdxImage
src={HiddenFields}
alt="Enable Hidden Fields"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
2. Now click on it to add a new hidden field ID. You can add as many hidden fields as you want.
<MdxImage
src={InputHiddenFields}
alt="Add Hidden Fields"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
<MdxImage
src={FilledHiddenFields}
alt="Filled Hidden Fields"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
### Set Hidden Field
<Col>
<CodeGroup title="Example Screen from which the User filled it">
```sh
formbricks.track("my event", {
hiddenFields: {
screen: "landing_page",
job: "Founder"
},
});
```
</CodeGroup>
</Col>
## View Hidden Fields in Responses
These hidden fields will now be visible in the responses tab just like other fields in the Summary as well as the Response Cards, and you can use them to filter and analyze your responses.
<MdxImage
src={HiddenFieldResponses}
alt="Hidden Field Responses"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
## Use Cases
- **User Metadata**: You can add hidden fields to capture user metadata such as user ID, email, or any other user-specific information.
- **Survey Metadata**: You can add hidden fields to capture other metadata, e.g. the screen from which the survey was filled, or any other app specific information.

View File

@@ -4,7 +4,6 @@ import FilledHiddenFields from "./filled-hidden-fields.webp";
import HiddenFieldResponses from "./hidden-field-responses.webp";
import HiddenFields from "./hidden-fields.webp";
import InputHiddenFields from "./input-hidden-fields.webp";
import SettingsPage from "./settings.webp";
export const metadata = {
title: "Hidden Fields",
@@ -21,16 +20,7 @@ Hidden fields are a powerful feature in Formbricks that allows you to add data t
### Enable them in the Survey Builder
1. Edit the survey you want to add hidden fields to & open it's settings, make sure it's selected as a **Link Survey**.
<MdxImage
src={SettingsPage}
alt="Select the Survey Type as Link Survey"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
2. Switch to the Questions tab and scroll down to the bottom of the page. You will see a section called **Hidden Fields**. Make sure to enable it by toggling the switch.
1. Edit the survey you want to add hidden fields to & switch to the Questions tab and scroll down to the bottom of the page. You will see a section called **Hidden Fields**. Make sure to enable it by toggling the switch.
<MdxImage
src={HiddenFields}
@@ -39,7 +29,7 @@ Hidden fields are a powerful feature in Formbricks that allows you to add data t
className="max-w-full rounded-lg sm:max-w-3xl"
/>
3. Now click on it to add a new hidden field ID. You can add as many hidden fields as you want.
2. Now click on it to add a new hidden field ID. You can add as many hidden fields as you want.
<MdxImage
src={InputHiddenFields}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -35,6 +35,7 @@ export const navigation: Array<NavGroup> = [
{ title: "Advanced Targeting", href: "/app-surveys/advanced-targeting" },
{ title: "Show Survey to % of users", href: "/global/show-survey-to-percent-of-users" }, // app and website
{ title: "Recontact Options", href: "/app-surveys/recontact" },
{ title: "Hidden Fields", href: "/global/hidden-fields" }, // global
{ title: "Multi Language Surveys", href: "/global/multi-language-surveys" }, // global
{ title: "User Metadata", href: "/global/metadata" }, // global
{ title: "Custom Styling", href: "/global/overwrite-styling" }, // global
@@ -57,6 +58,7 @@ export const navigation: Array<NavGroup> = [
{ title: "Actions & Targeting", href: "/website-surveys/actions-and-targeting" },
{ title: "Show Survey to % of users", href: "/global/show-survey-to-percent-of-users" }, // app and website
{ title: "Recontact Options", href: "/app-surveys/recontact" },
{ title: "Hidden Fields", href: "/global/hidden-fields" }, // global
{ title: "Multi Language Surveys", href: "/global/multi-language-surveys" }, // global
{ title: "User Metadata", href: "/global/metadata" }, // global
{ title: "Custom Styling", href: "/global/overwrite-styling" }, // global

View File

@@ -383,14 +383,12 @@ export const QuestionsView = ({
attributeClasses={attributeClasses}
/>
{localSurvey.type === "link" ? (
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveQuestionId={setActiveQuestionId}
activeQuestionId={activeQuestionId}
/>
) : null}
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveQuestionId={setActiveQuestionId}
activeQuestionId={activeQuestionId}
/>
<MultiLanguageCard
localSurvey={localSurvey}

View File

@@ -11,7 +11,7 @@ import { ResponseQueue } from "@formbricks/lib/responseQueue";
import { SurveyState } from "@formbricks/lib/surveyState";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TProduct } from "@formbricks/types/product";
import { TResponse, TResponseData, TResponseUpdate } from "@formbricks/types/responses";
import { TResponse, TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { TSurvey } from "@formbricks/types/surveys";
import { ClientLogo } from "@formbricks/ui/ClientLogo";
@@ -123,20 +123,17 @@ export const LinkSurvey = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hiddenFieldsRecord = useMemo<TResponseData | undefined>(() => {
const fieldsRecord: TResponseData = {};
let fieldsSet = false;
const hiddenFieldsRecord = useMemo<TResponseHiddenFieldValue>(() => {
const fieldsRecord: TResponseHiddenFieldValue = {};
survey.hiddenFields?.fieldIds?.forEach((field) => {
const answer = searchParams?.get(field);
if (answer) {
fieldsRecord[field] = answer;
fieldsSet = true;
}
});
// Only return the record if at least one field was set.
return fieldsSet ? fieldsRecord : undefined;
return fieldsRecord;
}, [searchParams, survey.hiddenFields?.fieldIds]);
const getVerifiedEmail = useMemo<Record<string, string> | null>(() => {
@@ -255,7 +252,6 @@ export const LinkSurvey = ({
responseQueue.add({
data: {
...responseUpdate.data,
...hiddenFieldsRecord,
...getVerifiedEmail,
},
ttc: responseUpdate.ttc,
@@ -266,6 +262,7 @@ export const LinkSurvey = ({
url: window.location.href,
source: sourceParam || "",
},
...(Object.keys(hiddenFieldsRecord).length > 0 && { hiddenFields: hiddenFieldsRecord }),
});
}}
onFileUpload={async (file: File, params: TUploadFileConfig) => {
@@ -285,7 +282,6 @@ export const LinkSurvey = ({
setQuestionId = f;
}}
startAtQuestionId={startAt && isStartAtValid ? startAt : undefined}
hiddenFieldsRecord={hiddenFieldsRecord}
/>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { TJsAppConfigInput } from "@formbricks/types/js";
import { TJsAppConfigInput, TJsTrackProperties } from "@formbricks/types/js";
import { CommandQueue } from "../shared/commandQueue";
import { ErrorHandler } from "../shared/errors";
@@ -41,7 +41,7 @@ const reset = async (): Promise<void> => {
await queue.wait();
};
const track = async (name: string, properties: any = {}): Promise<void> => {
const track = async (name: string, properties?: TJsTrackProperties): Promise<void> => {
queue.add<any>(true, "app", trackCodeAction, name, properties);
await queue.wait();
};

View File

@@ -1,5 +1,5 @@
import { FormbricksAPI } from "@formbricks/api";
import { TJsActionInput } from "@formbricks/types/js";
import { TJsActionInput, TJsTrackProperties } from "@formbricks/types/js";
import { InvalidCodeError, NetworkError, Result, err, okVoid } from "../../shared/errors";
import { Logger } from "../../shared/logger";
@@ -13,7 +13,11 @@ const inAppConfig = AppConfig.getInstance();
const intentsToNotCreateOnApp = ["Exit Intent (Desktop)", "50% Scroll"];
export const trackAction = async (name: string, alias?: string): Promise<Result<void, NetworkError>> => {
export const trackAction = async (
name: string,
alias?: string,
properties?: TJsTrackProperties
): Promise<Result<void, NetworkError>> => {
const aliasName = alias || name;
const { userId } = inAppConfig.get();
@@ -71,7 +75,7 @@ export const trackAction = async (name: string, alias?: string): Promise<Result<
for (const survey of activeSurveys) {
for (const trigger of survey.triggers) {
if (trigger.actionClass.name === name) {
await triggerSurvey(survey, name);
await triggerSurvey(survey, name, properties);
}
}
}
@@ -83,7 +87,8 @@ export const trackAction = async (name: string, alias?: string): Promise<Result<
};
export const trackCodeAction = (
code: string
code: string,
properties?: TJsTrackProperties
): Promise<Result<void, NetworkError>> | Result<void, InvalidCodeError> => {
const {
state: { actionClasses = [] },
@@ -99,7 +104,7 @@ export const trackCodeAction = (
});
}
return trackAction(action.name, code);
return trackAction(action.name, code, properties);
};
export const trackNoCodeAction = (name: string): Promise<Result<void, NetworkError>> => {

View File

@@ -2,12 +2,13 @@ import { FormbricksAPI } from "@formbricks/api";
import { ResponseQueue } from "@formbricks/lib/responseQueue";
import { SurveyState } from "@formbricks/lib/surveyState";
import { getStyling } from "@formbricks/lib/utils/styling";
import { TResponseUpdate } from "@formbricks/types/responses";
import { TJsTrackProperties } from "@formbricks/types/js";
import { TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { ErrorHandler } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { getDefaultLanguageCode, getLanguageCode } from "../../shared/utils";
import { getDefaultLanguageCode, getLanguageCode, handleHiddenFields } from "../../shared/utils";
import { AppConfig } from "./config";
import { putFormbricksInErrorState } from "./initialize";
import { sync } from "./sync";
@@ -30,7 +31,11 @@ const shouldDisplayBasedOnPercentage = (displayPercentage: number) => {
return randomNum <= displayPercentage;
};
export const triggerSurvey = async (survey: TSurvey, action?: string): Promise<void> => {
export const triggerSurvey = async (
survey: TSurvey,
action?: string,
properties?: TJsTrackProperties
): Promise<void> => {
// Check if the survey should be displayed based on displayPercentage
if (survey.displayPercentage) {
const shouldDisplaySurvey = shouldDisplayBasedOnPercentage(survey.displayPercentage);
@@ -39,10 +44,20 @@ export const triggerSurvey = async (survey: TSurvey, action?: string): Promise<v
return; // skip displaying the survey
}
}
await renderWidget(survey, action);
const hiddenFieldsObject: TResponseHiddenFieldValue = handleHiddenFields(
survey.hiddenFields,
properties?.hiddenFields
);
await renderWidget(survey, action, hiddenFieldsObject);
};
const renderWidget = async (survey: TSurvey, action?: string) => {
const renderWidget = async (
survey: TSurvey,
action?: string,
hiddenFields: TResponseHiddenFieldValue = {}
) => {
if (isSurveyRunning) {
logger.debug("A survey is already running. Skipping.");
return;
@@ -95,8 +110,8 @@ const renderWidget = async (survey: TSurvey, action?: string) => {
setTimeout(() => {
formbricksSurveys.renderSurveyModal({
survey: survey,
isBrandingEnabled: isBrandingEnabled,
survey,
isBrandingEnabled,
clickOutside,
darkOverlay,
languageCode,
@@ -144,6 +159,7 @@ const renderWidget = async (survey: TSurvey, action?: string) => {
url: window.location.href,
action,
},
hiddenFields,
});
},
onClose: closeSurvey,

View File

@@ -1,6 +1,12 @@
import { TAttributes } from "@formbricks/types/attributes";
import { TJsTrackProperties } from "@formbricks/types/js";
import { TResponseHiddenFieldValue } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { Logger } from "../shared/logger";
const logger = Logger.getInstance();
export const getIsDebug = () => window.location.search.includes("formbricksDebug=true");
export const getLanguageCode = (survey: TSurvey, attributes: TAttributes): string | undefined => {
@@ -34,3 +40,34 @@ export const getDefaultLanguageCode = (survey: TSurvey) => {
});
if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code;
};
export const handleHiddenFields = (
hiddenFieldsConfig: TSurvey["hiddenFields"],
hiddenFields: TJsTrackProperties["hiddenFields"]
): TResponseHiddenFieldValue => {
const { enabled: enabledHiddenFields, fieldIds: hiddenFieldIds } = hiddenFieldsConfig || {};
let hiddenFieldsObject: TResponseHiddenFieldValue = {};
if (!enabledHiddenFields) {
logger.error("Hidden fields are not enabled for this survey");
} else if (hiddenFieldIds && hiddenFields) {
const unknownHiddenFields: string[] = [];
hiddenFieldsObject = Object.keys(hiddenFields).reduce((acc, key) => {
if (hiddenFieldIds?.includes(key)) {
acc[key] = hiddenFields?.[key];
} else {
unknownHiddenFields.push(key);
}
return acc;
}, {} as TResponseHiddenFieldValue);
if (unknownHiddenFields.length > 0) {
logger.error(
`Unknown hidden fields: ${unknownHiddenFields.join(", ")}. Please add them to the survey hidden fields.`
);
}
}
return hiddenFieldsObject;
};

View File

@@ -1,4 +1,4 @@
import { TJsWebsiteConfigInput } from "@formbricks/types/js";
import { TJsTrackProperties, TJsWebsiteConfigInput } from "@formbricks/types/js";
// Shared imports
import { CommandQueue } from "../shared/commandQueue";
@@ -26,7 +26,7 @@ const reset = async (): Promise<void> => {
await queue.wait();
};
const track = async (name: string, properties: any = {}): Promise<void> => {
const track = async (name: string, properties?: TJsTrackProperties): Promise<void> => {
queue.add<any>(true, "website", trackCodeAction, name, properties);
await queue.wait();
};

View File

@@ -1,3 +1,5 @@
import { TJsTrackProperties } from "@formbricks/types/js";
import { InvalidCodeError, NetworkError, Result, err, okVoid } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { WebsiteConfig } from "./config";
@@ -6,7 +8,11 @@ import { triggerSurvey } from "./widget";
const logger = Logger.getInstance();
const websiteConfig = WebsiteConfig.getInstance();
export const trackAction = async (name: string, alias?: string): Promise<Result<void, NetworkError>> => {
export const trackAction = async (
name: string,
alias?: string,
properties?: TJsTrackProperties
): Promise<Result<void, NetworkError>> => {
const aliasName = alias || name;
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
@@ -17,7 +23,7 @@ export const trackAction = async (name: string, alias?: string): Promise<Result<
for (const survey of activeSurveys) {
for (const trigger of survey.triggers) {
if (trigger.actionClass.name === name) {
await triggerSurvey(survey, name);
await triggerSurvey(survey, name, properties);
}
}
}
@@ -29,7 +35,8 @@ export const trackAction = async (name: string, alias?: string): Promise<Result<
};
export const trackCodeAction = (
code: string
code: string,
properties?: TJsTrackProperties
): Promise<Result<void, NetworkError>> | Result<void, InvalidCodeError> => {
const {
state: { actionClasses = [] },
@@ -45,7 +52,7 @@ export const trackCodeAction = (
});
}
return trackAction(action.name, code);
return trackAction(action.name, code, properties);
};
export const trackNoCodeAction = (name: string): Promise<Result<void, NetworkError>> => {

View File

@@ -2,12 +2,12 @@ import { FormbricksAPI } from "@formbricks/api";
import { ResponseQueue } from "@formbricks/lib/responseQueue";
import { SurveyState } from "@formbricks/lib/surveyState";
import { getStyling } from "@formbricks/lib/utils/styling";
import { TJSWebsiteStateDisplay } from "@formbricks/types/js";
import { TResponseUpdate } from "@formbricks/types/responses";
import { TJSWebsiteStateDisplay, TJsTrackProperties } from "@formbricks/types/js";
import { TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { Logger } from "../../shared/logger";
import { getDefaultLanguageCode, getLanguageCode } from "../../shared/utils";
import { getDefaultLanguageCode, getLanguageCode, handleHiddenFields } from "../../shared/utils";
import { WebsiteConfig } from "./config";
import { filterPublicSurveys } from "./sync";
@@ -29,7 +29,11 @@ const shouldDisplayBasedOnPercentage = (displayPercentage: number) => {
return randomNum <= displayPercentage;
};
export const triggerSurvey = async (survey: TSurvey, action?: string): Promise<void> => {
export const triggerSurvey = async (
survey: TSurvey,
action?: string,
properties?: TJsTrackProperties
): Promise<void> => {
// Check if the survey should be displayed based on displayPercentage
if (survey.displayPercentage) {
const shouldDisplaySurvey = shouldDisplayBasedOnPercentage(survey.displayPercentage);
@@ -38,10 +42,20 @@ export const triggerSurvey = async (survey: TSurvey, action?: string): Promise<v
return; // skip displaying the survey
}
}
await renderWidget(survey, action);
const hiddenFieldsObject: TResponseHiddenFieldValue = handleHiddenFields(
survey.hiddenFields,
properties?.hiddenFields
);
await renderWidget(survey, action, hiddenFieldsObject);
};
const renderWidget = async (survey: TSurvey, action?: string) => {
const renderWidget = async (
survey: TSurvey,
action?: string,
hiddenFields: TResponseHiddenFieldValue = {}
) => {
if (isSurveyRunning) {
logger.debug("A survey is already running. Skipping.");
return;
@@ -94,8 +108,8 @@ const renderWidget = async (survey: TSurvey, action?: string) => {
setTimeout(() => {
formbricksSurveys.renderSurveyModal({
survey: survey,
isBrandingEnabled: isBrandingEnabled,
survey,
isBrandingEnabled,
clickOutside,
darkOverlay,
languageCode,
@@ -175,6 +189,7 @@ const renderWidget = async (survey: TSurvey, action?: string) => {
url: window.location.href,
action,
},
hiddenFields,
});
},
onClose: closeSurvey,

View File

@@ -87,6 +87,7 @@ export class ResponseQueue {
surveyId: this.surveyState.surveyId,
userId: this.surveyState.userId || null,
singleUseId: this.surveyState.singleUseId || null,
data: { ...responseUpdate.data, ...responseUpdate.hiddenFields },
});
if (!response.ok) {
throw new Error("Could not create response");

View File

@@ -35,7 +35,6 @@ export const Survey = ({
onFileUpload,
responseCount,
startAtQuestionId,
hiddenFieldsRecord,
clickOutside,
shouldResetQuestionId,
}: SurveyBaseProps) => {
@@ -58,7 +57,7 @@ export const Survey = ({
const [loadingElement, setLoadingElement] = useState(false);
const [history, setHistory] = useState<string[]>([]);
const [responseData, setResponseData] = useState<TResponseData>(hiddenFieldsRecord ?? {});
const [responseData, setResponseData] = useState<TResponseData>({});
const [ttc, setTtc] = useState<TResponseTtc>({});
const cardArrangement = useMemo(() => {
if (survey.type === "link") {

View File

@@ -24,7 +24,6 @@ export interface SurveyBaseProps {
responseCount?: number;
isCardBorderVisible?: boolean;
startAtQuestionId?: string;
hiddenFieldsRecord?: TResponseData;
clickOutside?: boolean;
shouldResetQuestionId?: boolean;
}

View File

@@ -5,6 +5,7 @@ import { ZActionClass } from "./actionClasses";
import { ZAttributes } from "./attributes";
import { ZPerson } from "./people";
import { ZProduct } from "./product";
import { ZResponseHiddenFieldValue } from "./responses";
import { ZSurvey } from "./surveys";
export const ZJsPerson = z.object({
@@ -252,3 +253,9 @@ export type TSettings = z.infer<typeof ZJsSettings>;
export const ZJsPackageType = z.union([z.literal("app"), z.literal("website")]);
export type TJsPackageType = z.infer<typeof ZJsPackageType>;
export const ZJsTrackProperties = z.object({
hiddenFields: ZResponseHiddenFieldValue.optional(),
});
export type TJsTrackProperties = z.infer<typeof ZJsTrackProperties>;

View File

@@ -292,6 +292,9 @@ export const ZResponseWithSurvey = ZResponse.extend({
export type TResponseWithSurvey = z.infer<typeof ZResponseWithSurvey>;
export const ZResponseHiddenFieldValue = z.record(z.union([z.string(), z.number(), z.array(z.string())]));
export type TResponseHiddenFieldValue = z.infer<typeof ZResponseHiddenFieldValue>;
export const ZResponseUpdate = z.object({
finished: z.boolean(),
data: ZResponseData,
@@ -304,6 +307,7 @@ export const ZResponseUpdate = z.object({
action: z.string().optional(),
})
.optional(),
hiddenFields: ZResponseHiddenFieldValue.optional(),
});
export type TResponseUpdate = z.infer<typeof ZResponseUpdate>;

View File

@@ -226,7 +226,7 @@ export const PreviewSurvey = ({
: "expanded_with_fixed_positioning"
: "shrink"
}
className="relative flex h-[95] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
className="relative flex h-[95%] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
{previewMode === "mobile" && (
<>
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">