Compare commits

...

3 Commits

Author SHA1 Message Date
Johannes
5555112e56 feat: add typed attributes system with date filtering and comprehensive UI improvements
## Summary

Introduces a complete typed attribute system for contacts, enabling date/number/text types with auto-detection, time-based segment filtering, full CRUD operations, and unified table styling across Contacts/Segments/Attributes.

## Core Features

### 1. Typed Attribute System
- Add `ContactAttributeDataType` enum (text, number, date) to database schema
- Auto-detection logic recognizes ISO 8601 dates and numeric values
- Backwards compatible: existing attributes default to "text" type
- SDK now accepts Date objects and auto-converts to ISO strings

### 2. Date-Based Segment Filtering
- Six new operators: isOlderThan, isNewerThan, isBefore, isAfter, isBetween, isSameDay
- Relative time filtering with days/weeks/months/years units
- Custom DateFilterValue component with type-aware inputs (date pickers, time unit selectors)
- Server-side evaluation and Prisma query generation using ISO string comparison

### 3. Attribute Management UI
- New "Attributes" tab in Contacts section for schema management
- TanStack data table with search, bulk selection, and deletion
- Create modal with type selection (text/number/date)
- Icon system: Calendar (date), Hash (number), Tag (text)

### 4. Contact Attribute Editing
- Edit Attributes modal with React Hook Form and Framer Motion animations
- Type-aware inputs: text/number/date pickers based on dataType
- Add new attributes with validation (email format, date validity)
- Delete attributes with confirmation for critical fields
- Dynamic form that updates on delete/add without modal close

### 5. Table Visual Consistency
- Unified styling across Contacts, Segments, and Attributes tables
- Removed vertical borders, added rounded corners (top-left, top-right, bottom-left, bottom-right)
- Consistent header styling: h-10, font-semibold, border-b
- Hover effects: subtle bg-slate-50
- Search bar aligned with action buttons in all tables

### 6. Segments Table Performance
- Refactored from custom grid to TanStack Table
- Removed expensive N+1 survey queries (5-10x faster page load)
- Survey details lazy-loaded only when modal opens
- Columns: Title, Updated (relative), Created (formatted)

## Technical Implementation

### Database Changes
- Add ContactAttributeDataType enum to schema.prisma
- Add dataType field to ContactAttributeKey with @default(text)
- No migration of existing data required

### Backend
- Auto-detection on attribute creation (detect-attribute-type.ts)
- Date utility functions for relative time calculations (date-utils.ts)
- Extended segment evaluation logic to handle date operators
- Prisma query builders for date comparisons using string operators
- Server actions for attribute CRUD and key management

### Frontend
- 6 new components for date filtering and attribute management
- Type-conditional operator selection in segment filters
- Smart defaults: date attributes default to "isOlderThan 1 days"
- Operator/value reset when switching between attribute types
- Badge components showing data types throughout UI

### SDK
- Accept Record<string, string | Date> in setAttributes()
- Auto-convert Date objects to ISO strings
- Fully backwards compatible

### API
- V1 and V2 endpoints updated to support optional dataType parameter
- Additive changes only, no breaking changes

## UX Improvements

### Icons & Visual Indicators
- Calendar icon for date attributes everywhere (filters, modals, tables)
- Hash icon for number attributes
- Tag icon for text attributes
- Type badges on attribute rows and form fields

### Table Consistency
- All three tables (Contacts, Segments, Attributes) now have identical:
  - Border radius (rounded-lg)
  - Header height and font weight
  - Hover effects (bg-slate-50)
  - Empty states (text-slate-400)
  - Search bar positioning
- Removed unnecessary vertical borders for cleaner look
- Proper rounded corners on first/last rows

### Form Patterns
- Consistent modal patterns using React Hook Form
- Type-specific validation (email format, date validity, positive numbers)
- Clear error messages with field-level feedback
- Framer Motion animations for smooth add/delete transitions

## Files Changed

**New Files (16):**
- Attribute type detection and tests
- Date utilities and tests
- Date filter value component
- Edit attributes modal
- Attribute keys management (page, actions, components)
- Segment table refactor (columns, updated table)

**Modified Files (20):**
- Schema, types, and Zod validators
- Segment filter components and evaluation logic
- Contact table styling and column definitions
- API endpoints (V1 and V2)
- Data table header and toolbar components
- Translation files

**Deleted Files (1):**
- Old segment table component (replaced with TanStack version)

## Translation Keys

Added 30+ new keys for date operators, attribute management, and validation messages with proper plural support.

## Testing

- Unit tests for date detection edge cases
- Unit tests for date math operations (leap years, month boundaries)
- Type validation ensures filter values match operator requirements

## Backwards Compatibility

- All changes are additive and non-breaking
- Existing attributes get text type automatically
- Old segment filters continue working
- SDK signature is backwards compatible (string | Date union)
- API changes are optional parameters only

## Next Steps

- Run database migration: pnpm db:migrate:dev --name add_contact_attribute_data_type
- Sync translations to other languages via Lingo.dev
- Add Playwright E2E tests for date filtering workflows
2025-12-14 08:54:05 +01:00
Johannes
7c92e2b5bb Merge branch 'main' of https://github.com/formbricks/formbricks into feat/attribute-type-date 2025-12-12 22:54:02 +01:00
Johannes
e90bb93dfb vibe draft 2025-12-10 20:37:42 +01:00
50 changed files with 2921 additions and 228 deletions

View File

@@ -0,0 +1,404 @@
---
name: Date Attribute Type Feature
overview: Add DATE type support to the Formbricks attribute system, enabling time-based segment filters like "Sign Up Date is older than 3 months". This involves schema changes, new operators, UI components, SDK updates, and evaluation logic.
todos:
- id: schema
content: Add ContactAttributeDataType enum and dataType field to ContactAttributeKey in Prisma schema
status: completed
- id: types
content: "Update type definitions: add data type to contact-attribute-key.ts, add date operators to segment.ts"
status: completed
- id: zod
content: Update Zod schemas in packages/database/zod/ to include dataType
status: completed
- id: detect
content: Create auto-detection logic for attribute data types based on value format
status: completed
- id: attributes
content: Update attribute creation/update logic to auto-detect and persist dataType
status: completed
- id: date-utils
content: Create date utility functions for relative time calculations
status: completed
- id: eval-logic
content: Add date filter evaluation logic to segments.ts evaluateSegment function
status: completed
- id: prisma-query
content: Update prisma-query.ts to handle date comparisons in segment filters
status: completed
- id: ui-operators
content: Update segment-filter.tsx to show date-specific operators when attribute is DATE type
status: completed
- id: ui-value
content: Create date-filter-value.tsx component for date filter value input
status: completed
- id: utils
content: Add date operator text/title conversions in utils.ts
status: completed
- id: sdk
content: Update JS SDK to accept Date objects and convert to ISO strings
status: completed
- id: api
content: Update API endpoints to expose dataType in contact attribute key responses
status: completed
- id: i18n
content: Add translation keys for new operators and UI elements
status: completed
- id: tests
content: Add unit tests for date detection, evaluation, and UI components
status: completed
---
# Date Attribute Type Feature
## Current State Analysis
The attribute system currently stores all values as strings:
- `ContactAttribute.value` is `String` in Prisma schema (line 73)
- `ContactAttributeKey` has no `dataType` field - only `type` (default/custom)
- Segment filter operators are string/number-focused with no date awareness
- SDK accepts `Record<string, string>` only
## Architecture Changes
### 1. Database Schema Updates
Add `dataType` enum and field to `ContactAttributeKey`:
```prisma
enum ContactAttributeDataType {
text
number
date
}
model ContactAttributeKey {
// ... existing fields
dataType ContactAttributeDataType @default(text)
}
```
Store dates as ISO 8601 strings in `ContactAttribute.value` (no schema change needed for value column).
### 2. Type Definitions (`packages/types/`)
**`packages/types/contact-attribute-key.ts`** - Add data type:
```typescript
export const ZContactAttributeDataType = z.enum(["text", "number", "date"]);
export type TContactAttributeDataType = z.infer<typeof ZContactAttributeDataType>;
```
**`packages/types/segment.ts`** - Add date operators:
```typescript
export const DATE_OPERATORS = [
"isOlderThan", // relative: X days/weeks/months/years ago
"isNewerThan", // relative: within last X days/weeks/months/years
"isBefore", // absolute: before specific date
"isAfter", // absolute: after specific date
"isBetween", // absolute: between two dates
"isSameDay", // absolute: matches specific date
] as const;
export const TIME_UNITS = ["days", "weeks", "months", "years"] as const;
export const ZSegmentDateFilter = z.object({
id: z.string().cuid2(),
root: z.object({
type: z.literal("attribute"),
contactAttributeKey: z.string(),
}),
value: z.union([
// Relative: { amount: 3, unit: "months" }
z.object({ amount: z.number(), unit: z.enum(TIME_UNITS) }),
// Absolute: ISO date string or [start, end] for between
z.string(),
z.tuple([z.string(), z.string()]),
]),
qualifier: z.object({
operator: z.enum(DATE_OPERATORS),
}),
});
```
### 3. Auto-Detection Logic (`apps/web/modules/ee/contacts/lib/`)
Create `detect-attribute-type.ts`:
```typescript
export const detectAttributeDataType = (value: string): TContactAttributeDataType => {
// Check if valid ISO 8601 date
const date = new Date(value);
if (!isNaN(date.getTime()) && /^\d{4}-\d{2}-\d{2}/.test(value)) {
return "date";
}
// Check if numeric
if (!isNaN(Number(value)) && value.trim() !== "") {
return "number";
}
return "text";
};
```
Update `apps/web/modules/ee/contacts/lib/attributes.ts` to auto-detect and set `dataType` when creating new attribute keys.
### 4. Segment Filter UI Components
**New files in `apps/web/modules/ee/contacts/segments/components/`:**
- `date-filter-value.tsx` - Combined component for date filter value input:
- Relative time: number input + unit dropdown (days/weeks/months/years)
- Absolute date: date picker component
- Between: two date pickers for range
- Update `segment-filter.tsx`:
- Check `contactAttributeKey.dataType` to determine which operators to show
- Render appropriate value input based on operator type
- Handle date-specific validation
### 5. Filter Evaluation Logic
Update `apps/web/modules/ee/contacts/segments/lib/segments.ts`:
```typescript
const evaluateDateFilter = (
attributeValue: string,
filterValue: TDateFilterValue,
operator: TDateOperator
): boolean => {
const attrDate = new Date(attributeValue);
const now = new Date();
switch (operator) {
case "isOlderThan": {
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
return attrDate < threshold;
}
case "isNewerThan": {
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
return attrDate >= threshold;
}
case "isBefore":
return attrDate < new Date(filterValue);
case "isAfter":
return attrDate > new Date(filterValue);
case "isBetween":
return attrDate >= new Date(filterValue[0]) && attrDate <= new Date(filterValue[1]);
case "isSameDay":
return isSameDay(attrDate, new Date(filterValue));
}
};
```
### 6. Prisma Query Generation (No Raw SQL)
Update `apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts`:
Since dates are stored as ISO 8601 strings, lexicographic string comparison works correctly (e.g., `"2024-01-15" < "2024-02-01"`). Calculate threshold dates in JavaScript and pass as ISO strings:
```typescript
const buildDateAttributeFilterWhereClause = (filter: TSegmentDateFilter): Prisma.ContactWhereInput => {
const { root, qualifier, value } = filter;
const { operator } = qualifier;
const now = new Date();
let dateCondition: Prisma.StringFilter = {};
switch (operator) {
case "isOlderThan": {
const threshold = subtractTimeUnit(now, value.amount, value.unit);
dateCondition = { lt: threshold.toISOString() };
break;
}
case "isNewerThan": {
const threshold = subtractTimeUnit(now, value.amount, value.unit);
dateCondition = { gte: threshold.toISOString() };
break;
}
case "isBefore":
dateCondition = { lt: value };
break;
case "isAfter":
dateCondition = { gt: value };
break;
case "isBetween":
dateCondition = { gte: value[0], lte: value[1] };
break;
case "isSameDay": {
const dayStart = startOfDay(new Date(value)).toISOString();
const dayEnd = endOfDay(new Date(value)).toISOString();
dateCondition = { gte: dayStart, lte: dayEnd };
break;
}
}
return {
attributes: {
some: {
attributeKey: { key: root.contactAttributeKey },
value: dateCondition,
},
},
};
};
```
## Backwards Compatibility Concerns
### 1. API Response Changes (Non-Breaking)
- **Concern**: Adding `dataType` to `ContactAttributeKey` responses
- **Solution**: This is an additive change - existing clients ignore unknown fields
- **Action**: No breaking change, just document the new field
### 2. API Request Changes (Non-Breaking)
- **Concern**: Existing integrations create attributes without specifying `dataType`
- **Solution**: Make `dataType` optional in create/update requests; auto-detect from value if not provided
- **Action**: Default to auto-detection, allow explicit override
### 3. SDK Signature Change (Backwards Compatible)
- **Concern**: Current signature `Record<string, string>` changing to `Record<string, string | Date>`
- **Solution**: TypeScript union types are backwards compatible - existing string values work
- **Action**: Existing code continues to work; Date objects are a new optional capability
### 4. Existing Segment Filters (Critical)
- **Concern**: Existing filters in database use current operator format
- **Solution**:
- Keep all existing operators functional
- Date operators only appear in UI when attribute has `dataType: "date"`
- Filter evaluation checks operator type and routes to appropriate handler
- **Action**: Add `isDateOperator()` check in evaluation logic
### 5. Filter Value Schema Change (Requires Careful Handling)
- **Concern**: Current `TSegmentFilterValue = string | number`, dates need `{ amount, unit }` for relative
- **Solution**: Extend the union type, not replace:
```typescript
export const ZSegmentFilterValue = z.union([
z.string(),
z.number(),
z.object({ amount: z.number(), unit: z.enum(TIME_UNITS) }), // NEW
z.tuple([z.string(), z.string()]), // NEW: for "between" operator
]);
```
- **Action**: Existing filters parse correctly; new format only used for date operators
### 6. Database Migration (Safe)
- **Concern**: Adding `dataType` column to existing `ContactAttributeKey` rows
- **Solution**:
- Add column with `@default(text)`
- All existing attributes become `text` type automatically
- No data transformation needed
- **Action**: Simple additive migration, no downtime
### 7. Segment Evaluation at Runtime
- **Concern**: Old segments with text operators should not break
- **Solution**:
- `evaluateAttributeFilter()` checks if operator is date-specific
- If yes, calls `evaluateDateFilter()`
- If no, uses existing `compareValues()` logic
- **Action**: Add operator type routing in evaluation
### 8. Client-Side Segment Evaluation (JS SDK)
- **Concern**: SDK may evaluate segments client-side for performance
- **Solution**: Ensure SDK's segment evaluation logic also handles date operators
- **Action**: Update `packages/js-core` if client-side evaluation exists
### Version Matrix
| Component | Breaking Change | Migration Required |
|-----------|-----------------|-------------------|
| Database Schema | No | Yes (additive) |
| REST API | No | No |
| JS SDK | No | No (optional upgrade) |
| Existing Segments | No | No |
| UI | No | No |
### 7. SDK Updates (`packages/js-core/`)
Update `packages/js-core/src/lib/user/attribute.ts`:
```typescript
export const setAttributes = async (
attributes: Record<string, string | Date>
): Promise<Result<void, NetworkError>> => {
// Convert Date objects to ISO strings
const normalizedAttributes = Object.fromEntries(
Object.entries(attributes).map(([key, value]) => [
key,
value instanceof Date ? value.toISOString() : value,
])
);
// ... rest of implementation
};
```
### 8. API Updates
Update attribute endpoints to include `dataType` in responses:
- `apps/web/modules/api/v2/management/contact-attribute-keys/`
- `apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/`
## Files to Modify
| File | Change |
|------|--------|
| `packages/database/schema.prisma` | Add `ContactAttributeDataType` enum, add `dataType` field |
| `packages/types/contact-attribute-key.ts` | Add data type definitions |
| `packages/types/segment.ts` | Add date operators, time units, date filter schema |
| `packages/database/zod/contact-attribute-keys.ts` | Add dataType to zod schema |
| `apps/web/modules/ee/contacts/lib/attributes.ts` | Auto-detect dataType on attribute creation |
| `apps/web/modules/ee/contacts/segments/lib/segments.ts` | Add date filter evaluation |
| `apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts` | Add date query building |
| `apps/web/modules/ee/contacts/segments/lib/utils.ts` | Add date operator text/title conversions |
| `apps/web/modules/ee/contacts/segments/components/segment-filter.tsx` | Conditionally render date operators/inputs |
| `packages/js-core/src/lib/user/attribute.ts` | Accept Date objects |
## New Files to Create
| File | Purpose |
|------|---------|
| `apps/web/modules/ee/contacts/lib/detect-attribute-type.ts` | Auto-detection logic |
| `apps/web/modules/ee/contacts/segments/components/date-filter-value.tsx` | Date filter value UI |
| `apps/web/modules/ee/contacts/segments/lib/date-utils.ts` | Date comparison utilities |
## Migration
Create Prisma migration:
```bash
pnpm db:migrate:dev --name add_contact_attribute_data_type
```
Default existing attributes to `text` dataType (no data migration needed).

View File

@@ -0,0 +1,3 @@
import { AttributesPage } from "@/modules/ee/contacts/attributes/page";
export default AttributesPage;

View File

@@ -183,8 +183,10 @@
"customer_success": "Customer Success",
"dark_overlay": "Dark overlay",
"date": "Date",
"days": "days",
"default": "Default",
"delete": "Delete",
"delete_selected": "{count, plural, one {Delete # item} other {Delete # items}}",
"description": "Description",
"dev_env": "Dev Environment",
"development_environment_banner": "You're in a development environment. Set it up to test surveys, actions and attributes.",
@@ -202,6 +204,7 @@
"email": "Email",
"ending_card": "Ending card",
"enter_url": "Enter URL",
"enter_value": "Enter value",
"enterprise_license": "Enterprise License",
"environment_not_found": "Environment not found",
"environment_notice": "You're currently in the {environment} environment.",
@@ -268,6 +271,7 @@
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
"mobile_overlay_surveys_look_good": "Don't worry your surveys look great on every device and screen size!",
"mobile_overlay_title": "Oops, tiny screen detected!",
"months": "months",
"move_down": "Move down",
"move_up": "Move up",
"multiple_languages": "Multiple languages",
@@ -295,6 +299,7 @@
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
"option_id": "Option ID",
"option_ids": "Option IDs",
"optional": "Optional",
"or": "or",
"organization": "Organization",
"organization_id": "Organization ID",
@@ -430,6 +435,7 @@
"user": "User",
"user_id": "User ID",
"user_not_found": "User not found",
"value": "Value",
"variable": "Variable",
"variable_ids": "Variable IDs",
"variables": "Variables",
@@ -442,7 +448,9 @@
"website_and_app_connection": "Website & App Connection",
"website_app_survey": "Website & App Survey",
"website_survey": "Website Survey",
"weeks": "weeks",
"welcome_card": "Welcome card",
"years": "years",
"you": "You",
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
"you_are_not_authorised_to_perform_this_action": "You are not authorised to perform this action.",
@@ -596,14 +604,34 @@
"waiting_for_your_signal": "Waiting for your signal..."
},
"contacts": {
"add_attribute": "Add Attribute",
"attribute_added_successfully": "Attribute added successfully",
"attribute_deleted_successfully": "Attribute deleted successfully",
"attribute_description_placeholder": "When the user signed up",
"attribute_key": "Attribute Key",
"attribute_key_created_successfully": "Attribute key created successfully",
"attribute_key_description": "Unique identifier (e.g., signUpDate, planType)",
"attribute_keys_deleted_successfully": "{count, plural, one {Attribute key deleted successfully} other {# attribute keys deleted successfully}}",
"attribute_name_description": "Human-readable display name",
"attributes_updated_successfully": "Attributes updated successfully",
"confirm_delete_attribute": "Are you sure you want to delete the {attributeName} attribute? This cannot be undone.",
"contact_deleted_successfully": "Contact deleted successfully",
"contact_not_found": "No such contact found",
"contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully",
"create_attribute": "Create attribute",
"create_attribute_key": "Create Attribute Key",
"data_type": "Data Type",
"data_type_description": "Choose how this attribute should be stored and filtered",
"delete_attribute_keys_warning_detailed": "{count, plural, one {Deleting this attribute key will permanently remove all attribute values across all contacts in this environment. Any segments or filters using this attribute will stop working. This action cannot be undone.} other {Deleting these # attribute keys will permanently remove all attribute values across all contacts in this environment. Any segments or filters using these attributes will stop working. This action cannot be undone.}}",
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.",
"delete_contact_confirmation_with_quotas": "{value, plural, one {This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost. If this contact has responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.} other {This will delete all survey responses and contact attributes associated with these contacts. Any targeting and personalization based on these contacts' data will be lost. If these contacts have responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.}}",
"edit_attributes": "Edit Attributes",
"generate_personal_link": "Generate Personal Link",
"generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.",
"invalid_date_value": "Invalid date value",
"invalid_email_value": "Invalid email address",
"no_custom_attributes_yet": "No custom attribute keys yet. Create one to get started.",
"no_published_link_surveys_available": "No published link surveys available. Please publish a link survey first.",
"no_published_surveys": "No published surveys",
"no_responses_found": "No responses found",
@@ -612,9 +640,12 @@
"personal_link_generated_but_clipboard_failed": "Personal link generated but failed to copy to clipboard: {url}",
"personal_survey_link": "Personal Survey Link",
"please_select_a_survey": "Please select a survey",
"search_contact": "Search contact",
"please_select_attribute_and_value": "Please select an attribute and enter a value",
"search_attribute_keys": "Search attribute keys...",
"search_contact": "Search contacts...",
"select_a_survey": "Select a survey",
"select_attribute": "Select Attribute",
"selected_attribute_keys": "{count, plural, one {# attribute key} other {# attribute keys}}",
"unlock_contacts_description": "Manage contacts and send out targeted surveys",
"unlock_contacts_title": "Unlock contacts with a higher plan",
"upload_contacts_modal_attributes_description": "Map the columns in your CSV to the attributes in Formbricks.",
@@ -960,6 +991,7 @@
"user_targeting_is_currently_only_available_when": "User targeting is currently only available when",
"value_cannot_be_empty": "Value cannot be empty.",
"value_must_be_a_number": "Value must be a number.",
"value_must_be_positive": "Value must be a positive number.",
"view_filters": "View filters",
"where": "Where",
"with_the_formbricks_sdk": "with the Formbricks SDK"

View File

@@ -37,7 +37,7 @@ export const getContactAttributeKeys = reactCache(
export const createContactAttributeKey = async (
contactAttributeKey: TContactAttributeKeyInput
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
const { environmentId, name, description, key } = contactAttributeKey;
const { environmentId, name, description, key, dataType } = contactAttributeKey;
try {
const prismaData: Prisma.ContactAttributeKeyCreateInput = {
@@ -49,6 +49,8 @@ export const createContactAttributeKey = async (
name,
description,
key,
// If dataType is provided, use it; otherwise Prisma will use the default (text)
...(dataType && { dataType }),
};
const createdContactAttributeKey = await prisma.contactAttributeKey.create({

View File

@@ -27,10 +27,15 @@ export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
key: true,
name: true,
description: true,
dataType: true,
environmentId: true,
}).openapi({
ref: "contactAttributeKeyInput",
description: "Input data for creating or updating a contact attribute",
});
})
.extend({
dataType: ZContactAttributeKey.shape.dataType.optional(),
})
.openapi({
ref: "contactAttributeKeyInput",
description: "Input data for creating or updating a contact attribute",
});
export type TContactAttributeKeyInput = z.infer<typeof ZContactAttributeKeyInput>;

View File

@@ -1,12 +1,17 @@
"use server";
import { revalidatePath } from "next/cache";
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";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
const ZGeneratePersonalSurveyLinkAction = z.object({
contactId: ZId,
@@ -58,3 +63,105 @@ export const generatePersonalSurveyLinkAction = authenticatedActionClient
surveyUrl: result.data,
};
});
const ZUpdateContactAttributesAction = z.object({
contactId: ZId,
attributes: ZContactAttributes,
});
export const updateContactAttributesAction = authenticatedActionClient
.schema(ZUpdateContactAttributesAction)
.action(async ({ ctx, parsedInput }) => {
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 contact = await getContact(parsedInput.contactId);
if (!contact) {
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
}
// Get userId from contact attributes
const userIdAttribute = await prisma.contactAttribute.findFirst({
where: {
contactId: parsedInput.contactId,
attributeKey: { key: "userId" },
},
select: { value: true },
});
if (!userIdAttribute) {
throw new InvalidInputError("Contact does not have a userId attribute");
}
const result = await updateAttributes(
parsedInput.contactId,
userIdAttribute.value,
contact.environmentId,
parsedInput.attributes
);
revalidatePath(`/environments/${contact.environmentId}/contacts/${parsedInput.contactId}`);
return result;
});
const ZDeleteContactAttributeAction = z.object({
contactId: ZId,
attributeKey: z.string(),
});
export const deleteContactAttributeAction = authenticatedActionClient
.schema(ZDeleteContactAttributeAction)
.action(async ({ ctx, parsedInput }) => {
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 contact = await getContact(parsedInput.contactId);
if (!contact) {
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
}
// Delete the attribute
await prisma.contactAttribute.deleteMany({
where: {
contactId: parsedInput.contactId,
attributeKey: { key: parsedInput.attributeKey },
},
});
revalidatePath(`/environments/${contact.environmentId}/contacts/${parsedInput.contactId}`);
return { success: true };
});

View File

@@ -1,12 +1,20 @@
import { getResponsesByContactId } from "@/lib/response/service";
import { getTranslate } from "@/lingodotdev/server";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import {
getContactAttributes,
getContactAttributesWithMetadata,
} from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { Badge } from "@/modules/ui/components/badge";
import { IdBadge } from "@/modules/ui/components/id-badge";
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
const t = await getTranslate();
const [contact, attributes] = await Promise.all([getContact(contactId), getContactAttributes(contactId)]);
const [contact, attributes, attributesWithMetadata] = await Promise.all([
getContact(contactId),
getContactAttributes(contactId),
getContactAttributesWithMetadata(contactId),
]);
if (!contact) {
throw new Error(t("environments.contacts.contact_not_found"));
@@ -53,13 +61,16 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{contact.id}</dd>
</div>
{Object.entries(attributes)
.filter(([key, _]) => key !== "email" && key !== "userId" && key !== "language")
.map(([key, attributeData]) => {
{attributesWithMetadata
.filter((attr) => attr.key !== "email" && attr.key !== "userId" && attr.key !== "language")
.map((attr) => {
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>
<div key={attr.key}>
<dt className="flex items-center gap-2 text-sm font-medium text-slate-500">
<span>{attr.name || attr.key}</span>
<Badge text={attr.dataType} type="gray" size="tiny" />
</dt>
<dd className="mt-1 text-sm text-slate-900">{attr.value}</dd>
</div>
);
})}

View File

@@ -1,23 +1,34 @@
"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 { TContactAttributeDataType, 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 AttributeWithMetadata {
key: string;
name: string | null;
value: string;
dataType: TContactAttributeDataType;
}
interface ContactControlBarProps {
environmentId: string;
contactId: string;
isReadOnly: boolean;
isQuotasAllowed: boolean;
publishedLinkSurveys: PublishedLinkSurvey[];
attributes: AttributeWithMetadata[];
allAttributeKeys: TContactAttributeKey[];
}
export const ContactControlBar = ({
@@ -26,12 +37,15 @@ export const ContactControlBar = ({
isReadOnly,
isQuotasAllowed,
publishedLinkSurveys,
attributes,
allAttributeKeys,
}: 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);
@@ -53,6 +67,14 @@ export const ContactControlBar = ({
}
const iconActions = [
{
icon: PencilIcon,
tooltip: t("environments.contacts.edit_attributes"),
onClick: () => {
setIsEditAttributesModalOpen(true);
},
isVisible: true,
},
{
icon: LinkIcon,
tooltip: t("environments.contacts.generate_personal_link"),
@@ -88,6 +110,13 @@ export const ContactControlBar = ({
: t("environments.contacts.delete_contact_confirmation")
}
/>
<EditAttributesModal
open={isEditAttributesModalOpen}
setOpen={setIsEditAttributesModalOpen}
contactId={contactId}
attributes={attributes}
allAttributeKeys={allAttributeKeys}
/>
<GeneratePersonalLinkModal
open={isGenerateLinkModalOpen}
setOpen={setIsGenerateLinkModalOpen}

View File

@@ -0,0 +1,426 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { AnimatePresence, motion } from "framer-motion";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
deleteContactAttributeAction,
updateContactAttributesAction,
} from "@/modules/ee/contacts/[contactId]/actions";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface AttributeWithMetadata {
key: string;
name: string | null;
value: string;
dataType: TContactAttributeDataType;
}
interface EditAttributesModalProps {
contactId: string;
attributes: AttributeWithMetadata[];
allAttributeKeys: TContactAttributeKey[];
open: boolean;
setOpen: (open: boolean) => void;
}
export function EditAttributesModal({
contactId,
attributes,
allAttributeKeys,
open,
setOpen,
}: EditAttributesModalProps) {
const { t } = useTranslation();
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [deletedKeys, setDeletedKeys] = useState<Set<string>>(new Set());
const [deletingKeys, setDeletingKeys] = useState<Set<string>>(new Set());
const [selectedNewAttributeKey, setSelectedNewAttributeKey] = useState<string>("");
const [newAttributeValue, setNewAttributeValue] = useState<string>("");
// Reset deleted keys when modal opens
useEffect(() => {
if (open) {
setDeletedKeys(new Set());
}
}, [open]);
// Filter out protected attributes and locally deleted ones
const editableAttributes = useMemo(() => {
return attributes.filter(
(attr) => attr.key !== "contactId" && attr.key !== "userId" && !deletedKeys.has(attr.key)
);
}, [attributes, deletedKeys]);
// Get available attribute keys that are not yet assigned to this contact (including deleted ones)
const availableAttributeKeys = useMemo(() => {
const currentKeys = new Set(editableAttributes.map((attr) => attr.key));
return allAttributeKeys.filter((key) => !currentKeys.has(key.key) && key.key !== "userId");
}, [editableAttributes, allAttributeKeys]);
const selectedAttributeKey = useMemo(() => {
return allAttributeKeys.find((key) => key.key === selectedNewAttributeKey);
}, [selectedNewAttributeKey, allAttributeKeys]);
// Create schema dynamically based on current editable attributes
const attributeSchema = useMemo(() => {
return z.object(
editableAttributes.reduce(
(acc, attr) => {
// Add specific validation for known attributes
if (attr.key === "email") {
acc[attr.key] = z.string().email({ message: "Invalid email address" });
} else if (attr.key === "language") {
acc[attr.key] = z.string().min(2, { message: "Language code must be at least 2 characters" });
} else {
// Generic string validation for other attributes
acc[attr.key] = z.string();
}
return acc;
},
{} as Record<string, z.ZodString | z.ZodEffects<z.ZodString>>
)
);
}, [editableAttributes]);
type TAttributeForm = z.infer<typeof attributeSchema>;
const form = useForm<TAttributeForm>({
resolver: zodResolver(attributeSchema),
defaultValues: editableAttributes.reduce(
(acc, attr) => {
acc[attr.key] = attr.value;
return acc;
},
{} as Record<string, string>
),
});
// Update form when editable attributes change
useEffect(() => {
const newDefaults = editableAttributes.reduce(
(acc, attr) => {
acc[attr.key] = attr.value;
return acc;
},
{} as Record<string, string>
);
form.reset(newDefaults);
}, [editableAttributes, form]);
const onSubmit = async (data: TAttributeForm) => {
setIsSubmitting(true);
try {
const result = await updateContactAttributesAction({
contactId,
attributes: data,
});
if (result?.data) {
toast.success(t("environments.contacts.attributes_updated_successfully"));
router.refresh();
setOpen(false);
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsSubmitting(false);
}
};
const handleDeleteAttribute = async (attributeKey: string) => {
// Confirm deletion for important attributes
if (attributeKey === "email" || attributeKey === "language") {
const confirmed = globalThis.confirm(
t("environments.contacts.confirm_delete_attribute", {
attributeName: attributeKey,
})
);
if (!confirmed) return;
}
setDeletingKeys((prev) => new Set(prev).add(attributeKey));
try {
const result = await deleteContactAttributeAction({
contactId,
attributeKey,
});
if (result?.data) {
toast.success(t("environments.contacts.attribute_deleted_successfully"));
// Mark as deleted locally and remove from form
setDeletedKeys((prev) => new Set(prev).add(attributeKey));
form.unregister(attributeKey);
router.refresh();
// Keep modal open so user can see the attribute is now available to add
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setDeletingKeys((prev) => {
const newSet = new Set(prev);
newSet.delete(attributeKey);
return newSet;
});
}
};
const handleAddAttribute = async () => {
if (!selectedNewAttributeKey || !newAttributeValue) {
toast.error(t("environments.contacts.please_select_attribute_and_value"));
return;
}
// Validate based on data type
const selectedKey = selectedAttributeKey;
if (selectedKey?.dataType === "date") {
const date = new Date(newAttributeValue);
if (Number.isNaN(date.getTime())) {
toast.error(t("environments.contacts.invalid_date_value"));
return;
}
} else if (selectedKey?.key === "email") {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(newAttributeValue)) {
toast.error(t("environments.contacts.invalid_email_value"));
return;
}
}
try {
const result = await updateContactAttributesAction({
contactId,
attributes: {
[selectedNewAttributeKey]: newAttributeValue,
},
});
if (result?.data) {
toast.success(t("environments.contacts.attribute_added_successfully"));
// Add to form dynamically
form.setValue(selectedNewAttributeKey, newAttributeValue);
// Remove from deleted keys if it was previously deleted
setDeletedKeys((prev) => {
const newSet = new Set(prev);
newSet.delete(selectedNewAttributeKey);
return newSet;
});
setSelectedNewAttributeKey("");
setNewAttributeValue("");
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("environments.contacts.edit_attributes")}</DialogTitle>
</DialogHeader>
<DialogBody className="max-h-[60vh] overflow-y-auto pb-4 pr-6">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<AnimatePresence mode="popLayout">
{editableAttributes.map((attr) => (
<motion.div
key={attr.key}
layout
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}>
<FormField
control={form.control}
name={attr.key}
render={({ field }) => (
<FormItem>
<FormLabel>
<div className="flex items-center gap-2">
<span>{attr.name || attr.key}</span>
<Badge text={attr.dataType} type="gray" size="tiny" />
</div>
</FormLabel>
<FormControl>
<div className="flex items-center gap-2">
{attr.dataType === "date" ? (
<Input
type="date"
{...field}
value={field.value ? field.value.split("T")[0] : ""}
onChange={(e) => {
const dateValue = e.target.value
? new Date(e.target.value).toISOString()
: "";
field.onChange(dateValue);
}}
/>
) : attr.dataType === "number" ? (
<Input type="number" {...field} />
) : (
<Input type="text" {...field} />
)}
<Button
type="button"
variant="outline"
size="icon"
onClick={() => handleDeleteAttribute(attr.key)}
disabled={deletingKeys.has(attr.key)}
loading={deletingKeys.has(attr.key)}
title={t("common.delete")}>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
</FormControl>
<FormError />
</FormItem>
)}
/>
</motion.div>
))}
</AnimatePresence>
{/* Add New Attribute Section */}
{availableAttributeKeys.length > 0 && (
<>
<hr className="my-6" />
<div className="space-y-3">
<h3 className="text-sm font-medium text-slate-900">
{t("environments.contacts.add_attribute")}
</h3>
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs text-slate-600">
{t("environments.contacts.select_attribute")}
</label>
<Select
value={selectedNewAttributeKey}
onValueChange={(value) => {
setSelectedNewAttributeKey(value);
setNewAttributeValue("");
}}>
<SelectTrigger>
<SelectValue placeholder={t("environments.contacts.select_attribute")} />
</SelectTrigger>
<SelectContent>
{availableAttributeKeys.map((key) => (
<SelectItem key={key.id} value={key.key}>
<div className="flex items-center gap-2">
<Badge text={key.dataType} type="gray" size="tiny" />
<span>{key.name || key.key}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedNewAttributeKey && (
<div className="flex-1">
<label className="mb-1 block text-xs text-slate-600">{t("common.value")}</label>
{selectedAttributeKey?.dataType === "date" ? (
<Input
type="date"
value={newAttributeValue ? newAttributeValue.split("T")[0] : ""}
onChange={(e) => {
const dateValue = e.target.value
? new Date(e.target.value).toISOString()
: "";
setNewAttributeValue(dateValue);
}}
/>
) : selectedAttributeKey?.dataType === "number" ? (
<Input
type="number"
value={newAttributeValue}
onChange={(e) => setNewAttributeValue(e.target.value)}
placeholder={t("common.enter_value")}
/>
) : (
<Input
type="text"
value={newAttributeValue}
onChange={(e) => setNewAttributeValue(e.target.value)}
placeholder={t("common.enter_value")}
/>
)}
</div>
)}
<Button
type="button"
onClick={handleAddAttribute}
disabled={!selectedNewAttributeKey || !newAttributeValue}>
<PlusIcon className="h-4 w-4" />
{t("common.add")}
</Button>
</div>
</div>
</>
)}
</form>
</FormProvider>
</DialogBody>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={form.handleSubmit(onSubmit)}
disabled={isSubmitting || !form.formState.isDirty}
loading={isSubmitting}>
{t("common.save_changes")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,7 +2,11 @@ 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 { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import {
getContactAttributes,
getContactAttributesWithMetadata,
} from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { getPublishedLinkSurveys } from "@/modules/ee/contacts/lib/surveys";
import { getContactIdentifier } from "@/modules/ee/contacts/lib/utils";
@@ -21,11 +25,20 @@ export const SingleContactPage = async (props: {
const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
const [environmentTags, contact, contactAttributes, publishedLinkSurveys] = await Promise.all([
const [
environmentTags,
contact,
contactAttributes,
publishedLinkSurveys,
attributesWithMetadata,
allAttributeKeys,
] = await Promise.all([
getTagsByEnvironmentId(params.environmentId),
getContact(params.contactId),
getContactAttributes(params.contactId),
getPublishedLinkSurveys(params.environmentId),
getContactAttributesWithMetadata(params.contactId),
getContactAttributeKeys(params.environmentId),
]);
if (!contact) {
@@ -42,6 +55,8 @@ export const SingleContactPage = async (props: {
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
publishedLinkSurveys={publishedLinkSurveys}
attributes={attributesWithMetadata}
allAttributeKeys={allAttributeKeys}
/>
);
};

View File

@@ -65,6 +65,7 @@ export const updateContactAttributeKey = async (
description: data.description,
name: data.name,
key: data.key,
...(data.dataType && { dataType: data.dataType }),
},
});

View File

@@ -1,9 +1,11 @@
import { z } from "zod";
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
export const ZContactAttributeKeyCreateInput = z.object({
key: z.string(),
description: z.string().optional(),
type: z.enum(["custom"]),
dataType: ZContactAttributeDataType.optional(),
environmentId: z.string(),
name: z.string().optional(),
});
@@ -13,6 +15,7 @@ export const ZContactAttributeKeyUpdateInput = z.object({
description: z.string().optional(),
name: z.string().optional(),
key: z.string().optional(),
dataType: ZContactAttributeDataType.optional(),
});
export type TContactAttributeKeyUpdateInput = z.infer<typeof ZContactAttributeKeyUpdateInput>;

View File

@@ -47,6 +47,7 @@ export const createContactAttributeKey = async (
name: data.name ?? data.key,
type: data.type,
description: data.description ?? "",
...(data.dataType && { dataType: data.dataType }),
environment: {
connect: {
id: environmentId,

View File

@@ -0,0 +1,99 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "@/lib/environment/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { deleteContactAttributeKey } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key";
import { createContactAttributeKey } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys";
const ZCreateAttributeKeyAction = z.object({
environmentId: ZId,
key: z.string().min(1),
name: z.string().optional(),
description: z.string().optional(),
dataType: ZContactAttributeDataType,
});
export const createAttributeKeyAction = authenticatedActionClient
.schema(ZCreateAttributeKeyAction)
.action(async ({ ctx, parsedInput }) => {
const environment = await getEnvironment(parsedInput.environmentId);
if (!environment) {
throw new ResourceNotFoundError("Environment", parsedInput.environmentId);
}
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: environment.projectId,
},
],
});
const attributeKey = await createContactAttributeKey(parsedInput.environmentId, {
key: parsedInput.key,
name: parsedInput.name,
description: parsedInput.description,
type: "custom",
dataType: parsedInput.dataType,
environmentId: parsedInput.environmentId,
});
revalidatePath(`/environments/${parsedInput.environmentId}/attributes`);
return attributeKey;
});
const ZDeleteAttributeKeyAction = z.object({
environmentId: ZId,
attributeKeyId: ZId,
});
export const deleteAttributeKeyAction = authenticatedActionClient
.schema(ZDeleteAttributeKeyAction)
.action(async ({ ctx, parsedInput }) => {
const environment = await getEnvironment(parsedInput.environmentId);
if (!environment) {
throw new ResourceNotFoundError("Environment", parsedInput.environmentId);
}
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: environment.projectId,
},
],
});
const result = await deleteContactAttributeKey(parsedInput.attributeKeyId);
revalidatePath(`/environments/${parsedInput.environmentId}/attributes`);
return result;
});

View File

@@ -0,0 +1,214 @@
"use client";
import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { SearchBar } from "@/modules/ui/components/search-bar";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { deleteAttributeKeyAction } from "../actions";
import { generateAttributeKeysTableColumns } from "./attribute-keys-table-columns";
interface AttributeKeysManagerProps {
environmentId: string;
attributeKeys: TContactAttributeKey[];
isReadOnly: boolean;
}
export function AttributeKeysManager({
environmentId,
attributeKeys,
isReadOnly,
}: AttributeKeysManagerProps) {
const { t } = useTranslation();
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeletingKeys, setIsDeletingKeys] = useState(false);
const [rowSelection, setRowSelection] = useState({});
const [searchValue, setSearchValue] = useState("");
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
// Filter to only show custom attribute keys
const customAttributeKeys = useMemo(() => {
return attributeKeys.filter((key) => key.type === "custom");
}, [attributeKeys]);
// Filter by search
const filteredAttributeKeys = useMemo(() => {
if (!searchValue) return customAttributeKeys;
return customAttributeKeys.filter((key) => {
const searchLower = searchValue.toLowerCase();
return (
key.key.toLowerCase().includes(searchLower) ||
key.name?.toLowerCase().includes(searchLower) ||
key.description?.toLowerCase().includes(searchLower)
);
});
}, [customAttributeKeys, searchValue]);
const columns = useMemo(() => {
return generateAttributeKeysTableColumns(isReadOnly);
}, [isReadOnly]);
const table = useReactTable({
data: filteredAttributeKeys,
columns,
state: {
rowSelection,
columnVisibility,
},
enableRowSelection: !isReadOnly,
onRowSelectionChange: setRowSelection,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
});
const selectedRows = table.getFilteredSelectedRowModel().rows;
const selectedAttributeKeyIds = selectedRows.map((row) => row.original.id);
const handleBulkDelete = async () => {
if (selectedAttributeKeyIds.length === 0) return;
setIsDeletingKeys(true);
try {
const deletePromises = selectedAttributeKeyIds.map((id) =>
deleteAttributeKeyAction({ environmentId, attributeKeyId: id })
);
await Promise.all(deletePromises);
toast.success(
t("environments.contacts.attribute_keys_deleted_successfully", {
count: selectedAttributeKeyIds.length,
})
);
setRowSelection({});
router.refresh();
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsDeletingKeys(false);
setDeleteDialogOpen(false);
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<SearchBar
value={searchValue}
onChange={setSearchValue}
placeholder={t("environments.contacts.search_attribute_keys")}
/>
</div>
{/* Toolbar with bulk actions */}
{!isReadOnly && selectedRows.length > 0 && (
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-4 py-3">
<p className="text-sm text-slate-700">
{t("environments.contacts.selected_attribute_keys", { count: selectedRows.length })}
</p>
<Button variant="destructive" size="sm" onClick={() => setDeleteDialogOpen(true)}>
{t("common.delete_selected", { count: selectedRows.length })}
</Button>
</div>
)}
{/* Data Table */}
<div className="rounded-lg border border-slate-200">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="rounded-t-lg">
{headerGroup.headers.map((header, index) => {
const isFirstHeader = index === 0;
const isLastHeader = index === headerGroup.headers.length - 1;
// Skip rendering checkbox in the header for selection column
if (header.id === "select") {
return (
<TableHead
key={header.id}
className="h-10 w-12 rounded-tl-lg border-b border-slate-200 bg-white px-4 font-semibold"
/>
);
}
return (
<TableHead
key={header.id}
className={`h-10 border-b border-slate-200 bg-white px-4 font-semibold ${
isFirstHeader ? "rounded-tl-lg" : isLastHeader ? "rounded-tr-lg" : ""
}`}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row, index) => {
const isLastRow = index === table.getRowModel().rows.length - 1;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={`hover:bg-white ${isLastRow ? "rounded-b-lg" : ""}`}>
{row.getVisibleCells().map((cell, cellIndex) => {
const isFirstCell = cellIndex === 0;
const isLastCell = cellIndex === row.getVisibleCells().length - 1;
return (
<TableCell
key={cell.id}
className={`py-2 ${
isLastRow
? isFirstCell
? "rounded-bl-lg"
: isLastCell
? "rounded-br-lg"
: ""
: ""
}`}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
);
})
) : (
<TableRow className="hover:bg-white">
<TableCell colSpan={columns.length} className="h-24 text-center">
<p className="text-slate-400">{t("environments.contacts.no_custom_attributes_yet")}</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Delete Confirmation Dialog */}
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat={
selectedRows.length === 1
? selectedRows[0].original.name || selectedRows[0].original.key
: t("environments.contacts.selected_attribute_keys", { count: selectedRows.length })
}
onDelete={handleBulkDelete}
isDeleting={isDeletingKeys}
text={t("environments.contacts.delete_attribute_keys_warning_detailed", {
count: selectedRows.length,
})}
/>
</div>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { format, formatDistanceToNow } from "date-fns";
import { Calendar1Icon, HashIcon, TagIcon } from "lucide-react";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { Badge } from "@/modules/ui/components/badge";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { IdBadge } from "@/modules/ui/components/id-badge";
const getIconForDataType = (dataType: string) => {
switch (dataType) {
case "date":
return <Calendar1Icon className="h-4 w-4 text-slate-600" />;
case "number":
return <HashIcon className="h-4 w-4 text-slate-600" />;
default:
return <TagIcon className="h-4 w-4 text-slate-600" />;
}
};
export const generateAttributeKeysTableColumns = (isReadOnly: boolean): ColumnDef<TContactAttributeKey>[] => {
const nameColumn: ColumnDef<TContactAttributeKey> = {
id: "name",
accessorKey: "name",
header: "Name",
cell: ({ row }) => {
const name = row.original.name || row.original.key;
return <span className="font-medium text-slate-900">{name}</span>;
},
};
const keyColumn: ColumnDef<TContactAttributeKey> = {
id: "key",
accessorKey: "key",
header: "Key",
cell: ({ row }) => {
return <IdBadge id={row.original.key} />;
},
};
const dataTypeColumn: ColumnDef<TContactAttributeKey> = {
id: "dataType",
accessorKey: "dataType",
header: "Data Type",
cell: ({ row }) => {
return (
<div className="flex items-center gap-2">
{getIconForDataType(row.original.dataType)}
<Badge text={row.original.dataType} type="gray" size="tiny" />
</div>
);
},
};
const descriptionColumn: ColumnDef<TContactAttributeKey> = {
id: "description",
accessorKey: "description",
header: "Description",
cell: ({ row }) => {
return <span className="text-sm text-slate-600">{row.original.description || "—"}</span>;
},
};
const createdAtColumn: ColumnDef<TContactAttributeKey> = {
id: "createdAt",
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => {
return (
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
);
},
};
const updatedAtColumn: ColumnDef<TContactAttributeKey> = {
id: "updatedAt",
accessorKey: "updatedAt",
header: "Updated",
cell: ({ row }) => {
return (
<span className="text-sm text-slate-900">
{formatDistanceToNow(row.original.updatedAt, { addSuffix: true }).replace("about ", "")}
</span>
);
},
};
const baseColumns = [
nameColumn,
keyColumn,
dataTypeColumn,
descriptionColumn,
createdAtColumn,
updatedAtColumn,
];
return isReadOnly ? baseColumns : [getSelectionColumn(), ...baseColumns];
};

View File

@@ -0,0 +1,26 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { CreateAttributeKeyModal } from "./create-attribute-key-modal";
interface CreateAttributeKeyButtonProps {
environmentId: string;
}
export function CreateAttributeKeyButton({ environmentId }: CreateAttributeKeyButtonProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)} size="sm">
{t("environments.contacts.create_attribute")}
<PlusIcon className="h-4 w-4" />
</Button>
<CreateAttributeKeyModal environmentId={environmentId} open={open} setOpen={setOpen} />
</>
);
}

View File

@@ -0,0 +1,219 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Calendar1Icon, HashIcon, TagIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import {
FormControl,
FormDescription,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { createAttributeKeyAction } from "../actions";
interface CreateAttributeKeyModalProps {
environmentId: string;
open: boolean;
setOpen: (open: boolean) => void;
}
const ZAttributeKeyInput = z.object({
key: z.string().min(1, { message: "Key is required" }),
name: z.string().optional(),
description: z.string().optional(),
dataType: z.enum(["text", "number", "date"]),
});
type TAttributeKeyInput = z.infer<typeof ZAttributeKeyInput>;
export function CreateAttributeKeyModal({ environmentId, open, setOpen }: CreateAttributeKeyModalProps) {
const { t } = useTranslation();
const router = useRouter();
const form = useForm<TAttributeKeyInput>({
resolver: zodResolver(ZAttributeKeyInput),
defaultValues: {
key: "",
name: "",
description: "",
dataType: "text",
},
});
const onSubmit = async (data: TAttributeKeyInput) => {
try {
const result = await createAttributeKeyAction({
environmentId,
key: data.key,
name: data.name || undefined,
description: data.description || undefined,
dataType: data.dataType,
});
if (result?.data) {
toast.success(t("environments.contacts.attribute_key_created_successfully"));
form.reset();
setOpen(false);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("environments.contacts.create_attribute_key")}</DialogTitle>
</DialogHeader>
<DialogBody>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="key"
render={({ field }) => (
<FormItem>
<FormLabel>{t("environments.contacts.attribute_key")}</FormLabel>
<FormControl>
<Input {...field} placeholder="signUpDate" autoFocus />
</FormControl>
<FormDescription>
{t("environments.contacts.attribute_key_description")}
</FormDescription>
<FormError />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("common.name")} ({t("common.optional")})
</FormLabel>
<FormControl>
<Input {...field} placeholder="Sign Up Date" />
</FormControl>
<FormDescription>
{t("environments.contacts.attribute_name_description")}
</FormDescription>
<FormError />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="dataType"
render={({ field }) => (
<FormItem>
<FormLabel>{t("environments.contacts.data_type")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger className="w-64">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="text">
<div className="flex items-center gap-2">
<TagIcon className="h-4 w-4" />
<span>{t("common.text")}</span>
</div>
</SelectItem>
<SelectItem value="number">
<div className="flex items-center gap-2">
<HashIcon className="h-4 w-4" />
<span>{t("common.number")}</span>
</div>
</SelectItem>
<SelectItem value="date">
<div className="flex items-center gap-2">
<Calendar1Icon className="h-4 w-4" />
<span>{t("common.date")}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormDescription>{t("environments.contacts.data_type_description")}</FormDescription>
<FormError />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("common.description")} ({t("common.optional")})
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t("environments.contacts.attribute_description_placeholder")}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
</form>
</FormProvider>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => {
form.reset();
setOpen(false);
}}
disabled={form.formState.isSubmitting}>
{t("common.cancel")}
</Button>
<Button type="button" onClick={form.handleSubmit(onSubmit)} loading={form.formState.isSubmitting}>
{t("environments.contacts.create_attribute_key")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,35 @@
import { getTranslate } from "@/lingodotdev/server";
import { AttributeKeysManager } from "@/modules/ee/contacts/attributes/components/attribute-keys-manager";
import { CreateAttributeKeyButton } from "@/modules/ee/contacts/attributes/components/create-attribute-key-button";
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 const AttributesPage = async ({
params: paramsProps,
}: {
params: Promise<{ environmentId: string }>;
}) => {
const params = await paramsProps;
const t = await getTranslate();
const { isReadOnly } = await getEnvironmentAuth(params.environmentId);
const attributeKeys = await getContactAttributeKeys(params.environmentId);
return (
<PageContentWrapper>
<PageHeader
pageTitle={t("common.contacts")}
cta={!isReadOnly ? <CreateAttributeKeyButton environmentId={params.environmentId} /> : undefined}>
<ContactsSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
</PageHeader>
<AttributeKeysManager
environmentId={params.environmentId}
attributeKeys={attributeKeys}
isReadOnly={isReadOnly}
/>
</PageContentWrapper>
);
};

View File

@@ -136,17 +136,19 @@ export const ContactDataView = ({
}, [contacts, environmentAttributes]);
return (
<ContactsTableDynamic
data={contactsTableData}
fetchNextPage={fetchNextPage}
hasMore={hasMore}
isDataLoaded={isFirstRender.current ? true : isDataLoaded}
updateContactList={updateContactList}
environmentId={environment.id}
searchValue={searchValue}
setSearchValue={setSearchValue}
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
/>
<div className="space-y-6">
<ContactsTableDynamic
data={contactsTableData}
fetchNextPage={fetchNextPage}
hasMore={hasMore}
isDataLoaded={isFirstRender.current ? true : isDataLoaded}
updateContactList={updateContactList}
environmentId={environment.id}
searchValue={searchValue}
setSearchValue={setSearchValue}
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
/>
</div>
);
};

View File

@@ -27,7 +27,7 @@ export const generateContactTableColumns = (
header: "User ID",
cell: ({ row }) => {
const userId = row.original.userId;
return <IdBadge id={userId} showCopyIconOnHover={true} />;
return <IdBadge id={userId} />;
},
};

View File

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

View File

@@ -221,27 +221,29 @@ export const ContactsTable = ({
return (
<div className="w-full">
<SearchBar
value={searchValue}
onChange={setSearchValue}
placeholder={t("environments.contacts.search_contact")}
/>
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}>
<DataTableToolbar
setIsExpanded={setIsExpanded}
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
isExpanded={isExpanded ?? false}
table={table}
updateRowList={updateContactList}
type="contact"
deleteAction={deleteContact}
isQuotasAllowed={isQuotasAllowed}
/>
<div className="w-full overflow-x-auto rounded-xl border border-slate-200">
<div className="flex items-center justify-between gap-4 pb-6">
<SearchBar
value={searchValue}
onChange={setSearchValue}
placeholder={t("environments.contacts.search_contact")}
/>
<DataTableToolbar
setIsExpanded={setIsExpanded}
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
isExpanded={isExpanded ?? false}
table={table}
updateRowList={updateContactList}
type="contact"
deleteAction={deleteContact}
isQuotasAllowed={isQuotasAllowed}
/>
</div>
<div className="w-full overflow-x-auto rounded-lg border border-slate-200">
<Table className="w-full" style={{ tableLayout: "fixed" }}>
<TableHeader className="pointer-events-auto">
{table.getHeaderGroups().map((headerGroup) => (
@@ -260,39 +262,55 @@ export const ContactsTable = ({
</TableHeader>
<TableBody ref={parent}>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={"group cursor-pointer"}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
onClick={() => {
if (cell.column.id === "select") return;
router.push(`/environments/${environmentId}/contacts/${row.id}`);
}}
style={cell.column.id === "select" ? getCommonPinningStyles(cell.column) : {}}
className={cn(
"border-slate-200 bg-white px-4 py-2 shadow-none group-hover:bg-slate-100",
row.getIsSelected() && "bg-slate-100",
{
"border-r": !cell.column.getIsLastColumn(),
"border-l": !cell.column.getIsFirstColumn(),
}
)}>
<div
className={cn("flex flex-1 items-center truncate", isExpanded ? "h-10" : "h-full")}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
</TableCell>
))}
</TableRow>
))}
{table.getRowModel().rows.map((row, rowIndex) => {
const isLastRow = rowIndex === table.getRowModel().rows.length - 1;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={`group cursor-pointer hover:bg-slate-50 ${isLastRow ? "rounded-b-lg" : ""}`}>
{row.getVisibleCells().map((cell, cellIndex) => {
const isFirstCell = cellIndex === 0;
const isLastCell = cellIndex === row.getVisibleCells().length - 1;
return (
<TableCell
key={cell.id}
onClick={() => {
if (cell.column.id === "select") return;
router.push(`/environments/${environmentId}/contacts/${row.id}`);
}}
style={cell.column.id === "select" ? getCommonPinningStyles(cell.column) : {}}
className={cn(
"border-slate-200 bg-white px-4 py-2 shadow-none group-hover:bg-slate-50",
row.getIsSelected() && "bg-slate-100",
{
"sticky left-0 z-10": cell.column.id === "select",
},
isLastRow
? isFirstCell
? "rounded-bl-lg"
: isLastCell
? "rounded-br-lg"
: ""
: ""
)}>
<div
className={cn(
"flex flex-1 items-center truncate",
isExpanded ? "h-10" : "h-full"
)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
</TableCell>
);
})}
</TableRow>
);
})}
{table.getRowModel().rows.length === 0 && (
<TableRow>
<TableRow className="hover:bg-white">
<TableCell colSpan={columns.length} className="h-24 text-center">
{t("common.no_results")}
<p className="text-slate-400">{t("common.no_results")}</p>
</TableCell>
</TableRow>
)}

View File

@@ -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 { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
export const updateAttributes = async (
contactId: string,
@@ -95,19 +96,22 @@ export const updateAttributes = async (
);
} else {
// Create new attributes since we're under the limit
// Auto-detect the data type based on the first value
await prisma.$transaction(
newAttributes.map(({ key, value }) =>
prisma.contactAttributeKey.create({
newAttributes.map(({ key, value }) => {
const dataType = detectAttributeDataType(value);
return prisma.contactAttributeKey.create({
data: {
key,
type: "custom",
dataType,
environment: { connect: { id: environmentId } },
attributes: {
create: { contactId, value },
},
},
})
)
});
})
);
}
}

View File

@@ -13,6 +13,7 @@ const selectContactAttribute = {
select: {
key: true,
name: true,
dataType: true,
},
},
} satisfies Prisma.ContactAttributeSelect;
@@ -41,6 +42,32 @@ export const getContactAttributes = reactCache(async (contactId: string) => {
}
});
export const getContactAttributesWithMetadata = reactCache(async (contactId: string) => {
validateInputs([contactId, ZId]);
try {
const prismaAttributes = await prisma.contactAttribute.findMany({
where: {
contactId,
},
select: selectContactAttribute,
});
return prismaAttributes.map((attr) => ({
key: attr.attributeKey.key,
name: attr.attributeKey.name,
value: attr.value,
dataType: attr.attributeKey.dataType,
}));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
});
export const hasEmailAttribute = reactCache(
async (email: string, environmentId: string, contactId: string): Promise<boolean> => {
validateInputs([email, ZUserEmail], [environmentId, ZId], [contactId, ZId]);

View File

@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import { detectAttributeDataType } from "./detect-attribute-type";
describe("detectAttributeDataType", () => {
test("detects ISO 8601 date strings", () => {
expect(detectAttributeDataType("2024-01-15")).toBe("date");
expect(detectAttributeDataType("2024-01-15T10:30:00Z")).toBe("date");
expect(detectAttributeDataType("2024-01-15T10:30:00.000Z")).toBe("date");
expect(detectAttributeDataType("2023-12-31")).toBe("date");
});
test("detects numeric values", () => {
expect(detectAttributeDataType("42")).toBe("number");
expect(detectAttributeDataType("3.14")).toBe("number");
expect(detectAttributeDataType("-10")).toBe("number");
expect(detectAttributeDataType("0")).toBe("number");
expect(detectAttributeDataType(" 123 ")).toBe("number");
});
test("detects text values", () => {
expect(detectAttributeDataType("hello")).toBe("text");
expect(detectAttributeDataType("john@example.com")).toBe("text");
expect(detectAttributeDataType("123abc")).toBe("text");
expect(detectAttributeDataType("")).toBe("text");
});
test("handles invalid date strings as text", () => {
expect(detectAttributeDataType("2024-13-01")).toBe("text"); // Invalid month
expect(detectAttributeDataType("not-a-date")).toBe("text");
expect(detectAttributeDataType("2024/01/15")).toBe("text"); // Wrong format
});
test("handles edge cases", () => {
expect(detectAttributeDataType(" ")).toBe("text"); // Whitespace only
expect(detectAttributeDataType("NaN")).toBe("text");
expect(detectAttributeDataType("Infinity")).toBe("number"); // Technically a number
});
});

View File

@@ -0,0 +1,28 @@
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
/**
* Detects the data type of an attribute value based on its format
* @param value - The attribute value to detect the type of
* @returns The detected data type (text, number, or date)
*/
export const detectAttributeDataType = (value: string): TContactAttributeDataType => {
// Check if valid ISO 8601 date format
// Must match YYYY-MM-DD at minimum (with optional time component)
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
const date = new Date(value);
// Verify it's a valid date and not "Invalid Date"
if (!isNaN(date.getTime())) {
return "date";
}
}
// Check if numeric (integer or decimal)
// Trim whitespace and check if it's a valid number
const trimmedValue = value.trim();
if (trimmedValue !== "" && !isNaN(Number(trimmedValue))) {
return "number";
}
// Default to text for everything else
return "text";
};

View File

@@ -1,10 +1,17 @@
"use client";
import { createId } from "@paralleldrive/cuid2";
import { FingerprintIcon, MonitorSmartphoneIcon, TagIcon, Users2Icon } from "lucide-react";
import {
Calendar1Icon,
FingerprintIcon,
HashIcon,
MonitorSmartphoneIcon,
TagIcon,
Users2Icon,
} from "lucide-react";
import React, { type JSX, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import type {
TBaseFilter,
TSegment,
@@ -33,6 +40,7 @@ export const handleAddFilter = ({
onAddFilter,
setOpen,
contactAttributeKey,
attributeDataType,
deviceType,
segmentId,
}: {
@@ -40,12 +48,22 @@ export const handleAddFilter = ({
onAddFilter: (filter: TBaseFilter) => void;
setOpen: (open: boolean) => void;
contactAttributeKey?: string;
attributeDataType?: TContactAttributeDataType;
segmentId?: string;
deviceType?: string;
}): void => {
if (type === "attribute") {
if (!contactAttributeKey) return;
// Set default operator and value based on attribute data type
let defaultOperator: "equals" | "isOlderThan" = "equals";
let defaultValue: string | { amount: number; unit: "days" } = "";
if (attributeDataType === "date") {
defaultOperator = "isOlderThan";
defaultValue = { amount: 1, unit: "days" };
}
const newFilterResource: TSegmentAttributeFilter = {
id: createId(),
root: {
@@ -53,9 +71,9 @@ export const handleAddFilter = ({
contactAttributeKey,
},
qualifier: {
operator: "equals",
operator: defaultOperator,
},
value: "",
value: defaultValue,
};
const newFilter: TBaseFilter = {
id: createId(),
@@ -235,33 +253,46 @@ export function AddFilterModal({
{allFiltersFiltered.map((filters, index) => (
<div key={index}>
{filters.attributes.map((attributeKey) => (
<FilterButton
key={attributeKey.id}
data-testid={`filter-btn-attribute-${attributeKey.key}`}
icon={<TagIcon className="h-4 w-4" />}
label={attributeKey.name ?? attributeKey.key}
onClick={() => {
handleAddFilter({
type: "attribute",
onAddFilter,
setOpen,
contactAttributeKey: attributeKey.key,
});
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
{filters.attributes.map((attributeKey) => {
const icon =
attributeKey.dataType === "date" ? (
<Calendar1Icon className="h-4 w-4" />
) : attributeKey.dataType === "number" ? (
<HashIcon className="h-4 w-4" />
) : (
<TagIcon className="h-4 w-4" />
);
return (
<FilterButton
key={attributeKey.id}
data-testid={`filter-btn-attribute-${attributeKey.key}`}
icon={icon}
label={attributeKey.name ?? attributeKey.key}
onClick={() => {
handleAddFilter({
type: "attribute",
onAddFilter,
setOpen,
contactAttributeKey: attributeKey.key,
attributeDataType: attributeKey.dataType,
});
}
}}
/>
))}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleAddFilter({
type: "attribute",
onAddFilter,
setOpen,
contactAttributeKey: attributeKey.key,
attributeDataType: attributeKey.dataType,
});
}
}}
/>
);
})}
{filters.contactAttributeFiltered.map((personAttribute) => (
<FilterButton

View File

@@ -1,6 +1,6 @@
import { FingerprintIcon, TagIcon } from "lucide-react";
import { Calendar1Icon, FingerprintIcon, HashIcon, TagIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import type { TBaseFilter } from "@formbricks/types/segment";
import FilterButton from "./filter-button";
@@ -13,6 +13,7 @@ interface AttributeTabContentProps {
onAddFilter: (filter: TBaseFilter) => void;
setOpen: (open: boolean) => void;
contactAttributeKey?: string;
attributeDataType?: TContactAttributeDataType;
}) => void;
}
@@ -26,6 +27,7 @@ function FilterButtonWithHandler({
setOpen,
handleAddFilter,
contactAttributeKey,
attributeDataType,
}: {
dataTestId: string;
icon: React.ReactNode;
@@ -38,8 +40,10 @@ function FilterButtonWithHandler({
onAddFilter: (filter: TBaseFilter) => void;
setOpen: (open: boolean) => void;
contactAttributeKey?: string;
attributeDataType?: TContactAttributeDataType;
}) => void;
contactAttributeKey?: string;
attributeDataType?: TContactAttributeDataType;
}) {
return (
<FilterButton
@@ -51,7 +55,7 @@ function FilterButtonWithHandler({
type,
onAddFilter,
setOpen,
...(type === "attribute" ? { contactAttributeKey } : {}),
...(type === "attribute" ? { contactAttributeKey, attributeDataType } : {}),
});
}}
onKeyDown={(e) => {
@@ -61,7 +65,7 @@ function FilterButtonWithHandler({
type,
onAddFilter,
setOpen,
...(type === "attribute" ? { contactAttributeKey } : {}),
...(type === "attribute" ? { contactAttributeKey, attributeDataType } : {}),
});
}
}}
@@ -104,19 +108,31 @@ function AttributeTabContent({
<p>{t("environments.segments.no_attributes_yet")}</p>
</div>
)}
{contactAttributeKeys.map((attributeKey) => (
<FilterButtonWithHandler
key={attributeKey.id}
dataTestId={`filter-btn-attribute-${attributeKey.key}`}
icon={<TagIcon className="h-4 w-4" />}
label={attributeKey.name ?? attributeKey.key}
type="attribute"
onAddFilter={onAddFilter}
setOpen={setOpen}
handleAddFilter={handleAddFilter}
contactAttributeKey={attributeKey.key}
/>
))}
{contactAttributeKeys.map((attributeKey) => {
const icon =
attributeKey.dataType === "date" ? (
<Calendar1Icon className="h-4 w-4" />
) : attributeKey.dataType === "number" ? (
<HashIcon className="h-4 w-4" />
) : (
<TagIcon className="h-4 w-4" />
);
return (
<FilterButtonWithHandler
key={attributeKey.id}
dataTestId={`filter-btn-attribute-${attributeKey.key}`}
icon={icon}
label={attributeKey.name ?? attributeKey.key}
type="attribute"
onAddFilter={onAddFilter}
setOpen={setOpen}
handleAddFilter={handleAddFilter}
contactAttributeKey={attributeKey.key}
attributeDataType={attributeKey.dataType}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TDateOperator, TSegmentFilterValue, TTimeUnit } from "@formbricks/types/segment";
import { cn } from "@/lib/cn";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface DateFilterValueProps {
operator: TDateOperator;
value: TSegmentFilterValue;
onChange: (value: TSegmentFilterValue) => void;
viewOnly?: boolean;
}
export function DateFilterValue({ operator, value, onChange, viewOnly }: DateFilterValueProps) {
const { t } = useTranslation();
const [error, setError] = useState("");
// Relative time operators: isOlderThan, isNewerThan
if (operator === "isOlderThan" || operator === "isNewerThan") {
const relativeValue =
typeof value === "object" && "amount" in value && "unit" in value
? value
: { amount: 1, unit: "days" as TTimeUnit };
return (
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
className={cn("h-9 w-20 bg-white", error && "border border-red-500 focus:border-red-500")}
disabled={viewOnly}
value={relativeValue.amount}
onChange={(e) => {
const amount = parseInt(e.target.value, 10);
if (isNaN(amount) || amount < 1) {
setError(t("environments.segments.value_must_be_positive"));
return;
}
setError("");
onChange({ amount, unit: relativeValue.unit });
}}
/>
<Select
disabled={viewOnly}
value={relativeValue.unit}
onValueChange={(unit: TTimeUnit) => {
onChange({ amount: relativeValue.amount, unit });
}}>
<SelectTrigger className="flex w-auto items-center justify-center bg-white" hideArrow>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="days">{t("common.days")}</SelectItem>
<SelectItem value="weeks">{t("common.weeks")}</SelectItem>
<SelectItem value="months">{t("common.months")}</SelectItem>
<SelectItem value="years">{t("common.years")}</SelectItem>
</SelectContent>
</Select>
</div>
);
}
// Between operator: needs two date inputs
if (operator === "isBetween") {
const betweenValue = Array.isArray(value) && value.length === 2 ? value : ["", ""];
return (
<div className="flex items-center gap-2">
<Input
type="date"
className="h-9 w-auto bg-white"
disabled={viewOnly}
value={betweenValue[0] ? betweenValue[0].split("T")[0] : ""}
onChange={(e) => {
const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
onChange([dateValue, betweenValue[1]]);
}}
/>
<span className="text-sm text-slate-600">{t("common.and")}</span>
<Input
type="date"
className="h-9 w-auto bg-white"
disabled={viewOnly}
value={betweenValue[1] ? betweenValue[1].split("T")[0] : ""}
onChange={(e) => {
const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
onChange([betweenValue[0], dateValue]);
}}
/>
</div>
);
}
// Absolute date operators: isBefore, isAfter, isSameDay
// Use a single date picker
const dateValue = typeof value === "string" ? value : "";
return (
<Input
type="date"
className="h-9 w-auto bg-white"
disabled={viewOnly}
value={dateValue ? dateValue.split("T")[0] : ""}
onChange={(e) => {
const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
onChange(dateValue);
}}
/>
);
}

View File

@@ -4,7 +4,7 @@ import { UsersIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TSegment } from "@formbricks/types/segment";
import { SegmentSettings } from "@/modules/ee/contacts/segments/components/segment-settings";
import {
Dialog,
@@ -20,7 +20,7 @@ interface EditSegmentModalProps {
environmentId: string;
open: boolean;
setOpen: (open: boolean) => void;
currentSegment: TSegmentWithSurveyNames;
currentSegment: TSegment;
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;

View File

@@ -8,16 +8,14 @@ import { Label } from "@/modules/ui/components/label";
interface SegmentActivityTabProps {
environmentId: string;
currentSegment: TSegment & {
activeSurveys: string[];
inactiveSurveys: string[];
};
currentSegment: TSegment;
}
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => {
const { t } = useTranslation();
const activeSurveys = currentSegment?.activeSurveys;
const inactiveSurveys = currentSegment?.inactiveSurveys;
const activeSurveys: string[] = [];
const inactiveSurveys: string[] = [];
return (
<div className="grid grid-cols-3 pb-2">

View File

@@ -3,7 +3,9 @@
import {
ArrowDownIcon,
ArrowUpIcon,
Calendar1Icon,
FingerprintIcon,
HashIcon,
MonitorSmartphoneIcon,
MoreVertical,
TagIcon,
@@ -31,9 +33,10 @@ import type {
} from "@formbricks/types/segment";
import {
ARITHMETIC_OPERATORS,
ATTRIBUTE_OPERATORS,
DATE_OPERATORS,
DEVICE_OPERATORS,
PERSON_OPERATORS,
TEXT_ATTRIBUTE_OPERATORS,
} from "@formbricks/types/segment";
import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
@@ -64,6 +67,7 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { AddFilterModal } from "./add-filter-modal";
import { DateFilterValue } from "./date-filter-value";
interface TSegmentFilterProps {
connector: TSegmentConnector;
@@ -239,17 +243,21 @@ function AttributeSegmentFilter({
}
}, [resource.qualifier, resource.value, t]);
const operatorArr = ATTRIBUTE_OPERATORS.map((operator) => {
const attributeKey = contactAttributeKeys.find((attrKey) => attrKey.key === contactAttributeKey);
const attrKeyValue = attributeKey?.name ?? attributeKey?.key ?? "";
// Default to 'text' if dataType is undefined (for backwards compatibility)
const attributeDataType = attributeKey?.dataType ?? "text";
const isDateAttribute = attributeDataType === "date";
// Show date operators for date attributes, otherwise show standard text/number operators
const availableOperators = isDateAttribute ? DATE_OPERATORS : TEXT_ATTRIBUTE_OPERATORS;
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) {
@@ -263,6 +271,15 @@ function AttributeSegmentFilter({
const updatedSegment = structuredClone(segment);
if (updatedSegment.filters) {
updateContactAttributeKeyInFilter(updatedSegment.filters, filterId, newAttributeClassName);
// When changing attribute, reset operator to appropriate default for the new attribute type
const newAttributeKey = contactAttributeKeys.find((attrKey) => attrKey.key === newAttributeClassName);
const newAttributeDataType = newAttributeKey?.dataType ?? "text";
const defaultOperator = newAttributeDataType === "date" ? "isOlderThan" : "equals";
const defaultValue = newAttributeDataType === "date" ? { amount: 1, unit: "days" as const } : "";
updateOperatorInFilter(updatedSegment.filters, filterId, defaultOperator as any);
updateFilterValue(updatedSegment.filters, filterId, defaultValue as any);
}
setSegment(updatedSegment);
@@ -319,7 +336,13 @@ function AttributeSegmentFilter({
hideArrow>
<SelectValue>
<div className="flex items-center gap-2">
<TagIcon className="h-4 w-4 text-sm" />
{isDateAttribute ? (
<Calendar1Icon className="h-4 w-4 text-sm" />
) : attributeDataType === "number" ? (
<HashIcon className="h-4 w-4 text-sm" />
) : (
<TagIcon className="h-4 w-4 text-sm" />
)}
<p>{attrKeyValue}</p>
</div>
</SelectValue>
@@ -328,7 +351,16 @@ function AttributeSegmentFilter({
<SelectContent>
{contactAttributeKeys.map((attrClass) => (
<SelectItem key={attrClass.id} value={attrClass.key}>
{attrClass.name ?? attrClass.key}
<div className="flex items-center gap-2">
{attrClass.dataType === "date" ? (
<Calendar1Icon className="h-4 w-4" />
) : attrClass.dataType === "number" ? (
<HashIcon className="h-4 w-4" />
) : (
<TagIcon className="h-4 w-4" />
)}
<span>{attrClass.name ?? attrClass.key}</span>
</div>
</SelectItem>
))}
</SelectContent>
@@ -356,23 +388,39 @@ function AttributeSegmentFilter({
</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);
}}
value={resource.value}
/>
<>
{isDateAttribute && DATE_OPERATORS.includes(resource.qualifier.operator as any) ? (
<DateFilterValue
operator={resource.qualifier.operator as any}
value={resource.value}
onChange={(newValue) => {
updateValueInLocalSurvey(resource.id, newValue);
}}
viewOnly={viewOnly}
/>
) : (
<div className="relative flex flex-col gap-1">
<Input
className={cn(
"h-9 w-auto bg-white",
valueError && "border border-red-500 focus:border-red-500"
)}
disabled={viewOnly}
onChange={(e) => {
if (viewOnly) return;
checkValueAndUpdate(e);
}}
value={resource.value}
/>
{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

View File

@@ -6,7 +6,7 @@ import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import type { TBaseFilter, TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import type { TBaseFilter, TSegment } from "@formbricks/types/segment";
import { ZSegmentFilters } from "@formbricks/types/segment";
import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
@@ -21,7 +21,7 @@ import { SegmentEditor } from "./segment-editor";
interface TSegmentSettingsTabProps {
environmentId: string;
setOpen: (open: boolean) => void;
initialSegment: TSegmentWithSurveyNames;
initialSegment: TSegment;
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isReadOnly: boolean;

View File

@@ -0,0 +1,55 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { format, formatDistanceToNow } from "date-fns";
import { UsersIcon } from "lucide-react";
import { TSegment } from "@formbricks/types/segment";
export const generateSegmentTableColumns = (): ColumnDef<TSegment>[] => {
const titleColumn: ColumnDef<TSegment> = {
id: "title",
accessorKey: "title",
header: "Title",
cell: ({ row }) => {
return (
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-slate-100">
<UsersIcon className="h-5 w-5 text-slate-600" />
</div>
<div className="flex flex-col">
<div className="font-medium text-slate-900">{row.original.title}</div>
{row.original.description && (
<div className="text-xs text-slate-500">{row.original.description}</div>
)}
</div>
</div>
);
},
};
const updatedAtColumn: ColumnDef<TSegment> = {
id: "updatedAt",
accessorKey: "updatedAt",
header: "Updated",
cell: ({ row }) => {
return (
<span className="text-sm text-slate-900">
{formatDistanceToNow(row.original.updatedAt, { addSuffix: true }).replace("about ", "")}
</span>
);
},
};
const createdAtColumn: ColumnDef<TSegment> = {
id: "createdAt",
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => {
return (
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
);
},
};
return [titleColumn, updatedAtColumn, createdAtColumn];
};

View File

@@ -0,0 +1,124 @@
"use client";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment } from "@formbricks/types/segment";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { EditSegmentModal } from "./edit-segment-modal";
import { generateSegmentTableColumns } from "./segment-table-columns";
interface SegmentTableUpdatedProps {
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
isReadOnly: boolean;
}
export function SegmentTableUpdated({
segments,
contactAttributeKeys,
isContactsEnabled,
isReadOnly,
}: SegmentTableUpdatedProps) {
const { t } = useTranslation();
const [editingSegment, setEditingSegment] = useState<TSegment | null>(null);
const columns = useMemo(() => {
return generateSegmentTableColumns();
}, []);
const table = useReactTable({
data: segments,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<>
<div className="rounded-lg border border-slate-200">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="rounded-t-lg">
{headerGroup.headers.map((header, index) => {
const isFirstHeader = index === 0;
const isLastHeader = index === headerGroup.headers.length - 1;
return (
<TableHead
key={header.id}
className={`h-10 border-b border-slate-200 bg-white px-4 font-semibold ${
isFirstHeader ? "rounded-tl-lg" : isLastHeader ? "rounded-tr-lg" : ""
}`}>
{header.isPlaceholder
? null
: typeof header.column.columnDef.header === "function"
? header.column.columnDef.header(header.getContext())
: header.column.columnDef.header}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row, rowIndex) => {
const isLastRow = rowIndex === table.getRowModel().rows.length - 1;
return (
<TableRow
key={row.id}
onClick={() => setEditingSegment(row.original)}
className={`cursor-pointer hover:bg-slate-50 ${isLastRow ? "rounded-b-lg" : ""}`}>
{row.getVisibleCells().map((cell, cellIndex) => {
const isFirstCell = cellIndex === 0;
const isLastCell = cellIndex === row.getVisibleCells().length - 1;
return (
<TableCell
key={cell.id}
className={
isLastRow
? isFirstCell
? "rounded-bl-lg"
: isLastCell
? "rounded-br-lg"
: ""
: ""
}>
{typeof cell.column.columnDef.cell === "function"
? cell.column.columnDef.cell(cell.getContext())
: cell.column.columnDef.cell}
</TableCell>
);
})}
</TableRow>
);
})
) : (
<TableRow className="hover:bg-white">
<TableCell colSpan={columns.length} className="h-24 text-center">
<p className="text-slate-400">{t("environments.segments.create_your_first_segment")}</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Edit Segment Modal */}
{editingSegment && (
<EditSegmentModal
environmentId={editingSegment.environmentId}
open={!!editingSegment}
setOpen={(open) => !open && setEditingSegment(null)}
currentSegment={editingSegment}
contactAttributeKeys={contactAttributeKeys}
segments={segments}
isContactsEnabled={isContactsEnabled}
isReadOnly={isReadOnly}
/>
)}
</>
);
}

View File

@@ -1,48 +0,0 @@
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment } from "@formbricks/types/segment";
import { getTranslate } from "@/lingodotdev/server";
import { SegmentTableDataRowContainer } from "./segment-table-data-row-container";
type TSegmentTableProps = {
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
isReadOnly: boolean;
};
export const SegmentTable = async ({
segments,
contactAttributeKeys,
isContactsEnabled,
isReadOnly,
}: TSegmentTableProps) => {
const t = await getTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 pl-6">{t("common.title")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.surveys")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.updated")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.created")}</div>
</div>
{segments.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">
{t("environments.segments.create_your_first_segment")}
</p>
) : (
<>
{segments.map((segment) => (
<SegmentTableDataRowContainer
key={segment.id}
currentSegment={segment}
segments={segments}
contactAttributeKeys={contactAttributeKeys}
isContactsEnabled={isContactsEnabled}
isReadOnly={isReadOnly}
/>
))}
</>
)}
</div>
);
};

View File

@@ -0,0 +1,114 @@
import { describe, expect, test } from "vitest";
import { addTimeUnit, endOfDay, isSameDay, startOfDay, subtractTimeUnit } from "./date-utils";
describe("date-utils", () => {
describe("subtractTimeUnit", () => {
test("subtracts days correctly", () => {
const date = new Date("2024-01-15T12:00:00Z");
const result = subtractTimeUnit(date, 5, "days");
expect(result.getDate()).toBe(10);
expect(result.getMonth()).toBe(0); // January
});
test("subtracts weeks correctly", () => {
const date = new Date("2024-01-15T12:00:00Z");
const result = subtractTimeUnit(date, 2, "weeks");
expect(result.getDate()).toBe(1);
});
test("subtracts months correctly", () => {
const date = new Date("2024-03-15T12:00:00Z");
const result = subtractTimeUnit(date, 2, "months");
expect(result.getMonth()).toBe(0); // January
});
test("subtracts years correctly", () => {
const date = new Date("2024-01-15T12:00:00Z");
const result = subtractTimeUnit(date, 1, "years");
expect(result.getFullYear()).toBe(2023);
});
test("does not modify original date", () => {
const date = new Date("2024-01-15T12:00:00Z");
const original = date.getTime();
subtractTimeUnit(date, 5, "days");
expect(date.getTime()).toBe(original);
});
});
describe("addTimeUnit", () => {
test("adds days correctly", () => {
const date = new Date("2024-01-15T12:00:00Z");
const result = addTimeUnit(date, 5, "days");
expect(result.getDate()).toBe(20);
});
test("adds weeks correctly", () => {
const date = new Date("2024-01-15T12:00:00Z");
const result = addTimeUnit(date, 2, "weeks");
expect(result.getDate()).toBe(29);
});
test("adds months correctly", () => {
const date = new Date("2024-01-15T12:00:00Z");
const result = addTimeUnit(date, 2, "months");
expect(result.getMonth()).toBe(2); // March
});
test("adds years correctly", () => {
const date = new Date("2024-01-15T12:00:00Z");
const result = addTimeUnit(date, 1, "years");
expect(result.getFullYear()).toBe(2025);
});
});
describe("startOfDay", () => {
test("sets time to 00:00:00.000", () => {
const date = new Date("2024-01-15T14:30:45.123Z");
const result = startOfDay(date);
expect(result.getHours()).toBe(0);
expect(result.getMinutes()).toBe(0);
expect(result.getSeconds()).toBe(0);
expect(result.getMilliseconds()).toBe(0);
expect(result.getDate()).toBe(date.getDate());
});
});
describe("endOfDay", () => {
test("sets time to 23:59:59.999", () => {
const date = new Date("2024-01-15T14:30:45.123Z");
const result = endOfDay(date);
expect(result.getHours()).toBe(23);
expect(result.getMinutes()).toBe(59);
expect(result.getSeconds()).toBe(59);
expect(result.getMilliseconds()).toBe(999);
expect(result.getDate()).toBe(date.getDate());
});
});
describe("isSameDay", () => {
test("returns true for dates on the same day", () => {
const date1 = new Date("2024-01-15T10:00:00Z");
const date2 = new Date("2024-01-15T22:00:00Z");
expect(isSameDay(date1, date2)).toBe(true);
});
test("returns false for dates on different days", () => {
const date1 = new Date("2024-01-15T23:59:59Z");
const date2 = new Date("2024-01-16T00:00:01Z");
expect(isSameDay(date1, date2)).toBe(false);
});
test("returns false for dates in different months", () => {
const date1 = new Date("2024-01-31T12:00:00Z");
const date2 = new Date("2024-02-01T12:00:00Z");
expect(isSameDay(date1, date2)).toBe(false);
});
test("returns false for dates in different years", () => {
const date1 = new Date("2023-12-31T12:00:00Z");
const date2 = new Date("2024-01-01T12:00:00Z");
expect(isSameDay(date1, date2)).toBe(false);
});
});
});

View File

@@ -0,0 +1,93 @@
import { TTimeUnit } from "@formbricks/types/segment";
/**
* Subtracts a time unit from a date
* @param date - The date to subtract from
* @param amount - The amount of time units to subtract
* @param unit - The time unit (days, weeks, months, years)
* @returns A new Date object with the time subtracted
*/
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;
};
/**
* Adds a time unit to a date
* @param date - The date to add to
* @param amount - The amount of time units to add
* @param unit - The time unit (days, weeks, months, years)
* @returns A new Date object with the time added
*/
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;
};
/**
* Gets the start of a day (00:00:00.000)
* @param date - The date to get the start of
* @returns A new Date object at the start of the day
*/
export const startOfDay = (date: Date): Date => {
const result = new Date(date);
result.setHours(0, 0, 0, 0);
return result;
};
/**
* Gets the end of a day (23:59:59.999)
* @param date - The date to get the end of
* @returns A new Date object at the end of the day
*/
export const endOfDay = (date: Date): Date => {
const result = new Date(date);
result.setHours(23, 59, 59, 999);
return result;
};
/**
* Checks if two dates are on the same day (ignoring time)
* @param date1 - The first date
* @param date2 - The second date
* @returns True if the dates are on the same day
*/
export const isSameDay = (date1: Date, date2: Date): boolean => {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
};

View File

@@ -3,7 +3,9 @@ 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,
@@ -11,6 +13,7 @@ import {
TSegmentSegmentFilter,
} from "@formbricks/types/segment";
import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils";
import { endOfDay, startOfDay, subtractTimeUnit } from "../date-utils";
import { getSegment } from "../segments";
// Type for the result of the segment filter to prisma query generation
@@ -18,6 +21,70 @@ export type SegmentFilterQueryResult = {
whereClause: Prisma.ContactWhereInput;
};
/**
* Builds a Prisma where clause for date attribute filters
* Since dates are stored as ISO 8601 strings, lexicographic comparison works correctly
*/
const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prisma.ContactWhereInput => {
const { root, qualifier, value } = filter;
const { contactAttributeKey } = root;
const { operator } = qualifier as { operator: TDateOperator };
const now = new Date();
let dateCondition: Prisma.StringFilter = {};
switch (operator) {
case "isOlderThan": {
// value should be { amount, unit }
if (typeof value === "object" && "amount" in value && "unit" in value) {
const threshold = subtractTimeUnit(now, value.amount, value.unit);
dateCondition = { lt: threshold.toISOString() };
}
break;
}
case "isNewerThan": {
// value should be { amount, unit }
if (typeof value === "object" && "amount" in value && "unit" in value) {
const threshold = subtractTimeUnit(now, value.amount, value.unit);
dateCondition = { gte: threshold.toISOString() };
}
break;
}
case "isBefore":
if (typeof value === "string") {
dateCondition = { lt: value };
}
break;
case "isAfter":
if (typeof value === "string") {
dateCondition = { gt: value };
}
break;
case "isBetween":
if (Array.isArray(value) && value.length === 2) {
dateCondition = { gte: value[0], lte: value[1] };
}
break;
case "isSameDay": {
if (typeof value === "string") {
const dayStart = startOfDay(new Date(value)).toISOString();
const dayEnd = endOfDay(new Date(value)).toISOString();
dateCondition = { gte: dayStart, lte: dayEnd };
}
break;
}
}
return {
attributes: {
some: {
attributeKey: { key: contactAttributeKey },
value: dateCondition,
},
},
};
};
/**
* Builds a Prisma where clause from a segment attribute filter
*/
@@ -60,6 +127,11 @@ const buildAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prism
},
} satisfies Prisma.ContactWhereInput;
// Handle date operators
if (DATE_OPERATORS.includes(operator as TDateOperator)) {
return buildDateAttributeFilterWhereClause(filter);
}
// Apply the appropriate operator to the attribute value
switch (operator) {
case "equals":

View File

@@ -10,8 +10,10 @@ import {
ValidationError,
} from "@formbricks/types/errors";
import {
DATE_OPERATORS,
TAllOperators,
TBaseFilters,
TDateOperator,
TEvaluateSegmentUserAttributeData,
TEvaluateSegmentUserData,
TSegment,
@@ -19,6 +21,7 @@ import {
TSegmentConnector,
TSegmentCreateInput,
TSegmentDeviceFilter,
TSegmentFilterValue,
TSegmentPersonFilter,
TSegmentSegmentFilter,
TSegmentUpdateInput,
@@ -29,6 +32,7 @@ import {
import { getSurvey } from "@/lib/survey/service";
import { validateInputs } from "@/lib/utils/validate";
import { isResourceFilter, searchForAttributeKeyInSegment } from "@/modules/ee/contacts/segments/lib/utils";
import { isSameDay, subtractTimeUnit } from "./date-utils";
export type PrismaSegment = Prisma.SegmentGetPayload<{
include: {
@@ -387,6 +391,12 @@ const evaluateAttributeFilter = (
return false;
}
// Check if this is a date operator
if (isDateOperator(qualifier.operator)) {
return evaluateDateFilter(String(attributeValue), value, qualifier.operator);
}
// Use standard comparison for non-date operators
const attResult = compareValues(attributeValue, value, qualifier.operator);
return attResult;
};
@@ -440,6 +450,86 @@ const evaluateDeviceFilter = (device: "phone" | "desktop", filter: TSegmentDevic
return compareValues(device, value, qualifier.operator);
};
/**
* Checks if an operator is a date-specific operator
*/
const isDateOperator = (operator: TAllOperators): operator is TDateOperator => {
return DATE_OPERATORS.includes(operator as TDateOperator);
};
/**
* Evaluates a date filter against an attribute value
*/
const evaluateDateFilter = (
attributeValue: string,
filterValue: TSegmentFilterValue,
operator: TDateOperator
): boolean => {
// Parse the attribute value as a date
const attrDate = new Date(attributeValue);
// Validate the attribute value is a valid date
if (isNaN(attrDate.getTime())) {
return false;
}
const now = new Date();
switch (operator) {
case "isOlderThan": {
// filterValue should be { amount, unit }
if (typeof filterValue === "object" && "amount" in filterValue && "unit" in filterValue) {
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
return attrDate < threshold;
}
return false;
}
case "isNewerThan": {
// filterValue should be { amount, unit }
if (typeof filterValue === "object" && "amount" in filterValue && "unit" in filterValue) {
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
return attrDate >= threshold;
}
return false;
}
case "isBefore": {
// filterValue should be an ISO date string
if (typeof filterValue === "string") {
const compareDate = new Date(filterValue);
return attrDate < compareDate;
}
return false;
}
case "isAfter": {
// filterValue should be an ISO date string
if (typeof filterValue === "string") {
const compareDate = new Date(filterValue);
return attrDate > compareDate;
}
return false;
}
case "isBetween": {
// filterValue should be a tuple [startDate, endDate]
if (Array.isArray(filterValue) && filterValue.length === 2) {
const startDate = new Date(filterValue[0]);
const endDate = new Date(filterValue[1]);
return attrDate >= startDate && attrDate <= endDate;
}
return false;
}
case "isSameDay": {
// filterValue should be an ISO date string
if (typeof filterValue === "string") {
const compareDate = new Date(filterValue);
return isSameDay(attrDate, compareDate);
}
return false;
}
default:
return false;
}
};
export const compareValues = (
a: string | number | undefined,
b: string | number,

View File

@@ -50,6 +50,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 same day";
default:
return operator;
}
@@ -85,6 +97,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 same day";
default:
return operator;
}

View File

@@ -2,7 +2,7 @@ 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 { SegmentTableUpdated } from "@/modules/ee/contacts/segments/components/segment-table-updated";
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";
@@ -51,7 +51,7 @@ export const SegmentsPage = async ({
</PageHeader>
{isContactsEnabled ? (
<SegmentTable
<SegmentTableUpdated
segments={filteredSegments}
contactAttributeKeys={contactAttributeKeys}
isContactsEnabled={isContactsEnabled}

View File

@@ -35,10 +35,7 @@ export const DataTableHeader = <T,>({ header, setIsTableSettingsModalOpen }: Dat
ref={setNodeRef}
style={style}
key={header.id}
className={cn("group relative h-10 border-b border-slate-200 bg-white px-4 text-center", {
"border-r": !header.column.getIsLastColumn(),
"border-l": !header.column.getIsFirstColumn(),
})}>
className="group relative h-10 border-b border-slate-200 bg-white px-4 text-center">
<div className="flex items-center justify-between">
<div className="w-full truncate text-left font-semibold">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}

View File

@@ -36,7 +36,7 @@ export const DataTableToolbar = <T,>({
const router = useRouter();
return (
<div className="sticky top-0 z-30 flex w-full items-center justify-between bg-slate-50 py-2">
<div className="flex w-full items-center justify-end">
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
<SelectedRowSettings
table={table}
@@ -46,9 +46,7 @@ export const DataTableToolbar = <T,>({
downloadRowsAction={downloadRowsAction}
isQuotasAllowed={isQuotasAllowed}
/>
) : (
<div></div>
)}
) : null}
<div className="flex space-x-2">
{type === "contact" ? (
<TooltipRenderer

View File

@@ -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[]

View File

@@ -1,4 +1,4 @@
import { type ContactAttributeKey, ContactAttributeType } from "@prisma/client";
import { ContactAttributeDataType, type ContactAttributeKey, ContactAttributeType } from "@prisma/client";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
@@ -36,6 +36,10 @@ export const ZContactAttributeKey = z.object({
description: "Whether this is a default or custom attribute",
example: "custom",
}),
dataType: z.nativeEnum(ContactAttributeDataType).openapi({
description: "The data type of the attribute (text, number, date)",
example: "text",
}),
environmentId: z.string().cuid2().openapi({
description: "The ID of the environment this attribute belongs to",
}),

View File

@@ -2,11 +2,17 @@ import { UpdateQueue } from "@/lib/user/update-queue";
import { type NetworkError, type Result, okVoid } from "@/types/error";
export const setAttributes = async (
attributes: Record<string, string>
attributes: Record<string, string | Date>
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
): Promise<Result<void, NetworkError>> => {
// Convert Date objects to ISO strings
const normalizedAttributes: Record<string, string> = {};
for (const [key, value] of Object.entries(attributes)) {
normalizedAttributes[key] = value instanceof Date ? value.toISOString() : value;
}
const updateQueue = UpdateQueue.getInstance();
updateQueue.updateAttributes(attributes);
updateQueue.updateAttributes(normalizedAttributes);
void updateQueue.processUpdates();
return okVoid();
};

View File

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

View File

@@ -16,8 +16,21 @@ export type TStringOperator = (typeof STRING_OPERATORS)[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 = [
// operators for date filters
export const DATE_OPERATORS = [
"isOlderThan",
"isNewerThan",
"isBefore",
"isAfter",
"isBetween",
"isSameDay",
] as const;
// time units for relative date operators
export const TIME_UNITS = ["days", "weeks", "months", "years"] as const;
// Standard operators for text/number attributes (without date operators)
export const TEXT_ATTRIBUTE_OPERATORS = [
...BASE_OPERATORS,
"isSet",
"isNotSet",
@@ -27,6 +40,9 @@ export const ATTRIBUTE_OPERATORS = [
"endsWith",
] as const;
// An attribute filter can have these operators (including date operators)
export const ATTRIBUTE_OPERATORS = [...TEXT_ATTRIBUTE_OPERATORS, ...DATE_OPERATORS] as const;
// 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;
@@ -52,9 +68,27 @@ export type TSegmentOperator = z.infer<typeof ZSegmentOperator>;
export const ZDeviceOperator = z.enum(DEVICE_OPERATORS);
export type TDeviceOperator = z.infer<typeof ZDeviceOperator>;
export const ZDateOperator = z.enum(DATE_OPERATORS);
export type TDateOperator = z.infer<typeof ZDateOperator>;
export const ZTimeUnit = z.enum(TIME_UNITS);
export type TTimeUnit = z.infer<typeof ZTimeUnit>;
export type TAllOperators = (typeof ALL_OPERATORS)[number];
export const ZSegmentFilterValue = z.union([z.string(), z.number()]);
// Relative date value for operators like "isOlderThan" and "isNewerThan"
export const ZRelativeDateValue = z.object({
amount: z.number(),
unit: ZTimeUnit,
});
export type TRelativeDateValue = z.infer<typeof ZRelativeDateValue>;
export const ZSegmentFilterValue = z.union([
z.string(),
z.number(),
ZRelativeDateValue,
z.tuple([z.string(), z.string()]), // for "isBetween" operator
]);
export type TSegmentFilterValue = z.infer<typeof ZSegmentFilterValue>;
// Each filter has a qualifier, which usually contains the operator for evaluating the filter.
@@ -137,10 +171,34 @@ export const ZSegmentFilter = z
return false;
}
// if the operator is a relative date operator (isOlderThan, isNewerThan), value must be an object with amount and unit
if (
(filter.qualifier.operator === "isOlderThan" || filter.qualifier.operator === "isNewerThan") &&
(typeof filter.value !== "object" || !("amount" in filter.value) || !("unit" in filter.value))
) {
return false;
}
// if the operator is an absolute date operator (isBefore, isAfter, isSameDay), value must be a string
if (
(filter.qualifier.operator === "isBefore" ||
filter.qualifier.operator === "isAfter" ||
filter.qualifier.operator === "isSameDay") &&
typeof filter.value !== "string"
) {
return false;
}
// if the operator is isBetween, value must be a tuple of two strings
if (filter.qualifier.operator === "isBetween" && !Array.isArray(filter.value)) {
return false;
}
return true;
},
{
message: "Value must be a string for string operators and a number for arithmetic operators",
message:
"Value must be a string for string operators, a number for arithmetic operators, and an object for relative date operators",
}
)
.refine(
@@ -153,6 +211,34 @@ export const ZSegmentFilter = z
return true;
}
// for relative date operators, validate the object structure
if (operator === "isOlderThan" || operator === "isNewerThan") {
if (typeof value === "object" && "amount" in value && "unit" in value) {
return value.amount > 0 && TIME_UNITS.includes(value.unit);
}
return false;
}
// for isBetween, validate we have a tuple with two non-empty strings
if (operator === "isBetween") {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!Array.isArray(value)) return false;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (value.length !== 2) return false;
return (
typeof value[0] === "string" &&
typeof value[1] === "string" &&
value[0].length > 0 &&
value[1].length > 0
);
}
// for absolute date operators, validate we have a non-empty string
if (operator === "isBefore" || operator === "isAfter" || operator === "isSameDay") {
return typeof value === "string" && value.length > 0;
}
// for string values, check they're not empty
if (typeof value === "string") {
return value.length > 0;
}