mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 13:40:31 -06:00
Compare commits
3 Commits
main
...
feat/attri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5555112e56 | ||
|
|
7c92e2b5bb | ||
|
|
e90bb93dfb |
404
.cursor/plans/date_attribute_type_feature_6f67ae57.plan.md
Normal file
404
.cursor/plans/date_attribute_type_feature_6f67ae57.plan.md
Normal 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).
|
||||
@@ -0,0 +1,3 @@
|
||||
import { AttributesPage } from "@/modules/ee/contacts/attributes/page";
|
||||
|
||||
export default AttributesPage;
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -65,6 +65,7 @@ export const updateContactAttributeKey = async (
|
||||
description: data.description,
|
||||
name: data.name,
|
||||
key: data.key,
|
||||
...(data.dataType && { dataType: data.dataType }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
99
apps/web/modules/ee/contacts/attributes/actions.ts
Normal file
99
apps/web/modules/ee/contacts/attributes/actions.ts
Normal 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;
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
35
apps/web/modules/ee/contacts/attributes/page.tsx
Normal file
35
apps/web/modules/ee/contacts/attributes/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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} />;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
28
apps/web/modules/ee/contacts/lib/detect-attribute-type.ts
Normal file
28
apps/web/modules/ee/contacts/lib/detect-attribute-type.ts
Normal 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";
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
114
apps/web/modules/ee/contacts/segments/lib/date-utils.test.ts
Normal file
114
apps/web/modules/ee/contacts/segments/lib/date-utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
93
apps/web/modules/ee/contacts/segments/lib/date-utils.ts
Normal file
93
apps/web/modules/ee/contacts/segments/lib/date-utils.ts
Normal 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()
|
||||
);
|
||||
};
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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())}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user