mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-18 18:19:33 -06:00
Compare commits
1 Commits
4.7.0
...
antgravity
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
403d837430 |
@@ -0,0 +1,29 @@
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AttributeKeysManager } from "@/modules/ee/contacts/attributes/attribute-keys-manager";
|
||||
import { ContactsSecondaryNavigation } from "@/modules/ee/contacts/components/contacts-secondary-navigation";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
|
||||
export default async function AttributeKeysPage({
|
||||
params: paramsProps,
|
||||
}: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
}) {
|
||||
const params = await paramsProps;
|
||||
const { environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
const t = await getTranslate();
|
||||
|
||||
const attributeKeys = await getContactAttributeKeys(params.environmentId);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.contacts")}>
|
||||
<ContactsSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
|
||||
<AttributeKeysManager environmentId={environment.id} attributeKeys={attributeKeys} />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,121 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromContactId, getProjectIdFromContactId } from "@/lib/utils/helper";
|
||||
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
|
||||
const ZUpdateContactAttributeAction = z.object({
|
||||
contactId: ZId,
|
||||
attributes: ZContactAttributes,
|
||||
});
|
||||
|
||||
export const updateContactAttributeAction = authenticatedActionClient
|
||||
.schema(ZUpdateContactAttributeAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: parsedInput.contactId },
|
||||
select: { environmentId: true },
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
|
||||
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await updateAttributes(
|
||||
parsedInput.contactId,
|
||||
ctx.user.id,
|
||||
contact.environmentId,
|
||||
parsedInput.attributes
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const ZDeleteContactAttributeAction = z.object({
|
||||
contactId: ZId,
|
||||
attributeKey: z.string(),
|
||||
});
|
||||
|
||||
export const deleteContactAttributeAction = authenticatedActionClient
|
||||
.schema(ZDeleteContactAttributeAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: parsedInput.contactId },
|
||||
select: { environmentId: true },
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
|
||||
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Find the attribute key
|
||||
const attributeKey = await prisma.contactAttributeKey.findFirst({
|
||||
where: {
|
||||
key: parsedInput.attributeKey,
|
||||
environmentId: contact.environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!attributeKey) {
|
||||
// If key doesn't exist, nothing to delete.
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
await prisma.contactAttribute.deleteMany({
|
||||
where: {
|
||||
contactId: parsedInput.contactId,
|
||||
attributeKeyId: attributeKey.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
const ZGeneratePersonalSurveyLinkAction = z.object({
|
||||
contactId: ZId,
|
||||
surveyId: ZId,
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { format } from "date-fns";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getResponsesByContactId } from "@/lib/response/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
|
||||
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
|
||||
interface AttributesSectionProps {
|
||||
contactId: string;
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
}
|
||||
|
||||
export const AttributesSection = async ({ contactId, attributeKeys }: AttributesSectionProps) => {
|
||||
const t = await getTranslate();
|
||||
const [contact, attributes] = await Promise.all([getContact(contactId), getContactAttributes(contactId)]);
|
||||
|
||||
@@ -22,7 +29,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
||||
<dt className="text-sm font-medium text-slate-500">email</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
|
||||
{attributes.email ? (
|
||||
<span>{attributes.email}</span>
|
||||
<span>{attributes.email as string}</span>
|
||||
) : (
|
||||
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
|
||||
)}
|
||||
@@ -32,7 +39,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
||||
<dt className="text-sm font-medium text-slate-500">language</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
|
||||
{attributes.language ? (
|
||||
<span>{attributes.language}</span>
|
||||
<span>{attributes.language as string}</span>
|
||||
) : (
|
||||
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
|
||||
)}
|
||||
@@ -42,7 +49,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
||||
<dt className="text-sm font-medium text-slate-500">userId</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
|
||||
{attributes.userId ? (
|
||||
<IdBadge id={attributes.userId} />
|
||||
<IdBadge id={attributes.userId as string} />
|
||||
) : (
|
||||
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
|
||||
)}
|
||||
@@ -56,10 +63,26 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
||||
{Object.entries(attributes)
|
||||
.filter(([key, _]) => key !== "email" && key !== "userId" && key !== "language")
|
||||
.map(([key, attributeData]) => {
|
||||
const attributeKey = attributeKeys.find((ak) => ak.key === key);
|
||||
let displayValue = attributeData;
|
||||
|
||||
if (attributeKey?.dataType === "date" && displayValue) {
|
||||
try {
|
||||
// assume attributeData is string ISO date or Date object
|
||||
displayValue = format(new Date(displayValue as string | number | Date), "do 'of' MMMM, yyyy");
|
||||
} catch (e) {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
|
||||
if (displayValue instanceof Date) {
|
||||
displayValue = displayValue.toLocaleDateString();
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<dt className="text-sm font-medium text-slate-500">{key}</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
|
||||
<dt className="text-sm font-medium text-slate-500">{attributeKey?.name ?? key}</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">{displayValue}</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { LinkIcon, TrashIcon } from "lucide-react";
|
||||
import { LinkIcon, PencilIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteContactAction } from "@/modules/ee/contacts/actions";
|
||||
import { PublishedLinkSurvey } from "@/modules/ee/contacts/lib/surveys";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { EditAttributesModal } from "./edit-attributes-modal";
|
||||
import { GeneratePersonalLinkModal } from "./generate-personal-link-modal";
|
||||
|
||||
interface ContactControlBarProps {
|
||||
@@ -18,6 +21,8 @@ interface ContactControlBarProps {
|
||||
isReadOnly: boolean;
|
||||
isQuotasAllowed: boolean;
|
||||
publishedLinkSurveys: PublishedLinkSurvey[];
|
||||
attributes: TContactAttributes;
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
}
|
||||
|
||||
export const ContactControlBar = ({
|
||||
@@ -26,12 +31,15 @@ export const ContactControlBar = ({
|
||||
isReadOnly,
|
||||
isQuotasAllowed,
|
||||
publishedLinkSurveys,
|
||||
attributes,
|
||||
attributeKeys,
|
||||
}: ContactControlBarProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeletingPerson, setIsDeletingPerson] = useState(false);
|
||||
const [isGenerateLinkModalOpen, setIsGenerateLinkModalOpen] = useState(false);
|
||||
const [isEditAttributesModalOpen, setIsEditAttributesModalOpen] = useState(false);
|
||||
|
||||
const handleDeletePerson = async () => {
|
||||
setIsDeletingPerson(true);
|
||||
@@ -61,6 +69,14 @@ export const ContactControlBar = ({
|
||||
},
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
icon: PencilIcon,
|
||||
tooltip: t("common.edit_attributes"),
|
||||
onClick: () => {
|
||||
setIsEditAttributesModalOpen(true);
|
||||
},
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
icon: TrashIcon,
|
||||
tooltip: t("common.delete"),
|
||||
@@ -94,6 +110,13 @@ export const ContactControlBar = ({
|
||||
contactId={contactId}
|
||||
publishedLinkSurveys={publishedLinkSurveys}
|
||||
/>
|
||||
<EditAttributesModal
|
||||
open={isEditAttributesModalOpen}
|
||||
setOpen={setIsEditAttributesModalOpen}
|
||||
contactId={contactId}
|
||||
attributes={attributes}
|
||||
attributeKeys={attributeKeys}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { AttributeIcon } from "@/modules/ee/contacts/segments/components/attribute-icon";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DatePicker } from "@/modules/ui/components/date-picker";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { deleteContactAttributeAction, updateContactAttributeAction } from "../actions";
|
||||
|
||||
interface EditAttributesModalProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactId: string;
|
||||
attributes: TContactAttributes;
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
}
|
||||
|
||||
export const EditAttributesModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
contactId,
|
||||
attributes,
|
||||
attributeKeys,
|
||||
}: EditAttributesModalProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Local state for editing. explicit key-value pairs array for easier rendering
|
||||
const [localAttributes, setLocalAttributes] = useState<
|
||||
{ key: string; value: string | number | Date; isNew?: boolean }[]
|
||||
>(
|
||||
Object.entries(attributes)
|
||||
.filter(([key]) => key !== "email" && key !== "userId" && key !== "language") // exclude standard/read-only attributes from generic editor?
|
||||
.map(([key, value]) => ({ key, value }))
|
||||
);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Deletion state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [attributeToDelete, setAttributeToDelete] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const availableKeys = useMemo(() => {
|
||||
// Filter out keys that are already used (except for the one being edited if we supported key change, but we lock key for existing)
|
||||
// Actually for new rows we want to show unused keys.
|
||||
const usedKeys = new Set(localAttributes.map((a) => a.key));
|
||||
return attributeKeys.filter((ak) => !usedKeys.has(ak.key));
|
||||
}, [attributeKeys, localAttributes]);
|
||||
|
||||
const handleUpdateAttribute = (index: number, field: "key" | "value", newValue: any) => {
|
||||
const updated = [...localAttributes];
|
||||
updated[index] = { ...updated[index], [field]: newValue };
|
||||
setLocalAttributes(updated);
|
||||
};
|
||||
|
||||
const handleAddAttribute = () => {
|
||||
setLocalAttributes([...localAttributes, { key: "", value: "", isNew: true }]);
|
||||
};
|
||||
|
||||
const handleRemoveAttribute = (index: number) => {
|
||||
const attribute = localAttributes[index];
|
||||
if (attribute.isNew) {
|
||||
// Just remove from state
|
||||
const updated = [...localAttributes];
|
||||
updated.splice(index, 1);
|
||||
setLocalAttributes(updated);
|
||||
} else {
|
||||
// Trigger delete confirmation for existing attributes
|
||||
setAttributeToDelete(attribute.key);
|
||||
setDeleteDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!attributeToDelete) return;
|
||||
setIsDeleting(true);
|
||||
const result = await deleteContactAttributeAction({ contactId, attributeKey: attributeToDelete });
|
||||
setIsDeleting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
|
||||
if (result?.data?.success) {
|
||||
toast.success("Attribute deleted successfully");
|
||||
setLocalAttributes(localAttributes.filter((a) => a.key !== attributeToDelete));
|
||||
setAttributeToDelete(null);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
// Convert array back to record
|
||||
const attributesRecord: TContactAttributes = {};
|
||||
for (const attr of localAttributes) {
|
||||
if (attr.key && attr.value !== "") {
|
||||
attributesRecord[attr.key] = attr.value;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await updateContactAttributeAction({
|
||||
contactId,
|
||||
attributes: attributesRecord,
|
||||
});
|
||||
|
||||
setIsSaving(false);
|
||||
|
||||
if (result?.data?.success) {
|
||||
toast.success("Attributes updated successfully");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to determine input type based on key
|
||||
const getInputType = (key: string) => {
|
||||
const attributeKey = attributeKeys.find((ak) => ak.key === key);
|
||||
return attributeKey?.dataType ?? "text";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl bg-white p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("common.edit_attributes")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody className="max-h-[60vh] overflow-y-auto pr-2">
|
||||
<div className="space-y-4">
|
||||
{localAttributes.length === 0 && (
|
||||
<p className="text-sm italic text-slate-500">No custom attributes found.</p>
|
||||
)}
|
||||
|
||||
{localAttributes.map((attr, index) => {
|
||||
const dataType = getInputType(attr.key);
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
<Label className="mb-1 block text-xs">Key</Label>
|
||||
{attr.isNew ? (
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={attr.key}
|
||||
onChange={(e) => handleUpdateAttribute(index, "key", e.target.value)}
|
||||
placeholder="Attribute Key"
|
||||
list={`keys-${index}`}
|
||||
/>
|
||||
{/* Simple datalist for suggestion */}
|
||||
<datalist id={`keys-${index}`}>
|
||||
{availableKeys.map((ak) => (
|
||||
<option key={ak.id} value={ak.key} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
) : (
|
||||
<Input value={attr.key} disabled className="bg-slate-50" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Label className="mb-1 flex items-center gap-2 text-xs">
|
||||
Value
|
||||
<AttributeIcon dataType={dataType} className="h-3 w-3 text-slate-400" />
|
||||
</Label>
|
||||
|
||||
{dataType === "date" ? (
|
||||
<DatePicker
|
||||
date={
|
||||
attr.value instanceof Date
|
||||
? attr.value
|
||||
: attr.value
|
||||
? new Date(attr.value as string)
|
||||
: null
|
||||
}
|
||||
updateSurveyDate={(date) => handleUpdateAttribute(index, "value", date ?? "")}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={dataType === "number" ? "number" : "text"}
|
||||
value={attr.value instanceof Date ? "" : attr.value}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (dataType === "number") {
|
||||
handleUpdateAttribute(index, "value", val === "" ? "" : Number(val));
|
||||
} else {
|
||||
handleUpdateAttribute(index, "value", val);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveAttribute(index)}
|
||||
type="button">
|
||||
<TrashIcon className="h-4 w-4 text-slate-500 hover:text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleAddAttribute} className="mt-2">
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Add Attribute
|
||||
</Button>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} loading={isSaving}>
|
||||
{t("common.save_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
deleteWhat="attribute"
|
||||
onDelete={confirmDelete}
|
||||
isDeleting={isDeleting}
|
||||
text={`Are you sure you want to delete the attribute "${attributeToDelete}"? This action cannot be undone.`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
|
||||
import { ContactControlBar } from "@/modules/ee/contacts/[contactId]/components/contact-control-bar";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getPublishedLinkSurveys } from "@/modules/ee/contacts/lib/surveys";
|
||||
@@ -21,12 +22,14 @@ export const SingleContactPage = async (props: {
|
||||
|
||||
const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [environmentTags, contact, contactAttributes, publishedLinkSurveys] = await Promise.all([
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getContact(params.contactId),
|
||||
getContactAttributes(params.contactId),
|
||||
getPublishedLinkSurveys(params.environmentId),
|
||||
]);
|
||||
const [environmentTags, contact, contactAttributes, publishedLinkSurveys, attributeKeys] =
|
||||
await Promise.all([
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getContact(params.contactId),
|
||||
getContactAttributes(params.contactId),
|
||||
getPublishedLinkSurveys(params.environmentId),
|
||||
getContactAttributeKeys(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!contact) {
|
||||
throw new Error(t("environments.contacts.contact_not_found"));
|
||||
@@ -42,6 +45,8 @@ export const SingleContactPage = async (props: {
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
publishedLinkSurveys={publishedLinkSurveys}
|
||||
attributes={contactAttributes}
|
||||
attributeKeys={attributeKeys}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -52,7 +57,7 @@ export const SingleContactPage = async (props: {
|
||||
<PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getContactControlBar()} />
|
||||
<section className="pb-24 pt-6">
|
||||
<div className="grid grid-cols-4 gap-x-8">
|
||||
<AttributesSection contactId={params.contactId} />
|
||||
<AttributesSection contactId={params.contactId} attributeKeys={attributeKeys} />
|
||||
<ResponseSection
|
||||
environment={environment}
|
||||
contactId={params.contactId}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
getProjectIdFromEnvironmentId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { createContactAttributeKey, updateContactAttributeKey } from "./lib/contact-attribute-keys";
|
||||
import { createContactsFromCSV, deleteContact, getContacts } from "./lib/contacts";
|
||||
import {
|
||||
ZContactCSVAttributeMap,
|
||||
@@ -129,3 +131,71 @@ export const createContactsFromCSVAction = authenticatedActionClient.schema(ZCre
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateAttributeKeyAction = z.object({
|
||||
id: ZId,
|
||||
environmentId: ZId,
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
dataType: ZContactAttributeDataType,
|
||||
});
|
||||
|
||||
export const updateAttributeKeyAction = authenticatedActionClient
|
||||
.schema(ZUpdateAttributeKeyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await updateContactAttributeKey(parsedInput.environmentId, parsedInput.id, {
|
||||
name: parsedInput.name,
|
||||
description: parsedInput.description,
|
||||
dataType: parsedInput.dataType,
|
||||
});
|
||||
});
|
||||
|
||||
const ZCreateAttributeKeyAction = z.object({
|
||||
environmentId: ZId,
|
||||
key: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
dataType: ZContactAttributeDataType,
|
||||
});
|
||||
|
||||
export const createAttributeKeyAction = authenticatedActionClient
|
||||
.schema(ZCreateAttributeKeyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await createContactAttributeKey(parsedInput.environmentId, parsedInput.key, "custom", {
|
||||
name: parsedInput.name,
|
||||
description: parsedInput.description,
|
||||
dataType: parsedInput.dataType,
|
||||
});
|
||||
});
|
||||
|
||||
185
apps/web/modules/ee/contacts/attributes/actions.ts
Normal file
185
apps/web/modules/ee/contacts/attributes/actions.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZContactAttributeDataType, ZContactAttributeKeyType } from "@formbricks/types/contact-attribute-key";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
|
||||
// I need proper helpers for auth. usually checkAuthorizationUpdated wrapper handles project/environment checks via getters?
|
||||
// Or I manually fetch.
|
||||
|
||||
const ZCreateAttributeKeyAction = z.object({
|
||||
environmentId: ZId,
|
||||
key: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
type: ZContactAttributeKeyType.optional(), // custom usually
|
||||
dataType: ZContactAttributeDataType.optional(),
|
||||
});
|
||||
|
||||
export const createAttributeKeyAction = authenticatedActionClient
|
||||
.schema(ZCreateAttributeKeyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdByEnvironmentId(parsedInput.environmentId);
|
||||
if (!organizationId) throw new ResourceNotFoundError("Environment", parsedInput.environmentId);
|
||||
|
||||
const projectId = await getProjectIdByEnvironmentId(parsedInput.environmentId);
|
||||
if (!projectId) throw new ResourceNotFoundError("Project", parsedInput.environmentId);
|
||||
|
||||
// Auth check
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const existingKey = await prisma.contactAttributeKey.findFirst({
|
||||
where: {
|
||||
environmentId: parsedInput.environmentId,
|
||||
key: parsedInput.key,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingKey) {
|
||||
throw new Error("Attribute key already exists");
|
||||
}
|
||||
|
||||
const attributeKey = await prisma.contactAttributeKey.create({
|
||||
data: {
|
||||
environmentId: parsedInput.environmentId,
|
||||
key: parsedInput.key,
|
||||
name: parsedInput.name,
|
||||
description: parsedInput.description,
|
||||
type: parsedInput.type ?? "custom",
|
||||
dataType: parsedInput.dataType ?? "text",
|
||||
},
|
||||
});
|
||||
|
||||
return attributeKey;
|
||||
});
|
||||
|
||||
const ZUpdateAttributeKeyAction = z.object({
|
||||
id: ZId,
|
||||
environmentId: ZId,
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
dataType: ZContactAttributeDataType.optional(), // allowing update?
|
||||
});
|
||||
|
||||
export const updateAttributeKeyAction = authenticatedActionClient
|
||||
.schema(ZUpdateAttributeKeyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
// Auth check
|
||||
const organizationId = await getOrganizationIdByEnvironmentId(parsedInput.environmentId);
|
||||
if (!organizationId) throw new ResourceNotFoundError("Organization", parsedInput.environmentId);
|
||||
|
||||
const projectId = await getProjectIdByEnvironmentId(parsedInput.environmentId);
|
||||
if (!projectId) throw new ResourceNotFoundError("Project", parsedInput.environmentId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// check if data type update is safe?
|
||||
// For now trusting the user or UI to warn.
|
||||
|
||||
const attributeKey = await prisma.contactAttributeKey.update({
|
||||
where: {
|
||||
id: parsedInput.id,
|
||||
},
|
||||
data: {
|
||||
name: parsedInput.name,
|
||||
description: parsedInput.description,
|
||||
dataType: parsedInput.dataType,
|
||||
},
|
||||
});
|
||||
|
||||
return attributeKey;
|
||||
});
|
||||
|
||||
const ZDeleteAttributeKeyAction = z.object({
|
||||
id: ZId,
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const deleteAttributeKeyAction = authenticatedActionClient
|
||||
.schema(ZDeleteAttributeKeyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
// Auth check
|
||||
const organizationId = await getOrganizationIdByEnvironmentId(parsedInput.environmentId);
|
||||
if (!organizationId) throw new ResourceNotFoundError("Organization", parsedInput.environmentId);
|
||||
|
||||
const projectId = await getProjectIdByEnvironmentId(parsedInput.environmentId);
|
||||
if (!projectId) throw new ResourceNotFoundError("Project", parsedInput.environmentId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.contactAttributeKey.delete({
|
||||
where: {
|
||||
id: parsedInput.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Helpers (mocked or should be imported from somewhere)
|
||||
// I need `getOrganizationIdByEnvironmentId` and `getProjectIdByEnvironmentId`.
|
||||
// Usually in `@/lib/project/service` or similar. I'll need to confirm imports.
|
||||
// `getProjectIdByEnvironmentId` is usually available.
|
||||
// `getOrganizationIdByEnvironmentId` - I might need to fetch environment then project then org.
|
||||
// Or helper exists.
|
||||
async function getOrganizationIdByEnvironmentId(environmentId: string) {
|
||||
const environment = await prisma.environment.findUnique({
|
||||
where: { id: environmentId },
|
||||
select: { project: { select: { organizationId: true } } },
|
||||
});
|
||||
return environment?.project.organizationId;
|
||||
}
|
||||
|
||||
async function getProjectIdByEnvironmentId(environmentId: string) {
|
||||
const environment = await prisma.environment.findUnique({
|
||||
where: { id: environmentId },
|
||||
select: { projectId: true },
|
||||
});
|
||||
return environment?.projectId;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { deleteAttributeKeyAction } from "./actions";
|
||||
// updated imports
|
||||
import { AttributeKeysTable } from "./attribute-keys-table";
|
||||
import { CreateAttributeKeyModal } from "./create-attribute-key-modal";
|
||||
import { EditAttributeKeyModal } from "./edit-attribute-key-modal";
|
||||
|
||||
interface AttributeKeysManagerProps {
|
||||
environmentId: string;
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
}
|
||||
|
||||
export const AttributeKeysManager = ({ environmentId, attributeKeys }: AttributeKeysManagerProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
|
||||
const [editingKey, setEditingKey] = useState<TContactAttributeKey | null>(null);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
|
||||
const [deletingKey, setDeletingKey] = useState<TContactAttributeKey | null>(null);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleEdit = (key: TContactAttributeKey) => {
|
||||
setEditingKey(key);
|
||||
setIsEditOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (key: TContactAttributeKey) => {
|
||||
setDeletingKey(key);
|
||||
setIsDeleteOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingKey) return;
|
||||
setIsDeleting(true);
|
||||
const result = await deleteAttributeKeyAction({ id: deletingKey.id, environmentId });
|
||||
setIsDeleting(false);
|
||||
|
||||
if (result?.data?.success) {
|
||||
toast.success("Attribute key deleted successfully");
|
||||
setDeletingKey(null);
|
||||
setIsDeleteOpen(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setIsDeleteOpen(false); // Close anyway?
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||
{t("environments.contacts.attributes.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">{t("environments.contacts.attributes.description")}</p>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateOpen(true)}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.contacts.attributes.create_attribute_key")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AttributeKeysTable attributeKeys={attributeKeys} onEdit={handleEdit} onDelete={handleDelete} />
|
||||
|
||||
<CreateAttributeKeyModal open={isCreateOpen} setOpen={setIsCreateOpen} environmentId={environmentId} />
|
||||
|
||||
<EditAttributeKeyModal
|
||||
open={isEditOpen}
|
||||
setOpen={setIsEditOpen}
|
||||
environmentId={environmentId}
|
||||
attributeKey={editingKey}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
open={isDeleteOpen}
|
||||
setOpen={setIsDeleteOpen}
|
||||
deleteWhat="attribute key"
|
||||
onDelete={confirmDelete}
|
||||
isDeleting={isDeleting}
|
||||
text={`Are you sure you want to delete the attribute key "${deletingKey?.key}"? This will delete all data associated with this key.`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Edit2Icon, Trash2Icon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { AttributeIcon } from "@/modules/ee/contacts/segments/components/attribute-icon";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
|
||||
interface AttributeKeysTableProps {
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
onEdit: (key: TContactAttributeKey) => void;
|
||||
onDelete: (key: TContactAttributeKey) => void;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
export const AttributeKeysTable = ({
|
||||
attributeKeys,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
}: AttributeKeysTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (attributeKeys.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 w-full flex-col items-center justify-center rounded-lg border border-slate-200 bg-slate-50">
|
||||
<p className="text-slate-500">{t("environments.contacts.attributes.no_keys_found")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("common.name")} / {t("common.key")}
|
||||
</TableHead>
|
||||
<TableHead>{t("common.description")}</TableHead>
|
||||
<TableHead>{t("common.type")}</TableHead>
|
||||
<TableHead>{t("common.last_updated")}</TableHead>
|
||||
<TableHead className="text-right">{t("common.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{attributeKeys.map((attributeKey) => (
|
||||
<TableRow key={attributeKey.key}>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-slate-900">{attributeKey.name}</span>
|
||||
<span className="font-mono text-xs text-slate-500">{attributeKey.key}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate text-slate-500">
|
||||
{attributeKey.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<AttributeIcon dataType={attributeKey.dataType} className="h-4 w-4 text-slate-500" />
|
||||
<span className="capitalize text-slate-700">{attributeKey.dataType}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-500">
|
||||
{formatDistanceToNow(new Date(attributeKey.updatedAt), { addSuffix: true })}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="icon" variant="ghost" onClick={() => onEdit(attributeKey)}>
|
||||
<Edit2Icon className="h-4 w-4 text-slate-500" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(attributeKey)}
|
||||
disabled={isDeleting}>
|
||||
<Trash2Icon className="h-4 w-4 text-slate-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
TContactAttributeDataType,
|
||||
ZContactAttributeDataType,
|
||||
} from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { createAttributeKeyAction } from "../actions";
|
||||
|
||||
const ZCreateAttributeKeyForm = z.object({
|
||||
key: z.string().min(1, "Key is required"),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.string().optional(),
|
||||
dataType: ZContactAttributeDataType,
|
||||
});
|
||||
|
||||
type TCreateAttributeKeyForm = z.infer<typeof ZCreateAttributeKeyForm>;
|
||||
|
||||
interface CreateAttributeKeyModalProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const CreateAttributeKeyModal = ({ open, setOpen, environmentId }: CreateAttributeKeyModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<TCreateAttributeKeyForm>({
|
||||
resolver: zodResolver(ZCreateAttributeKeyForm),
|
||||
defaultValues: {
|
||||
key: "",
|
||||
name: "",
|
||||
description: "",
|
||||
dataType: "text",
|
||||
},
|
||||
});
|
||||
|
||||
const dataType = watch("dataType");
|
||||
|
||||
const onSubmit = async (data: TCreateAttributeKeyForm) => {
|
||||
const result = await createAttributeKeyAction({
|
||||
environmentId,
|
||||
key: data.key,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
dataType: data.dataType,
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
toast.success("Attribute key created successfully");
|
||||
reset();
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Attribute Key</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="key">Key</Label>
|
||||
<Input id="key" {...register("key")} placeholder="e.g. date_of_birth" />
|
||||
{errors.key && <p className="text-sm text-red-500">{errors.key.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" {...register("name")} placeholder="e.g. Date of Birth" />
|
||||
{errors.name && <p className="text-sm text-red-500">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description (Optional)</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
{...register("description")}
|
||||
placeholder="Short description"
|
||||
className="flex min-h-[80px] w-full rounded-md border border-slate-200 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus:ring-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dataType">Data Type</Label>
|
||||
<Select
|
||||
value={dataType}
|
||||
onValueChange={(val) => setValue("dataType", val as TContactAttributeDataType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">Text</SelectItem>
|
||||
<SelectItem value="number">Number</SelectItem>
|
||||
<SelectItem value="date">Date</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.dataType && <p className="text-sm text-red-500">{errors.dataType.message}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
Create Key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
TContactAttributeDataType,
|
||||
TContactAttributeKey,
|
||||
ZContactAttributeDataType,
|
||||
} from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { updateAttributeKeyAction } from "../actions";
|
||||
|
||||
const ZEditAttributeKeyForm = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.string().optional(),
|
||||
dataType: ZContactAttributeDataType,
|
||||
});
|
||||
|
||||
type TEditAttributeKeyForm = z.infer<typeof ZEditAttributeKeyForm>;
|
||||
|
||||
interface EditAttributeKeyModalProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
environmentId: string;
|
||||
attributeKey: TContactAttributeKey | null;
|
||||
}
|
||||
|
||||
export const EditAttributeKeyModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
environmentId,
|
||||
attributeKey,
|
||||
}: EditAttributeKeyModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<TEditAttributeKeyForm>({
|
||||
resolver: zodResolver(ZEditAttributeKeyForm),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
dataType: "text",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (attributeKey) {
|
||||
reset({
|
||||
name: attributeKey.name ?? "",
|
||||
description: attributeKey.description ?? "",
|
||||
dataType: attributeKey.dataType ?? "text",
|
||||
});
|
||||
}
|
||||
}, [attributeKey, reset]);
|
||||
|
||||
const dataType = watch("dataType");
|
||||
|
||||
const onSubmit = async (data: TEditAttributeKeyForm) => {
|
||||
if (!attributeKey) return;
|
||||
|
||||
const result = await updateAttributeKeyAction({
|
||||
id: attributeKey.id,
|
||||
environmentId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
dataType: data.dataType,
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
toast.success("Attribute key updated successfully");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Attribute Key</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="key-edit">Key</Label>
|
||||
<Input id="key-edit" value={attributeKey?.key ?? ""} disabled className="bg-slate-50" />
|
||||
<p className="text-xs text-slate-500">The key cannot be changed once created.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name-edit">Name</Label>
|
||||
<Input id="name-edit" {...register("name")} placeholder="e.g. Date of Birth" />
|
||||
{errors.name && <p className="text-sm text-red-500">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description-edit">Description (Optional)</Label>
|
||||
<textarea
|
||||
id="description-edit"
|
||||
{...register("description")}
|
||||
placeholder="Short description"
|
||||
className="flex min-h-[80px] w-full rounded-md border border-slate-200 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus:ring-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dataType-edit">Data Type</Label>
|
||||
<Select
|
||||
value={dataType}
|
||||
onValueChange={(val) => setValue("dataType", val as TContactAttributeDataType)}>
|
||||
<SelectTrigger id="dataType-edit">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">Text</SelectItem>
|
||||
<SelectItem value="number">Number</SelectItem>
|
||||
<SelectItem value="date">Date</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-500">Changing data type may affect existing data.</p>
|
||||
{errors.dataType && <p className="text-sm text-red-500">{errors.dataType.message}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -35,6 +35,11 @@ export const ContactsSecondaryNavigation = async ({
|
||||
label: t("common.segments"),
|
||||
href: `/environments/${environmentId}/segments`,
|
||||
},
|
||||
{
|
||||
id: "attributes",
|
||||
label: t("common.attributes"),
|
||||
href: `/environments/${environmentId}/attributes`,
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { detectAttributeType } from "@/modules/ee/contacts/lib/detect-attribute-type";
|
||||
|
||||
export const updateAttributes = async (
|
||||
contactId: string,
|
||||
@@ -24,7 +25,7 @@ export const updateAttributes = async (
|
||||
// Fetch contact attribute keys and email check in parallel
|
||||
const [contactAttributeKeys, existingEmailAttribute] = await Promise.all([
|
||||
getContactAttributeKeys(environmentId),
|
||||
contactAttributesParam.email
|
||||
contactAttributesParam.email && typeof contactAttributesParam.email === "string"
|
||||
? hasEmailAttribute(contactAttributesParam.email, environmentId, contactId)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
@@ -42,6 +43,14 @@ export const updateAttributes = async (
|
||||
(acc, [key, value]) => {
|
||||
const attributeKey = contactAttributeKeyMap.get(key);
|
||||
if (attributeKey) {
|
||||
// Validate type
|
||||
if (attributeKey.dataType === "number") {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return acc; // Skip invalid number
|
||||
} else if (attributeKey.dataType === "date") {
|
||||
const date = new Date(value as string | number | Date);
|
||||
if (isNaN(date.getTime())) return acc; // Skip invalid date
|
||||
}
|
||||
acc.existingAttributes.push({ key, value, attributeKeyId: attributeKey.id });
|
||||
} else {
|
||||
acc.newAttributes.push({ key, value });
|
||||
@@ -49,8 +58,8 @@ export const updateAttributes = async (
|
||||
return acc;
|
||||
},
|
||||
{ existingAttributes: [], newAttributes: [] } as {
|
||||
existingAttributes: { key: string; value: string; attributeKeyId: string }[];
|
||||
newAttributes: { key: string; value: string }[];
|
||||
existingAttributes: { key: string; value: string | number | Date; attributeKeyId: string }[];
|
||||
newAttributes: { key: string; value: string | number | Date }[];
|
||||
}
|
||||
);
|
||||
|
||||
@@ -65,22 +74,29 @@ export const updateAttributes = async (
|
||||
// First, update all existing attributes
|
||||
if (existingAttributes.length > 0) {
|
||||
await prisma.$transaction(
|
||||
existingAttributes.map(({ attributeKeyId, value }) =>
|
||||
prisma.contactAttribute.upsert({
|
||||
existingAttributes.map(({ attributeKeyId, value }) => {
|
||||
let stringValue = value;
|
||||
if (value instanceof Date) {
|
||||
stringValue = value.toISOString();
|
||||
} else {
|
||||
stringValue = String(value);
|
||||
}
|
||||
|
||||
return prisma.contactAttribute.upsert({
|
||||
where: {
|
||||
contactId_attributeKeyId: {
|
||||
contactId,
|
||||
attributeKeyId,
|
||||
},
|
||||
},
|
||||
update: { value },
|
||||
update: { value: stringValue },
|
||||
create: {
|
||||
contactId,
|
||||
attributeKeyId,
|
||||
value,
|
||||
value: stringValue,
|
||||
},
|
||||
})
|
||||
)
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,18 +112,26 @@ export const updateAttributes = async (
|
||||
} else {
|
||||
// Create new attributes since we're under the limit
|
||||
await prisma.$transaction(
|
||||
newAttributes.map(({ key, value }) =>
|
||||
prisma.contactAttributeKey.create({
|
||||
newAttributes.map(({ key, value }) => {
|
||||
let stringValue = value;
|
||||
if (value instanceof Date) {
|
||||
stringValue = value.toISOString();
|
||||
} else {
|
||||
stringValue = String(value);
|
||||
}
|
||||
|
||||
return prisma.contactAttributeKey.create({
|
||||
data: {
|
||||
key,
|
||||
type: "custom",
|
||||
dataType: detectAttributeType(value),
|
||||
environment: { connect: { id: environmentId } },
|
||||
attributes: {
|
||||
create: { contactId, value },
|
||||
create: { contactId, value: stringValue },
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
export const getContactAttributeKeys = reactCache(
|
||||
async (environmentId: string): Promise<TContactAttributeKey[]> => {
|
||||
@@ -9,3 +9,41 @@ export const getContactAttributeKeys = reactCache(
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const updateContactAttributeKey = async (
|
||||
environmentId: string,
|
||||
keyId: string,
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
dataType: TContactAttributeDataType;
|
||||
}
|
||||
): Promise<TContactAttributeKey> => {
|
||||
return await prisma.contactAttributeKey.update({
|
||||
where: {
|
||||
id: keyId,
|
||||
environmentId,
|
||||
},
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
export const createContactAttributeKey = async (
|
||||
environmentId: string,
|
||||
key: string,
|
||||
type: "default" | "custom",
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
dataType: TContactAttributeDataType;
|
||||
}
|
||||
): Promise<TContactAttributeKey> => {
|
||||
return await prisma.contactAttributeKey.create({
|
||||
data: {
|
||||
key,
|
||||
type,
|
||||
environmentId,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
36
apps/web/modules/ee/contacts/lib/detect-attribute-type.ts
Normal file
36
apps/web/modules/ee/contacts/lib/detect-attribute-type.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
export const detectAttributeType = (value: string | number | Date): TContactAttributeDataType => {
|
||||
// if the value is a number, return "number"
|
||||
if (typeof value === "number") {
|
||||
return "number";
|
||||
}
|
||||
|
||||
// if the value is a string and looks like a number, return "number"
|
||||
if (typeof value === "string") {
|
||||
const trimmedValue = value.trim();
|
||||
if (trimmedValue !== "" && !isNaN(Number(trimmedValue))) {
|
||||
return "number";
|
||||
}
|
||||
}
|
||||
|
||||
// if the value is a date, return "date"
|
||||
if (value instanceof Date) {
|
||||
return "date";
|
||||
}
|
||||
|
||||
// if the value is a string and looks like a date, return "date"
|
||||
if (typeof value === "string") {
|
||||
// Check if it starts with YYYY-MM-DD (ISO 8601 partial match is enough for our needs)
|
||||
// we want to avoid treating arbitrary strings as dates even if Date.parse accepts them
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return "date";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise, return "text"
|
||||
return "text";
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { FingerprintIcon, MonitorSmartphoneIcon, TagIcon, Users2Icon } from "luc
|
||||
import React, { type JSX, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import type {
|
||||
TBaseFilter,
|
||||
TSegment,
|
||||
@@ -15,6 +16,7 @@ import { cn } from "@/lib/cn";
|
||||
import { Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { TabBar } from "@/modules/ui/components/tab-bar";
|
||||
import { AttributeIcon } from "./attribute-icon";
|
||||
import AttributeTabContent from "./attribute-tab-content";
|
||||
import FilterButton from "./filter-button";
|
||||
|
||||
@@ -35,6 +37,7 @@ export const handleAddFilter = ({
|
||||
contactAttributeKey,
|
||||
deviceType,
|
||||
segmentId,
|
||||
dataType,
|
||||
}: {
|
||||
type: TFilterType;
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
@@ -42,10 +45,22 @@ export const handleAddFilter = ({
|
||||
contactAttributeKey?: string;
|
||||
segmentId?: string;
|
||||
deviceType?: string;
|
||||
dataType?: TContactAttributeDataType;
|
||||
}): void => {
|
||||
if (type === "attribute") {
|
||||
if (!contactAttributeKey) return;
|
||||
|
||||
let operator: string = "equals";
|
||||
let value: any = "";
|
||||
|
||||
if (dataType === "date") {
|
||||
operator = "isOlderThan";
|
||||
value = { amount: 7, unit: "days" };
|
||||
} else if (dataType === "number") {
|
||||
operator = "equals";
|
||||
value = 0;
|
||||
}
|
||||
|
||||
const newFilterResource: TSegmentAttributeFilter = {
|
||||
id: createId(),
|
||||
root: {
|
||||
@@ -53,9 +68,9 @@ export const handleAddFilter = ({
|
||||
contactAttributeKey,
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
operator: operator as any,
|
||||
},
|
||||
value: "",
|
||||
value,
|
||||
};
|
||||
const newFilter: TBaseFilter = {
|
||||
id: createId(),
|
||||
@@ -239,7 +254,7 @@ export function AddFilterModal({
|
||||
<FilterButton
|
||||
key={attributeKey.id}
|
||||
data-testid={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={<TagIcon className="h-4 w-4" />}
|
||||
icon={<AttributeIcon dataType={attributeKey.dataType} className="h-4 w-4" />}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
@@ -247,6 +262,7 @@ export function AddFilterModal({
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
dataType: attributeKey.dataType,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
@@ -257,6 +273,7 @@ export function AddFilterModal({
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
dataType: attributeKey.dataType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CalendarIcon, HashIcon, TagIcon } from "lucide-react";
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
interface AttributeIconProps {
|
||||
dataType?: TContactAttributeDataType;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AttributeIcon = ({ dataType, className }: AttributeIconProps) => {
|
||||
switch (dataType) {
|
||||
case "date":
|
||||
return <CalendarIcon className={className} />;
|
||||
case "number":
|
||||
return <HashIcon className={className} />;
|
||||
case "text":
|
||||
default:
|
||||
return <TagIcon className={className} />;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { TTimeUnit } from "@formbricks/types/segment";
|
||||
import { DatePicker } from "@/modules/ui/components/date-picker";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface DateFilterValueProps {
|
||||
filterId: string;
|
||||
operator: string;
|
||||
value: string | number | { amount: number; unit: TTimeUnit } | [string, string];
|
||||
onChange: (value: string | number | { amount: number; unit: TTimeUnit } | [string, string]) => void;
|
||||
}
|
||||
|
||||
export const DateFilterValue = ({ operator, value, onChange }: DateFilterValueProps) => {
|
||||
// Handle relative operators (isOlderThan, isNewerThan)
|
||||
if (operator === "isOlderThan" || operator === "isNewerThan") {
|
||||
const relativeValue =
|
||||
typeof value === "object" && !Array.isArray(value)
|
||||
? (value as { amount: number; unit: TTimeUnit })
|
||||
: { amount: 1, unit: "days" as TTimeUnit };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={relativeValue.amount}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...relativeValue,
|
||||
amount: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="w-20 bg-white"
|
||||
/>
|
||||
<Select
|
||||
value={relativeValue.unit}
|
||||
onValueChange={(unit) =>
|
||||
onChange({
|
||||
...relativeValue,
|
||||
unit: unit as TTimeUnit,
|
||||
})
|
||||
}>
|
||||
<SelectTrigger className="w-32 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="days">days</SelectItem>
|
||||
<SelectItem value="weeks">weeks</SelectItem>
|
||||
<SelectItem value="months">months</SelectItem>
|
||||
<SelectItem value="years">years</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle isBetween (Range)
|
||||
if (operator === "isBetween") {
|
||||
const rangeValue = Array.isArray(value) ? (value as [string, string]) : ["", ""];
|
||||
const [startDate, endDate] = rangeValue;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-full">
|
||||
<DatePicker
|
||||
date={startDate ? new Date(startDate) : null}
|
||||
updateSurveyDate={(date) => {
|
||||
if (date) {
|
||||
onChange([date.toISOString(), endDate]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CalendarIcon className="pointer-events-none absolute right-2 top-2.5 h-4 w-4 text-slate-400" />
|
||||
</div>
|
||||
<span className="text-sm text-slate-500">and</span>
|
||||
<div className="relative w-full">
|
||||
<DatePicker
|
||||
date={endDate ? new Date(endDate) : null}
|
||||
updateSurveyDate={(date) => {
|
||||
if (date) {
|
||||
onChange([startDate, date.toISOString()]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CalendarIcon className="pointer-events-none absolute right-2 top-2.5 h-4 w-4 text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle absolute operators (isBefore, isAfter, isSameDay)
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<DatePicker
|
||||
date={typeof value === "string" && value ? new Date(value) : null}
|
||||
updateSurveyDate={(date) => {
|
||||
if (date) {
|
||||
onChange(date.toISOString());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CalendarIcon className="pointer-events-none absolute right-2 top-2.5 h-4 w-4 text-slate-400" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
FingerprintIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
MoreVertical,
|
||||
TagIcon,
|
||||
Trash2,
|
||||
Users2Icon,
|
||||
} from "lucide-react";
|
||||
@@ -14,11 +13,16 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import type {
|
||||
import {
|
||||
ARITHMETIC_OPERATORS,
|
||||
DATE_OPERATORS,
|
||||
DEVICE_OPERATORS,
|
||||
PERSON_OPERATORS,
|
||||
TArithmeticOperator,
|
||||
TAttributeOperator,
|
||||
TBaseFilter,
|
||||
TDeviceOperator,
|
||||
TEXT_ATTRIBUTE_OPERATORS,
|
||||
TSegment,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentConnector,
|
||||
@@ -29,14 +33,9 @@ import type {
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
} from "@formbricks/types/segment";
|
||||
import {
|
||||
ARITHMETIC_OPERATORS,
|
||||
ATTRIBUTE_OPERATORS,
|
||||
DEVICE_OPERATORS,
|
||||
PERSON_OPERATORS,
|
||||
} from "@formbricks/types/segment";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { DateFilterValue } from "@/modules/ee/contacts/segments/components/date-filter-value";
|
||||
import {
|
||||
convertOperatorToText,
|
||||
convertOperatorToTitle,
|
||||
@@ -64,6 +63,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { AddFilterModal } from "./add-filter-modal";
|
||||
import { AttributeIcon } from "./attribute-icon";
|
||||
|
||||
interface TSegmentFilterProps {
|
||||
connector: TSegmentConnector;
|
||||
@@ -224,6 +224,10 @@ function AttributeSegmentFilter({
|
||||
|
||||
const [valueError, setValueError] = useState("");
|
||||
|
||||
const attributeKey = contactAttributeKeys.find((attrKey) => attrKey.key === contactAttributeKey);
|
||||
const dataType = attributeKey?.dataType ?? "text";
|
||||
const attrKeyValue = attributeKey?.name ?? attributeKey?.key ?? "";
|
||||
|
||||
// when the operator changes, we need to check if the value is valid
|
||||
useEffect(() => {
|
||||
const { operator } = resource.qualifier;
|
||||
@@ -239,17 +243,27 @@ function AttributeSegmentFilter({
|
||||
}
|
||||
}, [resource.qualifier, resource.value, t]);
|
||||
|
||||
const operatorArr = ATTRIBUTE_OPERATORS.map((operator) => {
|
||||
const getAvailableOperators = () => {
|
||||
switch (dataType) {
|
||||
case "number":
|
||||
return [...ARITHMETIC_OPERATORS, "isSet", "isNotSet"] as const;
|
||||
case "date":
|
||||
return [...DATE_OPERATORS, "isSet", "isNotSet"] as const;
|
||||
default:
|
||||
// Text is default
|
||||
return TEXT_ATTRIBUTE_OPERATORS;
|
||||
}
|
||||
};
|
||||
|
||||
const availableOperators = getAvailableOperators();
|
||||
|
||||
const operatorArr = availableOperators.map((operator) => {
|
||||
return {
|
||||
id: operator,
|
||||
name: convertOperatorToText(operator),
|
||||
};
|
||||
});
|
||||
|
||||
const attributeKey = contactAttributeKeys.find((attrKey) => attrKey.key === contactAttributeKey);
|
||||
|
||||
const attrKeyValue = attributeKey?.name ?? attributeKey?.key ?? "";
|
||||
|
||||
const updateOperatorInLocalSurvey = (filterId: string, newOperator: TAttributeOperator) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters) {
|
||||
@@ -279,7 +293,7 @@ function AttributeSegmentFilter({
|
||||
|
||||
const { operator } = resource.qualifier;
|
||||
|
||||
if (ARITHMETIC_OPERATORS.includes(operator as TArithmeticOperator)) {
|
||||
if (dataType === "number" && ARITHMETIC_OPERATORS.includes(operator as TArithmeticOperator)) {
|
||||
const isNumber = z.coerce.number().safeParse(value);
|
||||
|
||||
if (isNumber.success) {
|
||||
@@ -319,7 +333,7 @@ function AttributeSegmentFilter({
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<TagIcon className="h-4 w-4 text-sm" />
|
||||
<AttributeIcon dataType={dataType} className="h-4 w-4 text-sm" />
|
||||
<p>{attrKeyValue}</p>
|
||||
</div>
|
||||
</SelectValue>
|
||||
@@ -355,25 +369,37 @@ function AttributeSegmentFilter({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) && (
|
||||
<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);
|
||||
}}
|
||||
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) &&
|
||||
(dataType === "date" ? (
|
||||
<DateFilterValue
|
||||
filterId={resource.id}
|
||||
operator={resource.qualifier.operator}
|
||||
value={resource.value}
|
||||
onChange={(newValue) => updateValueInLocalSurvey(resource.id, newValue)}
|
||||
/>
|
||||
) : (
|
||||
<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 any}
|
||||
type={dataType === "number" ? "number" : "text"}
|
||||
/>
|
||||
|
||||
{valueError ? (
|
||||
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">
|
||||
{valueError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{valueError ? (
|
||||
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">
|
||||
{valueError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<SegmentFilterItemContextMenu
|
||||
filterId={resource.id}
|
||||
@@ -544,7 +570,7 @@ function PersonSegmentFilter({
|
||||
if (viewOnly) return;
|
||||
checkValueAndUpdate(e);
|
||||
}}
|
||||
value={resource.value}
|
||||
value={resource.value as any}
|
||||
/>
|
||||
|
||||
{valueError ? (
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
|
||||
export const segmentTableColumns: ColumnDef<TSegmentWithSurveyNames>[] = [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
cell: ({ row }) => {
|
||||
const segment = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
|
||||
<UsersIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="ph-no-capture font-medium text-slate-900">{segment.title}</div>
|
||||
{segment.description && (
|
||||
<div className="ph-no-capture max-w-[300px] truncate text-xs font-medium text-slate-500">
|
||||
{segment.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "surveys",
|
||||
header: "Surveys",
|
||||
cell: ({ row }) => {
|
||||
// segments table data row had this hidden on small screens
|
||||
return <div className="text-center text-slate-900">{row.original.surveys?.length ?? 0}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="text-center text-slate-900">
|
||||
{formatDistanceToNow(row.original.updatedAt, {
|
||||
addSuffix: true,
|
||||
}).replace("about", "")}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="text-center text-slate-900">
|
||||
{format(row.original.createdAt, "do 'of' MMMM, yyyy")}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { EditSegmentModal } from "./edit-segment-modal";
|
||||
import { segmentTableColumns } from "./segment-table-columns";
|
||||
|
||||
interface SegmentsDataTableProps {
|
||||
segments: TSegmentWithSurveyNames[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isContactsEnabled: boolean;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const SegmentsDataTable = ({
|
||||
segments,
|
||||
contactAttributeKeys,
|
||||
isContactsEnabled,
|
||||
isReadOnly,
|
||||
}: SegmentsDataTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedSegment, setSelectedSegment] = useState<TSegmentWithSurveyNames | null>(null);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
|
||||
const table = useReactTable({
|
||||
data: segments,
|
||||
columns: segmentTableColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const handleRowClick = (segment: TSegmentWithSurveyNames) => {
|
||||
setSelectedSegment(segment);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableCell key={header.id} className="font-semibold text-slate-900">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className="cursor-pointer hover:bg-slate-50"
|
||||
onClick={() => handleRowClick(row.original)}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
{segments.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={segmentTableColumns.length}
|
||||
className="py-6 text-center text-sm text-slate-400">
|
||||
{t("environments.segments.create_your_first_segment")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{selectedSegment && (
|
||||
<EditSegmentModal
|
||||
environmentId={selectedSegment.environmentId}
|
||||
open={isEditModalOpen}
|
||||
setOpen={setIsEditModalOpen}
|
||||
currentSegment={selectedSegment}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
segments={segments as TSegment[]} // types might slightly differ if WithSurveyNames extends TSegment
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
59
apps/web/modules/ee/contacts/segments/lib/date-utils.ts
Normal file
59
apps/web/modules/ee/contacts/segments/lib/date-utils.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { TTimeUnit } from "@formbricks/types/segment";
|
||||
|
||||
export const subtractTimeUnit = (date: Date, amount: number, unit: TTimeUnit): Date => {
|
||||
const result = new Date(date);
|
||||
switch (unit) {
|
||||
case "days":
|
||||
result.setDate(result.getDate() - amount);
|
||||
break;
|
||||
case "weeks":
|
||||
result.setDate(result.getDate() - amount * 7);
|
||||
break;
|
||||
case "months":
|
||||
result.setMonth(result.getMonth() - amount);
|
||||
break;
|
||||
case "years":
|
||||
result.setFullYear(result.getFullYear() - amount);
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const addTimeUnit = (date: Date, amount: number, unit: TTimeUnit): Date => {
|
||||
const result = new Date(date);
|
||||
switch (unit) {
|
||||
case "days":
|
||||
result.setDate(result.getDate() + amount);
|
||||
break;
|
||||
case "weeks":
|
||||
result.setDate(result.getDate() + amount * 7);
|
||||
break;
|
||||
case "months":
|
||||
result.setMonth(result.getMonth() + amount);
|
||||
break;
|
||||
case "years":
|
||||
result.setFullYear(result.getFullYear() + amount);
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const startOfDay = (date: Date): Date => {
|
||||
const result = new Date(date);
|
||||
result.setHours(0, 0, 0, 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const endOfDay = (date: Date): Date => {
|
||||
const result = new Date(date);
|
||||
result.setHours(23, 59, 59, 999);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const isSameDay = (date1: Date, date2: Date): boolean => {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
);
|
||||
};
|
||||
@@ -3,19 +3,76 @@ import { cache as reactCache } from "react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import {
|
||||
DATE_OPERATORS,
|
||||
TBaseFilters,
|
||||
TDateOperator,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilter,
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
TTimeUnit,
|
||||
} from "@formbricks/types/segment";
|
||||
import { endOfDay, startOfDay, subtractTimeUnit } from "@/modules/ee/contacts/segments/lib/date-utils";
|
||||
import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { getSegment } from "../segments";
|
||||
|
||||
// Type for the result of the segment filter to prisma query generation
|
||||
export type SegmentFilterQueryResult = {
|
||||
whereClause: Prisma.ContactWhereInput;
|
||||
const isDateOperator = (operator: string): operator is TDateOperator => {
|
||||
return DATE_OPERATORS.includes(operator as TDateOperator);
|
||||
};
|
||||
|
||||
const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prisma.StringFilter => {
|
||||
const { qualifier, value } = filter;
|
||||
const { operator } = qualifier;
|
||||
|
||||
if (operator === "isOlderThan" || operator === "isNewerThan") {
|
||||
if (typeof value !== "object" || Array.isArray(value) || !("amount" in value) || !("unit" in value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { amount, unit } = value as { amount: number; unit: TTimeUnit };
|
||||
const now = new Date();
|
||||
const thresholdDate = subtractTimeUnit(now, amount, unit);
|
||||
|
||||
if (operator === "isOlderThan") {
|
||||
return { lt: thresholdDate.toISOString() };
|
||||
} else {
|
||||
return { gt: thresholdDate.toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
if (operator === "isBetween") {
|
||||
if (!Array.isArray(value) || value.length !== 2) {
|
||||
return {};
|
||||
}
|
||||
const [startStr, endStr] = value as [string, string];
|
||||
const startDate = startOfDay(new Date(startStr));
|
||||
const endDate = endOfDay(new Date(endStr));
|
||||
|
||||
return {
|
||||
gte: startDate.toISOString(),
|
||||
lte: endDate.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return {};
|
||||
}
|
||||
const compareDate = new Date(value);
|
||||
|
||||
switch (operator) {
|
||||
case "isBefore":
|
||||
return { lt: startOfDay(compareDate).toISOString() };
|
||||
case "isAfter":
|
||||
return { gt: endOfDay(compareDate).toISOString() };
|
||||
case "isSameDay":
|
||||
return {
|
||||
gte: startOfDay(compareDate).toISOString(),
|
||||
lte: endOfDay(compareDate).toISOString(),
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -60,39 +117,56 @@ const buildAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prism
|
||||
},
|
||||
} satisfies Prisma.ContactWhereInput;
|
||||
|
||||
if (isDateOperator(operator)) {
|
||||
// @ts-ignore
|
||||
valueQuery.attributes.some.value = buildDateAttributeFilterWhereClause(filter);
|
||||
return valueQuery;
|
||||
}
|
||||
|
||||
// Apply the appropriate operator to the attribute value
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
// @ts-ignore
|
||||
valueQuery.attributes.some.value = { equals: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "notEquals":
|
||||
// @ts-ignore
|
||||
valueQuery.attributes.some.value = { not: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "contains":
|
||||
// @ts-ignore
|
||||
valueQuery.attributes.some.value = { contains: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "doesNotContain":
|
||||
// @ts-ignore
|
||||
valueQuery.attributes.some.value = { not: { contains: String(value) }, mode: "insensitive" };
|
||||
break;
|
||||
case "startsWith":
|
||||
// @ts-ignore
|
||||
valueQuery.attributes.some.value = { startsWith: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "endsWith":
|
||||
// @ts-ignore
|
||||
valueQuery.attributes.some.value = { endsWith: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "greaterThan":
|
||||
// @ts-ignore
|
||||
valueQuery.attributes.some.value = { gt: String(value) };
|
||||
break;
|
||||
case "greaterEqual":
|
||||
// @ts-ignore
|
||||
valueQuery.attributes.some.value = { gte: String(value) };
|
||||
break;
|
||||
case "lessThan":
|
||||
// @ts-ignore
|
||||
valueQuery.attributes.some.value = { lt: String(value) };
|
||||
break;
|
||||
case "lessEqual":
|
||||
// @ts-ignore
|
||||
valueQuery.attributes.some.value = { lte: String(value) };
|
||||
break;
|
||||
default:
|
||||
// @ts-ignore
|
||||
valueQuery.attributes.some.value = String(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,12 +22,20 @@ import {
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
TSegmentUpdateInput,
|
||||
TSegmentWithSurveyNames,
|
||||
ZSegmentCreateInput,
|
||||
ZSegmentFilters,
|
||||
ZSegmentUpdateInput,
|
||||
} from "@formbricks/types/segment";
|
||||
import { DATE_OPERATORS, TDateOperator, TTimeUnit } from "@formbricks/types/segment";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import {
|
||||
endOfDay,
|
||||
isSameDay,
|
||||
startOfDay,
|
||||
subtractTimeUnit,
|
||||
} from "@/modules/ee/contacts/segments/lib/date-utils";
|
||||
import { isResourceFilter, searchForAttributeKeyInSegment } from "@/modules/ee/contacts/segments/lib/utils";
|
||||
|
||||
export type PrismaSegment = Prisma.SegmentGetPayload<{
|
||||
@@ -35,6 +43,8 @@ export type PrismaSegment = Prisma.SegmentGetPayload<{
|
||||
surveys: {
|
||||
select: {
|
||||
id: true;
|
||||
name: true;
|
||||
status: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -65,6 +75,21 @@ export const transformPrismaSegment = (segment: PrismaSegment): TSegment => {
|
||||
};
|
||||
};
|
||||
|
||||
export const transformPrismaSegmentWithSurveyNames = (segment: PrismaSegment): TSegmentWithSurveyNames => {
|
||||
const activeSurveys = segment.surveys
|
||||
.filter((survey) => survey.status === "inProgress")
|
||||
.map((survey) => survey.name);
|
||||
const inactiveSurveys = segment.surveys
|
||||
.filter((survey) => survey.status !== "inProgress")
|
||||
.map((survey) => survey.name);
|
||||
|
||||
return {
|
||||
...transformPrismaSegment(segment),
|
||||
activeSurveys,
|
||||
inactiveSurveys,
|
||||
};
|
||||
};
|
||||
|
||||
export const getSegment = reactCache(async (segmentId: string): Promise<TSegment> => {
|
||||
validateInputs([segmentId, ZId]);
|
||||
try {
|
||||
@@ -89,7 +114,7 @@ export const getSegment = reactCache(async (segmentId: string): Promise<TSegment
|
||||
}
|
||||
});
|
||||
|
||||
export const getSegments = reactCache(async (environmentId: string): Promise<TSegment[]> => {
|
||||
export const getSegments = reactCache(async (environmentId: string): Promise<TSegmentWithSurveyNames[]> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
try {
|
||||
const segments = await prisma.segment.findMany({
|
||||
@@ -103,7 +128,7 @@ export const getSegments = reactCache(async (environmentId: string): Promise<TSe
|
||||
return [];
|
||||
}
|
||||
|
||||
return segments.map((segment) => transformPrismaSegment(segment));
|
||||
return segments.map((segment) => transformPrismaSegmentWithSurveyNames(segment));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
@@ -375,6 +400,77 @@ export const getSegmentsByAttributeKey = reactCache(async (environmentId: string
|
||||
}
|
||||
});
|
||||
|
||||
const isDateOperator = (operator: string): operator is TDateOperator => {
|
||||
return DATE_OPERATORS.includes(operator as TDateOperator);
|
||||
};
|
||||
|
||||
const evaluateDateFilter = (
|
||||
attributeValue: string | number,
|
||||
filterValue: TSegmentAttributeFilter["value"],
|
||||
operator: TDateOperator
|
||||
): boolean => {
|
||||
const date = new Date(attributeValue);
|
||||
if (isNaN(date.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle relative date operators
|
||||
if (operator === "isOlderThan" || operator === "isNewerThan") {
|
||||
if (
|
||||
typeof filterValue !== "object" ||
|
||||
Array.isArray(filterValue) ||
|
||||
!("amount" in filterValue) ||
|
||||
!("unit" in filterValue)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { amount, unit } = filterValue as { amount: number; unit: TTimeUnit };
|
||||
const now = new Date();
|
||||
const thresholdDate = subtractTimeUnit(now, amount, unit);
|
||||
|
||||
if (operator === "isOlderThan") {
|
||||
// Something is older than 5 days if valid < (now - 5 days)
|
||||
return date < thresholdDate;
|
||||
} else {
|
||||
// Something is newer than 5 days if valid > (now - 5 days)
|
||||
return date > thresholdDate;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle between operator
|
||||
if (operator === "isBetween") {
|
||||
if (!Array.isArray(filterValue) || filterValue.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
const [startStr, endStr] = filterValue as [string, string];
|
||||
const startDate = startOfDay(new Date(startStr));
|
||||
const endDate = endOfDay(new Date(endStr));
|
||||
|
||||
return date >= startDate && date <= endDate;
|
||||
}
|
||||
|
||||
// Handle absolute operators
|
||||
if (typeof filterValue !== "string") {
|
||||
return false;
|
||||
}
|
||||
const compareDate = new Date(filterValue);
|
||||
if (isNaN(compareDate.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case "isBefore":
|
||||
return date < startOfDay(compareDate);
|
||||
case "isAfter":
|
||||
return date > endOfDay(compareDate);
|
||||
case "isSameDay":
|
||||
return isSameDay(date, compareDate);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const evaluateAttributeFilter = (
|
||||
attributes: TEvaluateSegmentUserAttributeData,
|
||||
filter: TSegmentAttributeFilter
|
||||
@@ -384,10 +480,21 @@ const evaluateAttributeFilter = (
|
||||
|
||||
const attributeValue = attributes[contactAttributeKey];
|
||||
if (!attributeValue) {
|
||||
// Special handling for isSet/isNotSet if needed, but compareValues handles it generically
|
||||
// However, if checks below depend on value existence, we might need to delegate earlier
|
||||
// For date operators, if no value, usually false (unless we add isNotSet for dates)
|
||||
// But compareValues handles isSet/isNotSet.
|
||||
// We should check operator type first.
|
||||
if (qualifier.operator === "isNotSet") return true;
|
||||
if (qualifier.operator === "isSet") return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const attResult = compareValues(attributeValue, value, qualifier.operator);
|
||||
if (isDateOperator(qualifier.operator)) {
|
||||
return evaluateDateFilter(attributeValue, value, qualifier.operator);
|
||||
}
|
||||
|
||||
const attResult = compareValues(attributeValue, value as string | number, qualifier.operator);
|
||||
return attResult;
|
||||
};
|
||||
|
||||
@@ -396,7 +503,7 @@ const evaluatePersonFilter = (userId: string, filter: TSegmentPersonFilter): boo
|
||||
const { personIdentifier } = root;
|
||||
|
||||
if (personIdentifier === "userId") {
|
||||
const attResult = compareValues(userId, value, qualifier.operator);
|
||||
const attResult = compareValues(userId, value as string | number, qualifier.operator);
|
||||
return attResult;
|
||||
}
|
||||
|
||||
@@ -437,7 +544,7 @@ const evaluateSegmentFilter = async (
|
||||
|
||||
const evaluateDeviceFilter = (device: "phone" | "desktop", filter: TSegmentDeviceFilter): boolean => {
|
||||
const { value, qualifier } = filter;
|
||||
return compareValues(device, value, qualifier.operator);
|
||||
return compareValues(device, value as string | number, qualifier.operator);
|
||||
};
|
||||
|
||||
export const compareValues = (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TSegmentConnector,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilter,
|
||||
TSegmentFilterValue,
|
||||
TSegmentOperator,
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
@@ -50,6 +51,18 @@ export const convertOperatorToText = (operator: TAllOperators) => {
|
||||
return "User is in";
|
||||
case "userIsNotIn":
|
||||
return "User is not in";
|
||||
case "isOlderThan":
|
||||
return "is older than";
|
||||
case "isNewerThan":
|
||||
return "is newer than";
|
||||
case "isBefore":
|
||||
return "is before";
|
||||
case "isAfter":
|
||||
return "is after";
|
||||
case "isBetween":
|
||||
return "is between";
|
||||
case "isSameDay":
|
||||
return "is on";
|
||||
default:
|
||||
return operator;
|
||||
}
|
||||
@@ -85,6 +98,18 @@ export const convertOperatorToTitle = (operator: TAllOperators) => {
|
||||
return "User is in";
|
||||
case "userIsNotIn":
|
||||
return "User is not in";
|
||||
case "isOlderThan":
|
||||
return "Is older than";
|
||||
case "isNewerThan":
|
||||
return "Is newer than";
|
||||
case "isBefore":
|
||||
return "Is before";
|
||||
case "isAfter":
|
||||
return "Is after";
|
||||
case "isBetween":
|
||||
return "Is between";
|
||||
case "isSameDay":
|
||||
return "Is on";
|
||||
default:
|
||||
return operator;
|
||||
}
|
||||
@@ -398,7 +423,7 @@ export const updateSegmentIdInFilter = (group: TBaseFilters, filterId: string, n
|
||||
}
|
||||
};
|
||||
|
||||
export const updateFilterValue = (group: TBaseFilters, filterId: string, newValue: string | number) => {
|
||||
export const updateFilterValue = (group: TBaseFilters, filterId: string, newValue: TSegmentFilterValue) => {
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
const { resource } = group[i];
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@ import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { ContactsSecondaryNavigation } from "@/modules/ee/contacts/components/contacts-secondary-navigation";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { SegmentTable } from "@/modules/ee/contacts/segments/components/segment-table";
|
||||
import { CreateSegmentModal } from "@/modules/ee/contacts/segments/components/create-segment-modal";
|
||||
import { SegmentsDataTable } from "@/modules/ee/contacts/segments/components/segments-data-table";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { CreateSegmentModal } from "./components/create-segment-modal";
|
||||
|
||||
export const SegmentsPage = async ({
|
||||
params: paramsProps,
|
||||
@@ -17,22 +17,11 @@ export const SegmentsPage = async ({
|
||||
params: Promise<{ environmentId: string }>;
|
||||
}) => {
|
||||
const params = await paramsProps;
|
||||
const t = await getTranslate();
|
||||
|
||||
const { isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [segments, contactAttributeKeys] = await Promise.all([
|
||||
getSegments(params.environmentId),
|
||||
getContactAttributeKeys(params.environmentId),
|
||||
]);
|
||||
|
||||
const t = await getTranslate();
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
|
||||
if (!segments) {
|
||||
throw new Error("Failed to fetch segments");
|
||||
}
|
||||
|
||||
const filteredSegments = segments.filter((segment) => !segment.isPrivate);
|
||||
const contactAttributeKeys = await getContactAttributeKeys(params.environmentId);
|
||||
const segments = await getSegments(params.environmentId);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -43,7 +32,7 @@ export const SegmentsPage = async ({
|
||||
<CreateSegmentModal
|
||||
environmentId={params.environmentId}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
segments={filteredSegments}
|
||||
segments={segments}
|
||||
/>
|
||||
) : undefined
|
||||
}>
|
||||
@@ -51,8 +40,8 @@ export const SegmentsPage = async ({
|
||||
</PageHeader>
|
||||
|
||||
{isContactsEnabled ? (
|
||||
<SegmentTable
|
||||
segments={filteredSegments}
|
||||
<SegmentsDataTable
|
||||
segments={segments}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isReadOnly={isReadOnly}
|
||||
|
||||
@@ -1,28 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { CalendarCheckIcon, CalendarIcon, XIcon } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import Calendar from "react-calendar";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import "./styles.css";
|
||||
|
||||
const getOrdinalSuffix = (day: number) => {
|
||||
if (day > 3 && day < 21) return "th"; // 11th, 12th, 13th, etc.
|
||||
switch (day % 10) {
|
||||
case 1:
|
||||
return "st";
|
||||
case 2:
|
||||
return "nd";
|
||||
case 3:
|
||||
return "rd";
|
||||
default:
|
||||
return "th";
|
||||
}
|
||||
};
|
||||
|
||||
interface DatePickerProps {
|
||||
date: Date | null;
|
||||
@@ -33,103 +15,52 @@ interface DatePickerProps {
|
||||
|
||||
export const DatePicker = ({ date, updateSurveyDate, minDate, onClearDate }: DatePickerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [value, onChange] = useState<Date | undefined>(date ? new Date(date) : undefined);
|
||||
const [formattedDate, setFormattedDate] = useState<string | undefined>(
|
||||
date ? format(new Date(date), "do MMM, yyyy") : undefined
|
||||
);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dateInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const onDateChange = (date: Date) => {
|
||||
if (date) {
|
||||
updateSurveyDate(date);
|
||||
const day = date.getDate();
|
||||
const ordinalSuffix = getOrdinalSuffix(day);
|
||||
const formatted = format(date, `d'${ordinalSuffix}' MMM, yyyy`);
|
||||
setFormattedDate(formatted);
|
||||
onChange(date);
|
||||
setIsOpen(false);
|
||||
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.value) {
|
||||
updateSurveyDate(new Date(e.target.value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearDate = () => {
|
||||
if (onClearDate) {
|
||||
onClearDate();
|
||||
setFormattedDate(undefined);
|
||||
onChange(undefined);
|
||||
if (dateInputRef.current) {
|
||||
dateInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{formattedDate ? (
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className={cn(
|
||||
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal",
|
||||
!formattedDate && "text-muted-foreground bg-slate-800"
|
||||
)}
|
||||
ref={btnRef}>
|
||||
<CalendarCheckIcon className="mr-2 h-4 w-4" />
|
||||
{formattedDate}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className={cn(
|
||||
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal",
|
||||
!formattedDate && "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
ref={btnRef}>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
<span>{t("common.pick_a_date")}</span>
|
||||
</Button>
|
||||
<div className="relative flex w-full items-center">
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
ref={dateInputRef}
|
||||
type="date"
|
||||
min={minDate ? format(minDate, "yyyy-MM-dd") : undefined}
|
||||
value={date ? format(date, "yyyy-MM-dd") : ""}
|
||||
onChange={handleDateChange}
|
||||
placeholder={t("common.pick_a_date")}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
!date && "text-slate-400"
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="min-w-96 rounded-lg px-4 py-3">
|
||||
<Calendar
|
||||
value={value}
|
||||
onChange={(date) => onDateChange(date as Date)}
|
||||
minDate={minDate || new Date()}
|
||||
className="!border-0"
|
||||
tileClassName={({ date }: { date: Date }) => {
|
||||
const baseClass =
|
||||
"hover:fb-bg-input-bg-selected fb-rounded-custom fb-h-9 fb-p-0 fb-mt-1 fb-font-normal fb-text-heading aria-selected:fb-opacity-100 focus:fb-ring-2 focus:fb-bg-slate-200";
|
||||
// today's date class
|
||||
if (
|
||||
date.getDate() === new Date().getDate() &&
|
||||
date.getMonth() === new Date().getMonth() &&
|
||||
date.getFullYear() === new Date().getFullYear()
|
||||
) {
|
||||
return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-heading focus:fb-ring-2 focus:fb-bg-slate-200`;
|
||||
}
|
||||
// active date class
|
||||
if (
|
||||
date.getDate() === value?.getDate() &&
|
||||
date.getMonth() === value?.getMonth() &&
|
||||
date.getFullYear() === value?.getFullYear()
|
||||
) {
|
||||
return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-heading`;
|
||||
}
|
||||
/>
|
||||
{!date && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center px-3 text-slate-400">
|
||||
{t("common.pick_a_date")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
return baseClass;
|
||||
}}
|
||||
showNeighboringMonth={false}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{formattedDate && onClearDate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
{date && onClearDate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearDate}
|
||||
className="h-8 w-8 p-0 hover:bg-slate-200">
|
||||
className="absolute right-3 rounded-sm opacity-50 hover:opacity-100 focus:outline-none">
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -81,6 +81,12 @@ enum ContactAttributeType {
|
||||
custom
|
||||
}
|
||||
|
||||
enum ContactAttributeDataType {
|
||||
text
|
||||
number
|
||||
date
|
||||
}
|
||||
|
||||
/// Defines the possible attributes that can be assigned to contacts.
|
||||
/// Acts as a schema for contact attributes within an environment.
|
||||
///
|
||||
@@ -89,17 +95,19 @@ enum ContactAttributeType {
|
||||
/// @property key - The attribute identifier used in the system
|
||||
/// @property name - Display name for the attribute
|
||||
/// @property type - Whether this is a default or custom attribute
|
||||
/// @property dataType - The data type of the attribute (text, number, date)
|
||||
/// @property environment - The environment this attribute belongs to
|
||||
model ContactAttributeKey {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
isUnique Boolean @default(false)
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
isUnique Boolean @default(false)
|
||||
key String
|
||||
name String?
|
||||
description String?
|
||||
type ContactAttributeType @default(custom)
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
type ContactAttributeType @default(custom)
|
||||
dataType ContactAttributeDataType @default(text)
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
environmentId String
|
||||
attributes ContactAttribute[]
|
||||
attributeFilters SurveyAttributeFilter[]
|
||||
|
||||
@@ -69,7 +69,7 @@ export class ApiClient {
|
||||
|
||||
async createOrUpdateUser(userUpdateInput: {
|
||||
userId: string;
|
||||
attributes?: Record<string, string>;
|
||||
attributes?: Record<string, string | number | Date>;
|
||||
}): Promise<Result<CreateOrUpdateUserResponse, ApiErrorResponse>> {
|
||||
// transform all attributes to string if attributes are present into a new attributes copy
|
||||
const attributes: Record<string, string> = {};
|
||||
|
||||
@@ -46,8 +46,9 @@ const migrateLocalStorage = (): { changed: boolean; newState?: TConfig } => {
|
||||
...personState,
|
||||
data: {
|
||||
...personState.data,
|
||||
// Copy over language from attributes if it exists
|
||||
...(attributes?.language && { language: attributes.language }),
|
||||
// Copy over language from attributes if it exists and is a string
|
||||
...(attributes?.language &&
|
||||
typeof attributes.language === "string" && { language: attributes.language }),
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import type { TAttributes } from "@/types/config";
|
||||
import { type NetworkError, type Result, okVoid } from "@/types/error";
|
||||
|
||||
export const setAttributes = async (
|
||||
attributes: Record<string, string>
|
||||
attributes: TAttributes
|
||||
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
|
||||
): Promise<Result<void, NetworkError>> => {
|
||||
const updateQueue = UpdateQueue.getInstance();
|
||||
|
||||
@@ -93,7 +93,10 @@ export class UpdateQueue {
|
||||
...config.get().user,
|
||||
data: {
|
||||
...config.get().user.data,
|
||||
language: currentUpdates.attributes?.language,
|
||||
language:
|
||||
typeof currentUpdates.attributes?.language === "string"
|
||||
? currentUpdates.attributes.language
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,7 +81,7 @@ export type TConfigUpdateInput = Omit<TConfig, "status"> & {
|
||||
};
|
||||
};
|
||||
|
||||
export type TAttributes = Record<string, string>;
|
||||
export type TAttributes = Record<string, string | number | Date>;
|
||||
|
||||
export interface TConfigInput {
|
||||
environmentId: string;
|
||||
@@ -133,7 +133,7 @@ export interface TLegacyConfigInput {
|
||||
apiHost: string;
|
||||
environmentId: string;
|
||||
userId?: string;
|
||||
attributes?: Record<string, string>;
|
||||
attributes?: TAttributes;
|
||||
}
|
||||
|
||||
export type TLegacyConfig = TConfig & {
|
||||
|
||||
@@ -4,6 +4,10 @@ export const ZContactAttributeKeyType = z.enum(["default", "custom"]);
|
||||
|
||||
export type TContactAttributeKeyType = z.infer<typeof ZContactAttributeKeyType>;
|
||||
|
||||
export const ZContactAttributeDataType = z.enum(["text", "number", "date"]);
|
||||
|
||||
export type TContactAttributeDataType = z.infer<typeof ZContactAttributeDataType>;
|
||||
|
||||
export const ZContactAttributeKey = z.object({
|
||||
id: z.string().cuid2(),
|
||||
createdAt: z.date(),
|
||||
@@ -13,6 +17,7 @@ export const ZContactAttributeKey = z.object({
|
||||
name: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
type: ZContactAttributeKeyType,
|
||||
dataType: ZContactAttributeDataType.default("text"),
|
||||
environmentId: z.string(),
|
||||
});
|
||||
|
||||
|
||||
@@ -19,5 +19,5 @@ export const ZContactAttributeUpdateInput = z.object({
|
||||
|
||||
export type TContactAttributeUpdateInput = z.infer<typeof ZContactAttributeUpdateInput>;
|
||||
|
||||
export const ZContactAttributes = z.record(z.string());
|
||||
export const ZContactAttributes = z.record(z.union([z.string(), z.number(), z.date()]));
|
||||
export type TContactAttributes = z.infer<typeof ZContactAttributes>;
|
||||
|
||||
@@ -13,12 +13,27 @@ export const ARITHMETIC_OPERATORS = ["lessThan", "lessEqual", "greaterThan", "gr
|
||||
export type TArithmeticOperator = (typeof ARITHMETIC_OPERATORS)[number];
|
||||
export const STRING_OPERATORS = ["contains", "doesNotContain", "startsWith", "endsWith"] as const;
|
||||
export type TStringOperator = (typeof STRING_OPERATORS)[number];
|
||||
|
||||
export const DATE_OPERATORS = [
|
||||
"isOlderThan",
|
||||
"isNewerThan",
|
||||
"isBefore",
|
||||
"isAfter",
|
||||
"isBetween",
|
||||
"isSameDay",
|
||||
] as const;
|
||||
export type TDateOperator = (typeof DATE_OPERATORS)[number];
|
||||
|
||||
export const TIME_UNITS = ["days", "weeks", "months", "years"] as const;
|
||||
export type TTimeUnit = (typeof TIME_UNITS)[number];
|
||||
|
||||
export const ZBaseOperator = z.enum(BASE_OPERATORS);
|
||||
export type TBaseOperator = z.infer<typeof ZBaseOperator>;
|
||||
|
||||
// An attribute filter can have these operators
|
||||
export const ATTRIBUTE_OPERATORS = [
|
||||
...BASE_OPERATORS,
|
||||
...DATE_OPERATORS,
|
||||
"isSet",
|
||||
"isNotSet",
|
||||
"contains",
|
||||
@@ -30,6 +45,15 @@ export const ATTRIBUTE_OPERATORS = [
|
||||
// the person filter currently has the same operators as the attribute filter
|
||||
// but we might want to add more operators in the future, so we keep it separated
|
||||
export const PERSON_OPERATORS = ATTRIBUTE_OPERATORS;
|
||||
export const TEXT_ATTRIBUTE_OPERATORS = [
|
||||
...BASE_OPERATORS,
|
||||
"isSet",
|
||||
"isNotSet",
|
||||
"contains",
|
||||
"doesNotContain",
|
||||
"startsWith",
|
||||
"endsWith",
|
||||
] as const;
|
||||
|
||||
// operators for segment filters
|
||||
export const SEGMENT_OPERATORS = ["userIsIn", "userIsNotIn"] as const;
|
||||
@@ -54,7 +78,12 @@ export type TDeviceOperator = z.infer<typeof ZDeviceOperator>;
|
||||
|
||||
export type TAllOperators = (typeof ALL_OPERATORS)[number];
|
||||
|
||||
export const ZSegmentFilterValue = z.union([z.string(), z.number()]);
|
||||
export const ZSegmentFilterValue = z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.object({ amount: z.number(), unit: z.enum(TIME_UNITS) }),
|
||||
z.tuple([z.string(), z.string()]),
|
||||
]);
|
||||
export type TSegmentFilterValue = z.infer<typeof ZSegmentFilterValue>;
|
||||
|
||||
// Each filter has a qualifier, which usually contains the operator for evaluating the filter.
|
||||
@@ -143,6 +172,41 @@ export const ZSegmentFilter = z
|
||||
message: "Value must be a string for string operators and a number for arithmetic operators",
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(filter) => {
|
||||
// Relative date operators
|
||||
if (
|
||||
["isOlderThan", "isNewerThan"].includes(filter.qualifier.operator as string) &&
|
||||
(typeof filter.value !== "object" ||
|
||||
Array.isArray(filter.value) ||
|
||||
!("amount" in filter.value) ||
|
||||
!("unit" in filter.value))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Absolute date operators that expect string/date
|
||||
if (
|
||||
["isBefore", "isAfter", "isSameDay"].includes(filter.qualifier.operator as string) &&
|
||||
typeof filter.value !== "string"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Between operator
|
||||
if (
|
||||
filter.qualifier.operator === "isBetween" &&
|
||||
(!Array.isArray(filter.value) || filter.value.length !== 2)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid value type for the selected operator",
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(filter) => {
|
||||
const { value, qualifier } = filter;
|
||||
|
||||
Reference in New Issue
Block a user