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:
Thomas des Francs
2025-11-18 18:11:07 +01:00
committed by GitHub
parent 6fa75e1360
commit f6cd51ba97
2 changed files with 86 additions and 15 deletions
+58 -3
View File
@@ -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
@@ -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>
);