Allow custom fields to be editable for system objects and prevent the error for timeline activity on system object pages. (#16268)

Solves #16015 

- Added a check in `useTimelineActivities.ts` to see if the system
object page we're viewing has timelineActivity being tracked before
querying to get the activity history.
- Removed @WorkspaceIsObjectUIReadOnly decorator from system objects and
added @WorkspaceIsFieldUIReadOnly to standard and system fields to allow
custom field edits as requested in the issue.
- Did not add calendarEvents or other system objects to timelineActivity
just yet since keeping track of timeline activity for every one of them
felt counter-intuitive and bloated. In order to determine which objects
need timelineActivity, I think we need to fix the broken views of system
objects first, such as the one in the attached screenshots - a good
number of them are broken. The check I added to
`useTimelineActivities.ts` hook displays timeline activity as empty for
the time - error would not be shown as suggested by Thomas.

<img width="1062" height="858" alt="image"
src="https://github.com/user-attachments/assets/e877e0fe-b665-46e3-b785-e84f2af7f833"
/>

<br />

<img width="1061" height="858" alt="image"
src="https://github.com/user-attachments/assets/0eba8c1c-444a-4b13-beda-64b95cf39077"
/>

- Editing custom fields on calendar events (and other objects without
position fields) crashed with TypeError: Cannot convert undefined or
null to object in sortCachedObjectEdges. This happened because some
cached queries had empty orderBy arrays ([]), and the optimistic effect
tried to sort with them. Objects without position fields returned
empty orderBy arrays when there were no sorts, while objects with
position fields automatically got [{ position: 'AscNullsFirst' }].
The backend always adds { id: 'AscNullsFirst' } as a fallback, but
the frontend didn't match this. So, I added { id: 'AscNullsFirst' } to
the Frontend as default orderBy when there are no sorts and no position
field.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Apply per-field UI read-only to system/standard fields and skip
timeline activity fetching when the target object isn’t related; refine
record-table cell open/navigation behavior.
> 
> - Server: Replace object-level UI read-only with per-field
`isUIReadOnly` across many standard objects (e.g., `calendarEvent`,
`workspaceMember`, messaging, calendar, favorites, attachments,
workflows, etc.), and mirror this via `@WorkspaceIsFieldUIReadOnly` on
workspace entities.
> - Frontend: In `useTimelineActivities.ts`, check object metadata for a
relation to `timelineActivity` and `skip` the query when absent,
preventing errors on system object pages.
> - Frontend: Simplify/adjust record-table cell logic—remove unused
args, allow navigation from first column when non-empty, block editing
for read-only records (while still allowing navigation), and update
button handlers/hover styles accordingly.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
dac1262d70d97ce84b39730454dd3579c494994a. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Abdullah.
2025-12-23 03:22:30 +05:00
committed by GitHub
parent 5003fc4196
commit c6ea7ae288
52 changed files with 465 additions and 77 deletions
@@ -2,6 +2,7 @@ import { useLinkedObjectsTitle } from '@/activities/timeline-activities/hooks/us
import { type TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
@@ -23,6 +24,17 @@ export const useTimelineActivities = (
nameSingular: targetableObject.targetObjectNameSingular,
});
const { objectMetadataItem: timelineActivityMetadata } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.TimelineActivity,
});
const hasTimelineActivityField = timelineActivityMetadata.fields.some(
(field) =>
field.relation?.targetObjectMetadata?.nameSingular ===
targetableObject.targetObjectNameSingular,
);
const { recordGqlFields: depthOneRecordGqlFields } =
useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.TimelineActivity,
@@ -34,6 +46,7 @@ export const useTimelineActivities = (
loading: loadingTimelineActivities,
fetchMoreRecords,
} = useFindManyRecords<TimelineActivity>({
skip: !hasTimelineActivityField,
objectNameSingular: CoreObjectNameSingular.TimelineActivity,
filter: {
[targetableObjectFieldIdName]: {
@@ -26,7 +26,7 @@ export const RecordTableCellEditButton = () => {
const handleMainButtonClick = () => {
if (!isFieldInputOnly && isFirstColumn) {
openTableCell(undefined, false, true);
openTableCell(undefined, true);
} else {
openTableCell();
}
@@ -18,27 +18,28 @@ import { BORDER_COMMON } from 'twenty-ui/theme';
import { useIsMobile } from 'twenty-ui/utilities';
const StyledRecordTableCellHoveredPortalContent = styled.div<{
isReadOnly: boolean;
showInteractiveStyle: boolean;
isRowActive: boolean;
}>`
align-items: center;
background: ${({ theme }) => theme.background.transparent.secondary};
background-color: ${({ theme, isRowActive }) =>
isRowActive ? theme.accent.quaternary : theme.background.primary};
border-radius: ${({ isReadOnly }) =>
!isReadOnly ? BORDER_COMMON.radius.sm : 'none'};
border-radius: ${({ showInteractiveStyle }) =>
showInteractiveStyle ? BORDER_COMMON.radius.sm : 'none'};
box-sizing: border-box;
cursor: ${({ isReadOnly }) => (isReadOnly ? 'default' : 'pointer')};
cursor: ${({ showInteractiveStyle }) =>
showInteractiveStyle ? 'pointer' : 'default'};
display: flex;
height: ${RECORD_TABLE_ROW_HEIGHT}px;
outline: ${({ theme, isReadOnly, isRowActive }) =>
outline: ${({ theme, showInteractiveStyle, isRowActive }) =>
isRowActive
? 'none'
: isReadOnly
? `1px solid ${theme.border.color.medium}`
: `1px solid ${theme.font.color.extraLight}`};
: showInteractiveStyle
? `1px solid ${theme.font.color.extraLight}`
: `1px solid ${theme.border.color.medium}`};
user-select: none;
`;
@@ -57,7 +58,11 @@ export const RecordTableCellHoveredPortalContent = () => {
const isFieldInputOnly = useIsFieldInputOnly();
const showButton =
!isFieldInputOnly && !isReadOnly && !(isMobile && isFirstColumn);
!isFieldInputOnly &&
(!isReadOnly || isFirstColumn) &&
!(isMobile && isFirstColumn);
const showInteractiveStyle = !isReadOnly || (isFirstColumn && showButton);
const { rowIndex } = useRecordTableRowContextOrThrow();
@@ -68,7 +73,7 @@ export const RecordTableCellHoveredPortalContent = () => {
return (
<StyledRecordTableCellHoveredPortalContent
isReadOnly={isReadOnly}
showInteractiveStyle={showInteractiveStyle}
isRowActive={isRowActive}
>
{isFieldInputOnly ? (
@@ -1,10 +1,9 @@
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { useRecoilCallback } from 'recoil';
import { useInitDraftValue } from '@/object-record/record-field/ui/hooks/useInitDraftValue';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { isFieldValueEmpty } from '@/object-record/record-field/ui/utils/isFieldValueEmpty';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { FOCUS_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/FocusClickOutsideListenerId';
import { RECORD_TABLE_CELL_INPUT_ID_PREFIX } from '@/object-record/record-table/constants/RecordTableCellInputIdPrefix';
@@ -16,7 +15,6 @@ import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useOpenFieldInputEditMode } from '@/object-record/record-field/ui/hooks/useOpenFieldInputEditMode';
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState';
import { RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/RecordTableClickOutsideListenerId';
import { recordTableCellEditModePositionComponentState } from '@/object-record/record-table/states/recordTableCellEditModePositionComponentState';
import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField';
@@ -37,11 +35,8 @@ export type OpenTableCellArgs = {
initialValue?: string;
cellPosition: TableCellPosition;
isReadOnly: boolean;
pathToShowPage: string;
objectNameSingular: string;
fieldDefinition: FieldDefinition<FieldMetadata>;
recordId: string;
isActionButtonClick: boolean;
isNavigating: boolean;
};
@@ -64,11 +59,6 @@ export const useOpenRecordTableCell = (recordTableId: string) => {
const initDraftValue = useInitDraftValue();
const setViewableRecordId = useSetRecoilState(viewableRecordIdState);
const setViewableRecordNameSingular = useSetRecoilState(
viewableRecordNameSingularState,
);
const { setActiveDropdownFocusIdAndMemorizePrevious } =
useSetActiveDropdownFocusIdAndMemorizePrevious();
@@ -94,16 +84,10 @@ export const useOpenRecordTableCell = (recordTableId: string) => {
initialValue,
cellPosition,
isReadOnly,
objectNameSingular,
fieldDefinition,
recordId,
isActionButtonClick,
isNavigating,
}: OpenTableCellArgs) => {
if (isReadOnly) {
return;
}
set(clickOutsideListenerIsActivatedState, false);
const isFirstColumnCell = cellPosition.column === 0;
@@ -121,10 +105,7 @@ export const useOpenRecordTableCell = (recordTableId: string) => {
fieldValue,
});
if (
(isFirstColumnCell && !isEmpty && !isActionButtonClick) ||
isNavigating
) {
if ((isFirstColumnCell && !isEmpty) || isNavigating) {
leaveTableFocus();
const openRecordIn = snapshot
@@ -141,11 +122,8 @@ export const useOpenRecordTableCell = (recordTableId: string) => {
return;
}
if (isFirstColumnCell && !isEmpty && isActionButtonClick) {
leaveTableFocus();
setViewableRecordId(recordId);
setViewableRecordNameSingular(objectNameSingular);
// Block editing for read-only records, but allow navigation (handled above)
if (isReadOnly) {
return;
}
@@ -200,8 +178,6 @@ export const useOpenRecordTableCell = (recordTableId: string) => {
leaveTableFocus,
activateRecordTableRow,
unfocusRecordTableRow,
setViewableRecordId,
setViewableRecordNameSingular,
openRecordFromIndexView,
],
);
@@ -1,23 +1,9 @@
import { useContext } from 'react';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { type TableCellPosition } from '@/object-record/record-table/types/TableCellPosition';
import { useRecordTableBodyContextOrThrow } from '@/object-record/record-table/contexts/RecordTableBodyContext';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
export type OpenTableCellArgs = {
initialValue?: string;
cellPosition: TableCellPosition;
isReadOnly: boolean;
pathToShowPage: string;
fieldDefinition: FieldDefinition<FieldMetadata>;
recordId: string;
};
export const useOpenRecordTableCellFromCell = () => {
const {
recordId,
@@ -25,27 +11,17 @@ export const useOpenRecordTableCellFromCell = () => {
isRecordFieldReadOnly: isReadOnly,
} = useContext(FieldContext);
const { pathToShowPage, objectNameSingular } =
useRecordTableRowContextOrThrow();
const { onOpenTableCell } = useRecordTableBodyContextOrThrow();
const { cellPosition } = useContext(RecordTableCellContext);
const openTableCell = (
initialValue?: string,
isActionButtonClick = false,
isNavigating = false,
) => {
const openTableCell = (initialValue?: string, isNavigating = false) => {
onOpenTableCell({
cellPosition,
recordId,
fieldDefinition,
isReadOnly,
pathToShowPage,
objectNameSingular,
initialValue,
isActionButtonClick,
isNavigating,
});
};