add selector for text attributes

This commit is contained in:
Johannes
2026-02-05 11:35:29 -03:00
parent f98a7158ce
commit 0d56b3b766
5 changed files with 275 additions and 59 deletions

View File

@@ -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;
}
}
);

View File

@@ -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);
});

View File

@@ -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<TComboboxOption[]>([]);
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 (
<div className="relative">
<InputCombobox
id={`attribute-value-${attributeKeyId}`}
options={options}
value={value}
onChangeValue={(newValue) => 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 && (
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">{valueError}</p>
)}
</div>
);
};

View File

@@ -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<HTMLInputElement>) => {
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 (
<DateFilterValue
operator={resource.qualifier.operator}
value={resource.value}
onChange={(newValue) => {
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 (
<AttributeValueInput
attributeKeyId={attributeKey.id}
environmentId={segment.environmentId}
dataType={attributeDataType}
value={resource.value as string | number}
onChange={(newValue) => {
updateValueInLocalSurvey(resource.id, newValue);
}}
disabled={viewOnly}
valueError={valueError}
/>
);
}
return;
}
setValueError("");
updateValueInLocalSurvey(resource.id, value);
return (
<div className="relative flex flex-col gap-1">
<Input
className={cn("h-9 w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
disabled={viewOnly}
onChange={(e) => {
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 ? (
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">{valueError}</p>
) : null}
</div>
);
};
return (
@@ -383,41 +432,7 @@ function AttributeSegmentFilter({
</SelectContent>
</Select>
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) && (
<>
{isDateAttribute && isDateOperator(resource.qualifier.operator) ? (
<DateFilterValue
operator={resource.qualifier.operator}
value={resource.value}
onChange={(newValue) => {
updateValueInLocalSurvey(resource.id, newValue);
}}
viewOnly={viewOnly}
/>
) : (
<div className="relative flex flex-col gap-1">
<Input
className={cn(
"h-9 w-auto bg-white",
valueError && "border border-red-500 focus:border-red-500"
)}
disabled={viewOnly}
onChange={(e) => {
if (viewOnly) return;
checkValueAndUpdate(e);
}}
value={resource.value as string | number}
/>
{valueError ? (
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">
{valueError}
</p>
) : null}
</div>
)}
</>
)}
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) && renderValueInput()}
<SegmentFilterItemContextMenu
filterId={resource.id}

View File

@@ -208,8 +208,11 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
{withInput && inputType !== "dropdown" && (
<Input
id={`${id}-input`}
className="min-w-0 rounded-none border-0 border-r border-slate-300 bg-white focus:border-r-slate-400"
{...inputProps}
className={cn(
"min-w-0 rounded-none border-0 border-r border-slate-300 bg-white focus:border-r-slate-400 focus-visible:ring-0 focus-visible:ring-offset-0",
inputProps?.className
)}
value={localValue ?? ""}
onChange={onInputChange}
/>
@@ -223,7 +226,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
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,
})}>