feat: Support custom fields in calendar event detail panel (#15853)
## Overview This PR refactors the `CalendarEventDetails` component to dynamically display both standard and custom fields added via the metadata API, instead of using a hardcoded field list. ## Changes - Replaced hardcoded `fieldsToDisplay` array with dynamic field fetching using `useFieldListFieldMetadataItems` hook - Split fields into `standardFields` (maintaining original order) and `customFields` - Introduced `renderField` helper function to eliminate code duplication - Custom fields now automatically appear at the bottom of the detail panel after standard fields - Maintained exact field order and all existing functionality including participant response status display ## Technical Details - Uses existing `useFieldListFieldMetadataItems` pattern already established in the codebase - Standard field order explicitly defined: startsAt, endsAt, conferenceLink, location, description - Participant response status (Yes/Maybe/No) correctly positioned between first 2 and last 3 standard fields - All fields respect permissions and visibility settings from metadata ## Testing - ✅ Verified all standard fields display in correct order - ✅ Added custom field `mycustomfieldtest` via metadata API - displays correctly at bottom - ✅ Participant responses (Yes/Maybe/No) render correctly with avatars - ✅ Event title, creation date, and event chip display properly - ✅ Canceled event styling (strikethrough) works - ✅ No console errors or regressions detected - ✅ Read-only behavior maintained (calendar events sync from external sources) ## Screenshots <img width="1401" height="746" alt="CleanShot 2025-11-17 at 11 23 57" src="https://github.com/user-attachments/assets/3d967ec5-6d31-4fc3-b971-c73ea521c87d" /> ## Related This enables users to extend calendar events with custom metadata fields that will automatically display in the UI without code changes. --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
committed by
GitHub
parent
6fa75e1360
commit
f6cd51ba97
@@ -30,6 +30,18 @@ type ButtonProps = {}; // Component props suffix with 'Props'
|
||||
// ✅ Files and directories - kebab-case
|
||||
// user-profile.component.tsx
|
||||
// user-profile.styles.ts
|
||||
|
||||
// ❌ NEVER use abbreviations in variable names
|
||||
// Bad
|
||||
const users = data.map((u) => u.name);
|
||||
const field = items.find((f) => f.id === id);
|
||||
|
||||
// Good
|
||||
const users = data.map((user) => user.name);
|
||||
const field = items.find((item) => item.id === id);
|
||||
const fieldMetadata = inlineFields.find(
|
||||
(fieldMetadataItem) => fieldMetadataItem.name === fieldName,
|
||||
);
|
||||
```
|
||||
|
||||
## Import Organization
|
||||
@@ -59,11 +71,11 @@ const processUserData = (
|
||||
): ProcessedUser => {
|
||||
const processedUser = transformUserData(user);
|
||||
applyOptions(processedUser, options);
|
||||
|
||||
|
||||
if (callback) {
|
||||
callback(processedUser);
|
||||
}
|
||||
|
||||
|
||||
return processedUser;
|
||||
};
|
||||
```
|
||||
@@ -71,7 +83,7 @@ const processUserData = (
|
||||
## Comments
|
||||
```typescript
|
||||
// ✅ Use short-form comments, NOT JSDoc blocks
|
||||
// ✅ Explain business logic and non-obvious intentions
|
||||
// ✅ Explain business logic and non-obvious intentions (WHY, not WHAT)
|
||||
// Apply 15% discount for premium users with orders > $100
|
||||
const discount = isPremiumUser && orderTotal > 100 ? 0.15 : 0;
|
||||
|
||||
@@ -85,12 +97,55 @@ const calculateTotalPrice = (basePrice: number): number => {
|
||||
// Implementation
|
||||
};
|
||||
|
||||
// ❌ AVOID obvious comments that just describe what code does
|
||||
// Bad: Get all inline fields dynamically
|
||||
const { inlineFieldMetadataItems } = useFieldListFieldMetadataItems({...});
|
||||
|
||||
// Bad: Define standard fields in display order
|
||||
const standardFieldOrder = ['startsAt', 'endsAt', 'conferenceLink'];
|
||||
|
||||
// Bad: Split fields into standard and custom
|
||||
const standardFields = standardFieldOrder.map(...)
|
||||
|
||||
// ✅ GOOD: Only comment if explaining non-obvious business logic
|
||||
// Calendar events display standard fields first, then custom fields after participants
|
||||
// to maintain consistency with the legacy UI behavior
|
||||
const standardFields = standardFieldOrder.map(...)
|
||||
|
||||
// ❌ AVOID JSDoc blocks - use short comments instead
|
||||
/**
|
||||
* This style is NOT preferred in this codebase
|
||||
*/
|
||||
```
|
||||
|
||||
**Comment Guidelines:**
|
||||
- **DO** comment complex business rules or domain-specific logic
|
||||
- **DO** comment non-obvious algorithmic decisions
|
||||
- **DO** add TODOs for future improvements
|
||||
- **DON'T** comment obvious variable declarations or function calls
|
||||
- **DON'T** comment what is already clear from well-named variables/functions
|
||||
- **DON'T** add comments that just repeat what the code says
|
||||
|
||||
## Utility Helpers
|
||||
```typescript
|
||||
// ✅ Use existing utility helpers instead of manual checks
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isNonEmptyString, isNonEmptyArray } from '@sniptt/guards';
|
||||
|
||||
// ❌ Manual type guards
|
||||
const validItems = items.filter((item): item is Item => item !== undefined);
|
||||
const hasValue = value !== null && value !== undefined;
|
||||
|
||||
// ✅ Use utility helpers
|
||||
const validItems = items.filter(isDefined);
|
||||
const hasValue = isDefined(value);
|
||||
|
||||
// Other useful helpers:
|
||||
// - isDefined(value) - checks !== null && !== undefined
|
||||
// - isNonEmptyString(value) - checks string is defined and not empty
|
||||
// - isNonEmptyArray(value) - checks array is defined and has items
|
||||
```
|
||||
|
||||
## Security Patterns
|
||||
```typescript
|
||||
// ✅ CSV Export: Always apply security first, then formatting
|
||||
|
||||
+28
-12
@@ -5,16 +5,18 @@ import { CalendarEventParticipantsResponseStatus } from '@/activities/calendar/c
|
||||
import { type CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
|
||||
import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly';
|
||||
import { useFieldListFieldMetadataItems } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems';
|
||||
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
|
||||
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
|
||||
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
|
||||
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
|
||||
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Chip, ChipAccent, ChipSize, ChipVariant } from 'twenty-ui/components';
|
||||
import { IconCalendarEvent } from 'twenty-ui/display';
|
||||
import { mapArrayToObject } from '~/utils/array/mapArrayToObject';
|
||||
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
|
||||
|
||||
type CalendarEventDetailsProps = {
|
||||
@@ -80,7 +82,13 @@ export const CalendarEventDetails = ({
|
||||
objectNameSingular: CoreObjectNameSingular.CalendarEvent,
|
||||
});
|
||||
|
||||
const fieldsToDisplay = [
|
||||
const { inlineFieldMetadataItems } = useFieldListFieldMetadataItems({
|
||||
objectNameSingular: CoreObjectNameSingular.CalendarEvent,
|
||||
showRelationSections: false,
|
||||
excludeCreatedAtAndUpdatedAt: true,
|
||||
});
|
||||
|
||||
const standardFieldOrder = [
|
||||
'startsAt',
|
||||
'endsAt',
|
||||
'conferenceLink',
|
||||
@@ -88,9 +96,16 @@ export const CalendarEventDetails = ({
|
||||
'description',
|
||||
];
|
||||
|
||||
const fieldsByName = mapArrayToObject(
|
||||
objectMetadataItem.fields,
|
||||
({ name }) => name,
|
||||
const standardFields = standardFieldOrder
|
||||
.map((fieldName) =>
|
||||
inlineFieldMetadataItems.find(
|
||||
(fieldMetadataItem) => fieldMetadataItem.name === fieldName,
|
||||
),
|
||||
)
|
||||
.filter(isDefined);
|
||||
|
||||
const customFields = inlineFieldMetadataItems.filter(
|
||||
(field) => field.isCustom && !standardFieldOrder.includes(field.name),
|
||||
);
|
||||
|
||||
const { calendarEventParticipants } = calendarEvent;
|
||||
@@ -100,14 +115,14 @@ export const CalendarEventDetails = ({
|
||||
objectMetadataId: objectMetadataItem.id,
|
||||
});
|
||||
|
||||
const Fields = fieldsToDisplay.map((fieldName) => (
|
||||
<StyledPropertyBox key={fieldName}>
|
||||
const renderField = (fieldMetadataItem: FieldMetadataItem) => (
|
||||
<StyledPropertyBox key={fieldMetadataItem.id}>
|
||||
<FieldContext.Provider
|
||||
value={{
|
||||
recordId: calendarEvent.id,
|
||||
isLabelIdentifier: false,
|
||||
fieldDefinition: formatFieldMetadataItemAsFieldDefinition({
|
||||
field: fieldsByName[fieldName],
|
||||
field: fieldMetadataItem,
|
||||
objectMetadataItem,
|
||||
showLabel: true,
|
||||
labelWidth: 72,
|
||||
@@ -121,7 +136,7 @@ export const CalendarEventDetails = ({
|
||||
value={{
|
||||
instanceId: getRecordFieldInputInstanceId({
|
||||
recordId: calendarEvent.id,
|
||||
fieldName,
|
||||
fieldName: fieldMetadataItem.name,
|
||||
prefix: INPUT_ID_PREFIX,
|
||||
}),
|
||||
}}
|
||||
@@ -130,7 +145,7 @@ export const CalendarEventDetails = ({
|
||||
</RecordFieldComponentInstanceContext.Provider>
|
||||
</FieldContext.Provider>
|
||||
</StyledPropertyBox>
|
||||
));
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
@@ -154,13 +169,14 @@ export const CalendarEventDetails = ({
|
||||
</StyledCreatedAt>
|
||||
</StyledHeader>
|
||||
<StyledFields>
|
||||
{Fields.slice(0, 2)}
|
||||
{standardFields.slice(0, 2).map(renderField)}
|
||||
{calendarEventParticipants && (
|
||||
<CalendarEventParticipantsResponseStatus
|
||||
participants={calendarEventParticipants}
|
||||
/>
|
||||
)}
|
||||
{Fields.slice(2)}
|
||||
{standardFields.slice(2).map(renderField)}
|
||||
{customFields.map(renderField)}
|
||||
</StyledFields>
|
||||
</StyledContainer>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user