Skip to main content

Inline Cell Edit Lifecycle

display mode, edit mode, persist, close, focus 복귀를 하나의 cell shell로 표준화하는 패턴이다.

핵심은 cell owns lifecycle, field input only reports events다.

1. 모듈 코드

  • src/modules/object-record/record-field/ui/contexts/FieldInputEventContext.ts: field input이 Enter/Escape/Tab/click-outside 같은 lifecycle event를 보고하는 계약을 정의한다.
  • src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx: display/input router를 연결하고 event별 persist/close 정책을 소유한다.
  • src/modules/object-record/record-inline-cell/components/RecordInlineCellContext.tsx: cell shell이 필요한 display/edit content와 open/close handler를 전달한다.
  • src/modules/object-record/record-inline-cell/components/RecordInlineCellValue.tsx: loading/display 상태를 렌더링하고 click을 edit open으로 연결한다.
  • src/modules/object-record/record-inline-cell/hooks/useInlineCell.ts: 외부 컴포넌트가 inline cell을 열고 닫을 때 draft value와 focus stack을 함께 처리한다.
// filepath: src/modules/object-record/record-field/ui/contexts/FieldInputEventContext.ts
import { createContext } from 'react';

export type FieldInputEventArgs = {
newValue?: unknown;
skipPersist?: boolean;
skipClose?: boolean;
};

export type FieldInputEvent = (args: FieldInputEventArgs) => void;

export type FieldInputClickOutsideEvent = (args: {
newValue?: unknown;
skipPersist?: boolean;
event?: MouseEvent | TouchEvent;
}) => void;

export type FieldInputEventContextType = {
onEnter?: FieldInputEvent;
onCancel?: () => void;
onEscape?: FieldInputEvent;
onSubmit?: FieldInputEvent;
onTab?: FieldInputEvent;
onShiftTab?: FieldInputEvent;
onClickOutside?: FieldInputClickOutsideEvent;
};

export const FieldInputEventContext = createContext<FieldInputEventContextType>(
{} as FieldInputEventContextType,
);

// filepath: src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx
export const RecordInlineCell = ({ loading, instanceIdPrefix }: Props) => {
const { fieldDefinition, recordId, isRecordFieldReadOnly } =
useContext(FieldContext);
const { openFieldInput, closeFieldInput } = useOpenFieldInputEditMode();
const { persistFieldFromFieldInputContext } =
usePersistFieldFromFieldInputContext();
const { goBackToPreviousDropdownFocusId } =
useGoBackToPreviousDropdownFocusId();

const onOpenEditMode = () =>
openFieldInput({ fieldDefinition, recordId, prefix: instanceIdPrefix });

const closeInlineCell = () => {
closeFieldInput({ fieldDefinition, recordId, prefix: instanceIdPrefix });
goBackToPreviousDropdownFocusId();
};

const persistAndClose: FieldInputEvent = ({ newValue, skipPersist }) => {
if (skipPersist !== true) {
persistFieldFromFieldInputContext(newValue);
}

closeInlineCell();
};

const handleSubmit: FieldInputEvent = ({
newValue,
skipPersist,
skipClose,
}) => {
if (skipPersist !== true) {
persistFieldFromFieldInputContext(newValue);
}

if (skipClose !== true) {
closeInlineCell();
}
};

return (
<FieldInputEventContext.Provider
value={{
onCancel: closeInlineCell,
onEnter: persistAndClose,
onEscape: persistAndClose,
onTab: persistAndClose,
onShiftTab: persistAndClose,
onSubmit: handleSubmit,
}}
>
<FieldFocusContextProvider>
<RecordInlineCellContext.Provider
value={{
readonly: isRecordFieldReadOnly,
editModeContent: <FieldInput />,
displayModeContent: <FieldDisplay />,
loading,
onOpenEditMode,
onCloseEditMode: closeInlineCell,
}}
>
<RecordInlineCellContainer />
</RecordInlineCellContext.Provider>
</FieldFocusContextProvider>
</FieldInputEventContext.Provider>
);
};

// filepath: src/modules/object-record/record-inline-cell/components/RecordInlineCellValue.tsx
export const RecordInlineCellValue = () => {
const { readonly, loading, isCentered, onOpenEditMode } =
useRecordInlineCellContext();
const { isFocused } = useFieldFocus();

if (loading === true) {
return <RecordInlineCellSkeletonLoader />;
}

return (
<StyledClickableContainer readonly={readonly} isCentered={isCentered}>
<RecordInlineCellDisplayMode
isHovered={isFocused}
onClick={onOpenEditMode}
>
<FieldDisplay />
</RecordInlineCellDisplayMode>
</StyledClickableContainer>
);
};

// filepath: src/modules/object-record/record-inline-cell/hooks/useInlineCell.ts
export const useInlineCell = (recordFieldComponentInstanceIdFromProps?: string) => {
const { recordId, fieldDefinition } = useContext(FieldContext);
const recordFieldComponentInstanceId = useAvailableComponentInstanceIdOrThrow(
RecordFieldComponentInstanceContext,
recordFieldComponentInstanceIdFromProps,
);
const { onOpenEditMode, onCloseEditMode } = useRecordInlineCellContext();
const { scopeInstanceId } = useRecordFieldsScopeContextOrThrow();
const initFieldInputDraftValue = useInitDraftValue();

const openInlineCell = () => {
onOpenEditMode?.();
initFieldInputDraftValue({
recordId,
fieldDefinition,
fieldComponentInstanceId: recordFieldComponentInstanceId,
});
setActiveDropdownFocusIdAndMemorizePrevious(
getDropdownFocusIdForRecordField({
recordId,
fieldMetadataId: fieldDefinition.fieldMetadataId,
componentType: 'inline-cell',
instanceId: scopeInstanceId,
}),
);
};

const closeInlineCell = () => {
onCloseEditMode?.();
goBackToPreviousDropdownFocusId();
};

return { openInlineCell, closeInlineCell };
};

2. 사용 예제

// filepath: src/modules/object-record/record-field/ui/meta-types/input/components/TextFieldInput.tsx
export const TextFieldInput = () => {
const { onEnter, onEscape, onClickOutside } = useContext(FieldInputEventContext);
const [draftValue, setDraftValue] = useState(initialValue);

return (
<TextInput
value={draftValue}
onChange={setDraftValue}
onEnter={() => onEnter?.({ newValue: draftValue })}
onEscape={() => onEscape?.({ newValue: initialValue, skipPersist: true })}
onClickOutside={(event) =>
onClickOutside?.({ event, newValue: draftValue })
}
/>
);
};