From 1324ad1ee34f3553f494d024a1fc9c138cb011f7 Mon Sep 17 00:00:00 2001 From: Baptiste Devessier Date: Mon, 22 Dec 2025 15:21:39 +0100 Subject: [PATCH] Edit simple values in Field widget (#16749) https://github.com/user-attachments/assets/116b8259-b366-47bf-8068-b5276b138e03 --- .../components/WidgetActionFieldEdit.tsx | 126 ++++++++++++++++++ .../components/WidgetActionRenderer.tsx | 55 +------- .../field/components/FieldWidgetDisplay.tsx | 7 +- .../components/FieldWidgetEditAction.tsx | 44 ++++++ .../FieldWidgetRelationEditAction.tsx | 1 - .../field/utils/getFieldWidgetInstanceId.ts | 11 ++ .../widgets/hooks/useWidgetActions.ts | 35 +++-- 7 files changed, 212 insertions(+), 67 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionFieldEdit.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetEditAction.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/field/utils/getFieldWidgetInstanceId.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionFieldEdit.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionFieldEdit.tsx new file mode 100644 index 0000000000..9588874497 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionFieldEdit.tsx @@ -0,0 +1,126 @@ +import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly'; +import { isRecordFieldReadOnly } from '@/object-record/read-only/utils/isRecordFieldReadOnly'; +import { + FieldContext, + type GenericFieldContextType, +} from '@/object-record/record-field/ui/contexts/FieldContext'; +import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext'; +import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; +import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; +import { useRecordShowContainerActions } from '@/object-record/record-show/hooks/useRecordShowContainerActions'; +import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; +import { useResolveFieldMetadataIdFromNameOrId } from '@/page-layout/hooks/useResolveFieldMetadataIdFromNameOrId'; +import { FieldWidgetEditAction } from '@/page-layout/widgets/field/components/FieldWidgetEditAction'; +import { FieldWidgetRelationEditAction } from '@/page-layout/widgets/field/components/FieldWidgetRelationEditAction'; +import { getFieldWidgetInstanceId } from '@/page-layout/widgets/field/utils/getFieldWidgetInstanceId'; +import { isFieldWidget } from '@/page-layout/widgets/field/utils/isFieldWidget'; +import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; +import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; +import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; +import { assertIsDefinedOrThrow } from 'twenty-shared/utils'; +import { useCurrentWidget } from '../hooks/useCurrentWidget'; + +export const WidgetActionFieldEdit = () => { + const widget = useCurrentWidget(); + const targetRecord = useTargetRecord(); + const { isInRightDrawer } = useLayoutRenderingContext(); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: targetRecord.targetObjectNameSingular, + }); + + const fieldMetadataId = isFieldWidget(widget) + ? widget.configuration.fieldMetadataId + : undefined; + + const resolvedFieldMetadataId = useResolveFieldMetadataIdFromNameOrId( + fieldMetadataId ?? '', + ); + + const { fieldMetadataItem } = useFieldMetadataItemById( + resolvedFieldMetadataId ?? '', + ); + + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ + objectNameSingular: objectMetadataItem.nameSingular, + objectRecordId: targetRecord.id, + }); + + const isRecordReadOnly = useIsRecordReadOnly({ + recordId: targetRecord.id, + objectMetadataId: objectMetadataItem.id, + }); + + assertIsDefinedOrThrow(fieldMetadataItem); + + const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({ + field: fieldMetadataItem, + position: 0, + objectMetadataItem, + showLabel: true, + labelWidth: 90, + }); + + const isRelationField = + isFieldRelation(fieldDefinition) || isFieldMorphRelation(fieldDefinition); + + if (isRelationField) { + return ( + + ); + } + + const instanceId = getFieldWidgetInstanceId({ + recordId: targetRecord.id, + fieldName: fieldMetadataItem.name, + isInRightDrawer, + }); + + const recordFieldInputInstanceId = getRecordFieldInputInstanceId({ + recordId: targetRecord.id, + fieldName: fieldMetadataItem.name, + prefix: instanceId, + }); + + const fieldContextValue = { + recordId: targetRecord.id, + maxWidth: 200, + isLabelIdentifier: false, + fieldDefinition, + useUpdateRecord: useUpdateOneObjectRecordMutation, + isDisplayModeFixHeight: false, + isRecordFieldReadOnly: isRecordFieldReadOnly({ + isRecordReadOnly, + objectPermissions: getObjectPermissionsFromMapByObjectMetadataId({ + objectPermissionsByObjectMetadataId, + objectMetadataId: objectMetadataItem.id, + }), + fieldMetadataItem: { + id: fieldMetadataItem.id, + isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false, + }, + }), + anchorId: recordFieldInputInstanceId, + } satisfies GenericFieldContextType; + + return ( + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionRenderer.tsx index e8615b048e..fc979eb21a 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionRenderer.tsx @@ -1,16 +1,8 @@ -import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition'; -import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; -import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; -import { useResolveFieldMetadataIdFromNameOrId } from '@/page-layout/hooks/useResolveFieldMetadataIdFromNameOrId'; -import { FieldWidgetRelationEditAction } from '@/page-layout/widgets/field/components/FieldWidgetRelationEditAction'; -import { isFieldWidget } from '@/page-layout/widgets/field/utils/isFieldWidget'; import { type WidgetAction } from '@/page-layout/widgets/types/WidgetAction'; -import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; -import { assertIsDefinedOrThrow, CustomError } from 'twenty-shared/utils'; +import { CustomError } from 'twenty-shared/utils'; import { WidgetType } from '~/generated/graphql'; import { useCurrentWidget } from '../hooks/useCurrentWidget'; +import { WidgetActionFieldEdit } from './WidgetActionFieldEdit'; type WidgetActionRendererProps = { action: WidgetAction; @@ -18,50 +10,9 @@ type WidgetActionRendererProps = { export const WidgetActionRenderer = ({ action }: WidgetActionRendererProps) => { const widget = useCurrentWidget(); - const targetRecord = useTargetRecord(); - - const { objectMetadataItem } = useObjectMetadataItem({ - objectNameSingular: targetRecord.targetObjectNameSingular, - }); - - const fieldMetadataId = isFieldWidget(widget) - ? widget.configuration.fieldMetadataId - : undefined; - - const resolvedFieldMetadataId = useResolveFieldMetadataIdFromNameOrId( - fieldMetadataId ?? '', - ); - - const { fieldMetadataItem } = useFieldMetadataItemById( - resolvedFieldMetadataId ?? '', - ); if (action.id === 'edit' && widget.type === WidgetType.FIELD) { - assertIsDefinedOrThrow(fieldMetadataItem); - - const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({ - field: fieldMetadataItem, - position: 0, - objectMetadataItem, - showLabel: true, - labelWidth: 90, - }); - - const isRelationField = - isFieldRelation(fieldDefinition) || isFieldMorphRelation(fieldDefinition); - - if (!isRelationField) { - throw new Error( - 'Edit action is only available for relation fields for now', - ); - } - - return ( - - ); + return ; } throw new CustomError( diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetDisplay.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetDisplay.tsx index 2b35e599eb..54724bd751 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetDisplay.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetDisplay.tsx @@ -12,6 +12,7 @@ import { FieldWidgetCellEditModePortal } from '@/page-layout/widgets/field/compo import { FieldWidgetCellHoveredPortal } from '@/page-layout/widgets/field/components/FieldWidgetCellHoveredPortal'; import { FieldWidgetInlineCell } from '@/page-layout/widgets/field/components/FieldWidgetInlineCell'; import { fieldWidgetHoverComponentState } from '@/page-layout/widgets/field/states/fieldWidgetHoverComponentState'; +import { getFieldWidgetInstanceId } from '@/page-layout/widgets/field/utils/getFieldWidgetInstanceId'; import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDrawerContext'; import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; @@ -41,7 +42,11 @@ export const FieldWidgetDisplay = ({ fieldWidgetHoverComponentState, ); - const instanceId = `field-widget-${recordId}-${fieldMetadataItem.name}-${isInRightDrawer ? 'right-drawer' : ''}`; + const instanceId = getFieldWidgetInstanceId({ + recordId, + fieldName: fieldMetadataItem.name, + isInRightDrawer, + }); const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetEditAction.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetEditAction.tsx new file mode 100644 index 0000000000..058fbfcb2a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetEditAction.tsx @@ -0,0 +1,44 @@ +import { useContext } from 'react'; + +import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell'; +import { useOpenFieldWidgetFieldInputEditMode } from '@/page-layout/widgets/field/hooks/useOpenFieldWidgetFieldInputEditMode'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { IconPencil } from 'twenty-ui/display'; +import { LightIconButton } from 'twenty-ui/input'; + +const StyledEditButton = styled(LightIconButton)` + ${({ theme }) => css` + opacity: 0; + pointer-events: none; + transition: opacity ${theme.animation.duration.instant}s ease; + `} + + .widget:hover & { + opacity: 1; + pointer-events: auto; + } +`; + +export const FieldWidgetEditAction = () => { + const { recordId, fieldDefinition } = useContext(FieldContext); + const { openInlineCell } = useInlineCell(); + const { openFieldInput } = useOpenFieldWidgetFieldInputEditMode(); + + const handleClick = () => { + openInlineCell(); + openFieldInput({ + fieldDefinition, + recordId, + }); + }; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationEditAction.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationEditAction.tsx index 0452fad100..e8668df6dd 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationEditAction.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationEditAction.tsx @@ -101,7 +101,6 @@ export const FieldWidgetRelationEditAction = ({ isDropdownOpen={isRelationSelectionDropdownOpen} Icon={IconPencil} accent="secondary" - className="display-on-widget-hover" /> ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/utils/getFieldWidgetInstanceId.ts b/packages/twenty-front/src/modules/page-layout/widgets/field/utils/getFieldWidgetInstanceId.ts new file mode 100644 index 0000000000..2b7f66f760 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/utils/getFieldWidgetInstanceId.ts @@ -0,0 +1,11 @@ +export const getFieldWidgetInstanceId = ({ + recordId, + fieldName, + isInRightDrawer, +}: { + recordId: string; + fieldName: string; + isInRightDrawer: boolean; +}): string => { + return `field-widget-${recordId}-${fieldName}${isInRightDrawer ? '-right-drawer' : ''}`; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetActions.ts b/packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetActions.ts index f2a7884dc3..b9926b316a 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetActions.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetActions.ts @@ -1,12 +1,13 @@ import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition'; -import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; -import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly'; +import { isRecordFieldReadOnly } from '@/object-record/read-only/utils/isRecordFieldReadOnly'; import { useResolveFieldMetadataIdFromNameOrId } from '@/page-layout/hooks/useResolveFieldMetadataIdFromNameOrId'; import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; import { isFieldWidget } from '@/page-layout/widgets/field/utils/isFieldWidget'; import { type WidgetAction } from '@/page-layout/widgets/types/WidgetAction'; +import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; import { isDefined } from 'twenty-shared/utils'; @@ -35,6 +36,13 @@ export const useWidgetActions = ({ resolvedFieldMetadataId ?? '', ); + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const isRecordReadOnly = useIsRecordReadOnly({ + recordId: targetRecord.id, + objectMetadataId: objectMetadataItem.id, + }); + const actions: WidgetAction[] = []; if ( @@ -45,18 +53,19 @@ export const useWidgetActions = ({ return actions; } - const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({ - field: fieldMetadataItem, - position: 0, - objectMetadataItem, - showLabel: true, - labelWidth: 90, + const isFieldReadOnly = isRecordFieldReadOnly({ + isRecordReadOnly, + objectPermissions: getObjectPermissionsFromMapByObjectMetadataId({ + objectPermissionsByObjectMetadataId, + objectMetadataId: objectMetadataItem.id, + }), + fieldMetadataItem: { + id: fieldMetadataItem.id, + isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false, + }, }); - const isRelationField = - isFieldRelation(fieldDefinition) || isFieldMorphRelation(fieldDefinition); - - if (isRelationField) { + if (!isFieldReadOnly) { actions.push({ id: 'edit', position: 0,