Skip to main content

Field Context Injection

Record field 하위 컴포넌트가 record id, field metadata, update hook을 props drilling 없이 가져가게 하는 패턴이다.

핵심은 FieldContextProvider가 metadata를 column definition으로 바꾸고, field input/display hook들이 FieldContext에서 필요한 값만 꺼내는 것이다.

1. 모듈 코드

  • src/modules/object-record/record-field/ui/contexts/FieldContext.ts: field 렌더링에 필요한 record id, field definition, update hook, UI 옵션을 담는다.
  • src/modules/object-record/record-field/ui/components/FieldContextProvider.tsx: object/field 이름으로 metadata를 찾고 field context value를 만든다.
  • src/modules/object-record/record-field/ui/meta-types/hooks/useTextField.ts: 개별 field type hook이 context와 record store를 조합해 input에 필요한 값을 반환한다.
// filepath: src/modules/object-record/record-field/ui/contexts/FieldContext.ts
import { createContext, type MouseEvent } from 'react';

import { type TriggerEventType } from 'twenty-ui/utilities';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';

export type RecordUpdateHookParams = {
variables: {
where: Record<string, unknown>;
updateOneRecordInput: Record<string, unknown>;
};
};

export type RecordUpdateHookReturn = {
loading?: boolean;
};

export type RecordUpdateHook = () => [
(params: RecordUpdateHookParams) => void,
RecordUpdateHookReturn,
];

export type GenericFieldContextType = {
fieldMetadataItemId?: string;
recordId: string;
fieldDefinition: FieldDefinition<FieldMetadata>;
useUpdateRecord?: RecordUpdateHook;
isLabelIdentifier: boolean;
clearable?: boolean;
maxWidth?: number;
isCentered?: boolean;
overridenIsFieldEmpty?: boolean;
displayedMaxRows?: number;
isDisplayModeFixHeight?: boolean;
isRecordFieldReadOnly: boolean;
disableChipClick?: boolean;
onRecordChipClick?: (event: MouseEvent) => void;
onOpenEditMode?: () => void;
onCloseEditMode?: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
triggerEvent?: TriggerEventType;
isForbidden?: boolean;
anchorId?: string;
};

export const FieldContext = createContext<GenericFieldContextType>(
{} as GenericFieldContextType,
);

// filepath: src/modules/object-record/record-field/ui/components/FieldContextProvider.tsx
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly';
import {
FieldContext,
type RecordUpdateHook,
type RecordUpdateHookParams,
} from '@/object-record/record-field/ui/contexts/FieldContext';
import { type ReactNode } from 'react';

export const FieldContextProvider = ({
fieldMetadataName,
fieldPosition,
isLabelIdentifier = false,
objectNameSingular,
objectRecordId,
customUseUpdateOneObjectHook,
showLabel = true,
children,
}: {
fieldMetadataName: string;
fieldPosition: number;
isLabelIdentifier?: boolean;
objectNameSingular: string;
objectRecordId: string;
customUseUpdateOneObjectHook?: RecordUpdateHook;
showLabel?: boolean;
children: ReactNode;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});

const fieldMetadataItem = objectMetadataItem?.fields.find(
(field) => field.name === fieldMetadataName,
);

const { updateOneRecord } = useUpdateOneRecord();

const useUpdateOneObjectMutation: RecordUpdateHook = () => {
const updateEntity = ({ variables }: RecordUpdateHookParams) => {
updateOneRecord({
objectNameSingular,
idToUpdate: variables.where.id as string,
updateOneRecordInput: variables.updateOneRecordInput,
});
};

return [updateEntity, { loading: false }];
};

const isRecordFieldReadOnly = useIsRecordFieldReadOnly({
recordId: objectRecordId,
fieldMetadataId: fieldMetadataItem?.id ?? '',
objectMetadataId: objectMetadataItem.id,
});

if (!fieldMetadataItem) {
return null;
}

return (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
recordId: objectRecordId,
isLabelIdentifier,
fieldDefinition: formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
showLabel,
position: fieldPosition,
objectMetadataItem,
labelWidth: 90,
}),
useUpdateRecord:
customUseUpdateOneObjectHook ?? useUpdateOneObjectMutation,
isRecordFieldReadOnly,
}}
>
{children}
</FieldContext.Provider>
);
};

// filepath: src/modules/object-record/record-field/ui/meta-types/hooks/useTextField.ts
import { useContext } from 'react';

import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useRecordFieldInput } from '@/object-record/record-field/ui/hooks/useRecordFieldInput';
import { recordFieldInputDraftValueComponentState } from '@/object-record/record-field/ui/states/recordFieldInputDraftValueComponentState';
import { type FieldTextValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata';
import { isFieldText } from '@/object-record/record-field/ui/types/guards/isFieldText';
import { isFieldTextValue } from '@/object-record/record-field/ui/types/guards/isFieldTextValue';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { useAtomFamilySelectorState } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { FieldMetadataType } from '~/generated-metadata/graphql';

export const useTextField = () => {
const { recordId, fieldDefinition, maxWidth } = useContext(FieldContext);

assertFieldMetadata(FieldMetadataType.TEXT, isFieldText, fieldDefinition);

const fieldName = fieldDefinition.metadata.fieldName;

const [fieldValue, setFieldValue] = useAtomFamilySelectorState(
recordStoreFamilySelector,
{ recordId, fieldName },
);
const fieldTextValue = isFieldTextValue(fieldValue) ? fieldValue : '';

const { setDraftValue } = useRecordFieldInput<FieldTextValue>();

const recordFieldInputDraftValue = useAtomComponentStateValue(
recordFieldInputDraftValueComponentState,
);

return {
draftValue: recordFieldInputDraftValue,
setDraftValue,
maxWidth,
fieldDefinition,
fieldValue: fieldTextValue,
setFieldValue,
};
};

2. 사용 예제

// Provider 사용
<FieldContextProvider
objectNameSingular="company"
objectRecordId={recordId}
fieldMetadataName="name"
fieldPosition={0}
>
<TextFieldInput />
</FieldContextProvider>;

// Consumer 사용
import { useTextField } from '@/object-record/record-field/ui/meta-types/hooks/useTextField';

export const TextFieldInput = () => {
const { draftValue, setDraftValue, fieldDefinition } = useTextField();

return (
<TextInput
value={draftValue}
placeholder={fieldDefinition.label}
onChange={setDraftValue}
/>
);
};