diff --git a/apps/web/modules/ee/contacts/lib/contact-attributes.ts b/apps/web/modules/ee/contacts/lib/contact-attributes.ts index 7665a0baa4..96ebbf61a6 100644 --- a/apps/web/modules/ee/contacts/lib/contact-attributes.ts +++ b/apps/web/modules/ee/contacts/lib/contact-attributes.ts @@ -3,6 +3,7 @@ import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; import { ZId, ZString } from "@formbricks/types/common"; import { TContactAttributes } from "@formbricks/types/contact-attribute"; +import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key"; import { DatabaseError } from "@formbricks/types/errors"; import { ZUserEmail } from "@formbricks/types/user"; import { validateInputs } from "@/lib/utils/validate"; @@ -129,3 +130,62 @@ export const hasUserIdAttribute = reactCache( return !!contactAttribute; } ); + +export const getDistinctAttributeValues = reactCache( + async (attributeKeyId: string, dataType: TContactAttributeDataType, limit: number = 50) => { + validateInputs([attributeKeyId, ZId]); + + try { + // Determine which column to query based on data type + let selectField: "value" | "valueNumber" | "valueDate"; + if (dataType === "number") { + selectField = "valueNumber"; + } else if (dataType === "date") { + selectField = "valueDate"; + } else { + selectField = "value"; + } + + // Build where clause - only filter by attributeKeyId, we'll filter nulls in JS + const results = await prisma.contactAttribute.findMany({ + where: { + attributeKeyId, + }, + select: { + value: true, + valueNumber: true, + valueDate: true, + }, + distinct: [selectField as any], + take: limit * 2, // Get more than needed to account for filtering + orderBy: { [selectField]: "asc" }, + }); + + // Extract and filter values based on data type + let values: any[]; + if (dataType === "number") { + values = results + .map((r) => r.valueNumber) + .filter((v) => v !== null && v !== undefined) + .slice(0, limit); + } else if (dataType === "date") { + values = results + .map((r) => r.valueDate) + .filter((v) => v !== null && v !== undefined) + .slice(0, limit); + } else { + values = results + .map((r) => r.value) + .filter((v) => v !== null && v !== undefined && v !== "") + .slice(0, limit); + } + + return values; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } + } +); diff --git a/apps/web/modules/ee/contacts/segments/actions.ts b/apps/web/modules/ee/contacts/segments/actions.ts index 82725f9a4a..ef5ac3a12b 100644 --- a/apps/web/modules/ee/contacts/segments/actions.ts +++ b/apps/web/modules/ee/contacts/segments/actions.ts @@ -20,6 +20,7 @@ import { getProjectIdFromSurveyId, } from "@/lib/utils/helper"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; +import { getDistinctAttributeValues } from "@/modules/ee/contacts/lib/contact-attributes"; import { checkForRecursiveSegmentFilter } from "@/modules/ee/contacts/segments/lib/helper"; import { cloneSegment, @@ -310,3 +311,31 @@ export const resetSegmentFiltersAction = authenticatedActionClient.schema(ZReset } ) ); + +const ZGetDistinctAttributeValuesAction = z.object({ + environmentId: ZId, + attributeKeyId: ZId, + dataType: z.enum(["string", "number", "date"]), +}); + +export const getDistinctAttributeValuesAction = authenticatedActionClient + .schema(ZGetDistinctAttributeValuesAction) + .action(async ({ ctx, parsedInput }) => { + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "projectTeam", + minPermission: "read", + projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId), + }, + ], + }); + + return await getDistinctAttributeValues(parsedInput.attributeKeyId, parsedInput.dataType); + }); diff --git a/apps/web/modules/ee/contacts/segments/components/attribute-value-input.tsx b/apps/web/modules/ee/contacts/segments/components/attribute-value-input.tsx new file mode 100644 index 0000000000..ffb6b7990c --- /dev/null +++ b/apps/web/modules/ee/contacts/segments/components/attribute-value-input.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key"; +import { cn } from "@/lib/cn"; +import { InputCombobox, TComboboxOption } from "@/modules/ui/components/input-combo-box"; +import { getDistinctAttributeValuesAction } from "../actions"; + +interface AttributeValueInputProps { + attributeKeyId: string; + environmentId: string; + dataType: TContactAttributeDataType; + value: string | number; + onChange: (value: string | number) => void; + disabled?: boolean; + className?: string; + valueError?: string; +} + +export const AttributeValueInput = ({ + attributeKeyId, + environmentId, + dataType, + value, + onChange, + disabled, + className, + valueError, +}: AttributeValueInputProps) => { + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + + // Fetch on mount or when key params change + useEffect(() => { + // Don't fetch if we don't have a valid attributeKeyId + if (!attributeKeyId || attributeKeyId === "") { + return; + } + + let isCancelled = false; + + const fetchDistinctValues = async () => { + setLoading(true); + try { + const result = await getDistinctAttributeValuesAction({ + environmentId, + attributeKeyId, + dataType, + }); + + if (!isCancelled && result?.data) { + const comboboxOptions: TComboboxOption[] = result.data.map((val) => ({ + label: String(val), + value: val, + })); + setOptions(comboboxOptions); + } + } catch (error) { + if (!isCancelled) { + console.error("Failed to fetch attribute values:", error); + } + } finally { + if (!isCancelled) { + setLoading(false); + } + } + }; + + fetchDistinctValues(); + + return () => { + isCancelled = true; + }; + }, [environmentId, attributeKeyId, dataType]); + + const emptyDropdownText = useMemo(() => { + if (loading) return "Loading values..."; + if (options.length === 50) return "Showing first 50 values"; + return "No values found"; + }, [loading, options.length]); + + return ( +
+ onChange(newValue as string | number)} + withInput={true} + showSearch={true} + clearable={true} + inputProps={{ + className: cn( + "h-9 w-auto bg-white", + valueError && "border border-red-500 focus:border-red-500", + className + ), + disabled, + }} + comboboxClasses="h-9" + emptyDropdownText={emptyDropdownText} + /> + + {valueError && ( +

{valueError}

+ )} +
+ ); +}; diff --git a/apps/web/modules/ee/contacts/segments/components/segment-filter.tsx b/apps/web/modules/ee/contacts/segments/components/segment-filter.tsx index a3b4bb906a..c23a148a5f 100644 --- a/apps/web/modules/ee/contacts/segments/components/segment-filter.tsx +++ b/apps/web/modules/ee/contacts/segments/components/segment-filter.tsx @@ -65,6 +65,7 @@ import { SelectValue, } from "@/modules/ui/components/select"; import { AddFilterModal } from "./add-filter-modal"; +import { AttributeValueInput } from "./attribute-value-input"; import { DateFilterValue } from "./date-filter-value"; interface TSegmentFilterProps { @@ -293,33 +294,81 @@ function AttributeSegmentFilter({ setSegment(updatedSegment); }; - const checkValueAndUpdate = (e: React.ChangeEvent) => { - const { value } = e.target; - updateValueInLocalSurvey(resource.id, value); - - if (!value) { - setValueError(t("environments.segments.value_cannot_be_empty")); - return; + const renderValueInput = () => { + if (isDateAttribute && isDateOperator(resource.qualifier.operator)) { + return ( + { + updateValueInLocalSurvey(resource.id, newValue); + }} + viewOnly={viewOnly} + /> + ); } - const { operator } = resource.qualifier; - - if (ARITHMETIC_OPERATORS.includes(operator as TArithmeticOperator)) { - const isNumber = z.coerce.number().safeParse(value); - - if (isNumber.success) { - setValueError(""); - updateValueInLocalSurvey(resource.id, parseInt(value, 10)); - } else { - setValueError(t("environments.segments.value_must_be_a_number")); - updateValueInLocalSurvey(resource.id, value); + if (attributeDataType === "string") { + // Only show combobox if we have a valid attributeKeyId + if (attributeKey?.id) { + return ( + { + updateValueInLocalSurvey(resource.id, newValue); + }} + disabled={viewOnly} + valueError={valueError} + /> + ); } - - return; } - setValueError(""); - updateValueInLocalSurvey(resource.id, value); + return ( +
+ { + if (viewOnly) return; + const { value } = e.target; + updateValueInLocalSurvey(resource.id, value); + + if (!value) { + setValueError(t("environments.segments.value_cannot_be_empty")); + return; + } + + const { operator } = resource.qualifier; + + if (ARITHMETIC_OPERATORS.includes(operator as TArithmeticOperator)) { + const isNumber = z.coerce.number().safeParse(value); + + if (isNumber.success) { + setValueError(""); + updateValueInLocalSurvey(resource.id, Number.parseInt(value, 10)); + } else { + setValueError(t("environments.segments.value_must_be_a_number")); + updateValueInLocalSurvey(resource.id, value); + } + + return; + } + + setValueError(""); + updateValueInLocalSurvey(resource.id, value); + }} + value={resource.value as string | number} + /> + + {valueError ? ( +

{valueError}

+ ) : null} +
+ ); }; return ( @@ -383,41 +432,7 @@ function AttributeSegmentFilter({ - {!["isSet", "isNotSet"].includes(resource.qualifier.operator) && ( - <> - {isDateAttribute && isDateOperator(resource.qualifier.operator) ? ( - { - updateValueInLocalSurvey(resource.id, newValue); - }} - viewOnly={viewOnly} - /> - ) : ( -
- { - if (viewOnly) return; - checkValueAndUpdate(e); - }} - value={resource.value as string | number} - /> - - {valueError ? ( -

- {valueError} -

- ) : null} -
- )} - - )} + {!["isSet", "isNotSet"].includes(resource.qualifier.operator) && renderValueInput()} = ({ {withInput && inputType !== "dropdown" && ( @@ -223,7 +226,7 @@ export const InputCombobox: React.FC = ({ tabIndex={0} aria-controls="options" aria-expanded={open} - className={cn("flex h-10 w-full cursor-pointer items-center justify-end bg-white pr-2", { + className={cn("flex h-full w-full cursor-pointer items-center justify-end bg-white pr-2", { "w-10 justify-center pr-0": withInput && inputType !== "dropdown", "pointer-events-none": isClearing, })}>