mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-10 18:58:44 -06:00
add selector for text attributes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
})}>
|
||||
|
||||
Reference in New Issue
Block a user