import { useState, useCallback, useRef, useEffect } from "react"; import { UnstyledButton, TextInput, Button, Stack, Text, Group, ActionIcon, Divider, ScrollArea, Loader, } from "@mantine/core"; import { IconTrash, IconPencil, IconChevronRight, IconSettings, IconMathFunction, } from "@tabler/icons-react"; import { IBaseProperty, BasePropertyType, TypeOptions, SelectTypeOptions, } from "@/ee/base/types/base.types"; import { useAtom } from "jotai"; import { propertyMenuCloseRequestAtomFamily } from "@/ee/base/atoms/base-atoms"; import { useUpdatePropertyMutation, useDeletePropertyMutation, } from "@/ee/base/queries/base-property-query"; import { PropertyTypePicker } from "./property-type-picker"; import { PropertyOptions } from "./property-options"; import { conversionWarning, isLossyConversion, NON_USER_TARGET_TYPES, } from "./conversion-warning"; import { useTranslation } from "react-i18next"; import { isSystemPropertyType, propertyTypes, defaultTypeOptionsFor, } from "@/ee/base/property-types/property-type.registry"; import cellClasses from "@/ee/base/styles/cells.module.css"; import classes from "@/ee/base/styles/property.module.css"; type PropertyMenuContentProps = { property: IBaseProperty; opened: boolean; onClose: () => void; onDirtyChange?: (dirty: boolean) => void; onEditFormula?: () => void; pageId: string; initialPanel?: "main" | "options"; }; type MenuPanel = | "main" | "rename" | "options" | "changeType" | "confirmTypeChange" | "confirmDelete" | "confirmDiscard"; const CHOICE_TYPES = new Set([ "select", "multiSelect", "status", ]); function typeOptionsForConversion( source: IBaseProperty, target: BasePropertyType, ): TypeOptions { if (!CHOICE_TYPES.has(source.type) || !CHOICE_TYPES.has(target)) { return defaultTypeOptionsFor(target); } const opts = source.typeOptions as SelectTypeOptions | undefined; const choices = opts?.choices ?? []; const choiceOrder = opts?.choiceOrder?.length ? opts.choiceOrder : choices.map((c) => c.id); const carried: SelectTypeOptions = { choices, choiceOrder }; if (target === "status") { carried.defaultValue = choices[0]?.id ?? null; } return carried; } export function PropertyMenuContent({ property, opened, onClose, onDirtyChange, onEditFormula, pageId, initialPanel, }: PropertyMenuContentProps) { const { t } = useTranslation(); const [panel, setPanel] = useState(initialPanel ?? "main"); const [renameValue, setRenameValue] = useState(property.name); const renameInputRef = useRef(null); const [optionsDirty, setOptionsDirty] = useState(false); // Portal target for nested Select dropdowns to avoid triggering closeOnClickOutside. const [optionsAnchor, setOptionsAnchor] = useState(null); const [pendingTargetType, setPendingTargetType] = useState(null); const pendingActionRef = useRef<"back" | "close" | null>(null); const sourcePanelRef = useRef<"rename" | "options" | null>(null); const [closeRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number]; const closeRequestRef = useRef(closeRequest); const renameDirty = renameValue !== property.name; const updatePropertyMutation = useUpdatePropertyMutation(); const deletePropertyMutation = useDeletePropertyMutation(); useEffect(() => { if (opened) { setPanel(initialPanel ?? "main"); setRenameValue(property.name); setOptionsDirty(false); setPendingTargetType(null); } }, [opened, property.name, initialPanel]); useEffect(() => { if (panel === "rename") { setTimeout(() => renameInputRef.current?.select(), 0); } }, [panel]); const handleOptionsDirtyChange = useCallback((dirty: boolean) => { setOptionsDirty(dirty); }, []); useEffect(() => { const dirty = (panel === "rename" && renameDirty) || (panel === "options" && optionsDirty); onDirtyChange?.(dirty); }, [panel, renameDirty, optionsDirty, onDirtyChange]); const commitRename = useCallback(() => { const trimmed = renameValue.trim(); if (trimmed && trimmed !== property.name) { updatePropertyMutation.mutate({ propertyId: property.id, pageId: property.pageId, name: trimmed, }); } }, [renameValue, property, updatePropertyMutation]); const handleRenameAndClose = useCallback(() => { commitRename(); onClose(); }, [commitRename, onClose]); const requestClose = useCallback(() => { if (panel === "rename" && renameDirty) { sourcePanelRef.current = "rename"; pendingActionRef.current = "close"; setPanel("confirmDiscard"); } else if (panel === "options" && optionsDirty) { sourcePanelRef.current = "options"; pendingActionRef.current = "close"; setPanel("confirmDiscard"); } else { onClose(); } }, [panel, renameDirty, optionsDirty, onClose]); const handleRenameKeyDown = useCallback( (e: React.KeyboardEvent) => { e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); handleRenameAndClose(); } if (e.key === "Escape") { e.preventDefault(); requestClose(); } }, [handleRenameAndClose, requestClose], ); const handleOptionsUpdate = useCallback( (typeOptions: Record) => { updatePropertyMutation.mutate({ propertyId: property.id, pageId: property.pageId, typeOptions, }); setOptionsDirty(false); }, [property, updatePropertyMutation], ); const handleTypeSelect = useCallback( (type: BasePropertyType) => { if (type === property.type) { onClose(); return; } setPendingTargetType(type); setPanel("confirmTypeChange"); }, [property.type, onClose], ); const handleApplyTypeChange = useCallback(() => { if (!pendingTargetType) return; updatePropertyMutation.mutate({ propertyId: property.id, pageId: property.pageId, type: pendingTargetType, typeOptions: typeOptionsForConversion(property, pendingTargetType), }); onClose(); }, [pendingTargetType, property, updatePropertyMutation, onClose]); const handleDelete = useCallback(() => { deletePropertyMutation.mutate({ propertyId: property.id, pageId: property.pageId, }); onClose(); }, [property, deletePropertyMutation, onClose]); const handleOptionsBack = useCallback(() => { if (optionsDirty) { sourcePanelRef.current = "options"; pendingActionRef.current = "back"; setPanel("confirmDiscard"); } else { setPanel("main"); } }, [optionsDirty]); useEffect(() => { if (closeRequest !== closeRequestRef.current) { closeRequestRef.current = closeRequest; if (opened) { requestClose(); } } }, [closeRequest, opened, requestClose]); const handleConfirmDiscard = useCallback(() => { setOptionsDirty(false); setRenameValue(property.name); const action = pendingActionRef.current; pendingActionRef.current = null; sourcePanelRef.current = null; if (action === "back") { setPanel("main"); } else { onClose(); } }, [property.name, onClose]); const handleCancelDiscard = useCallback(() => { const source = sourcePanelRef.current ?? "options"; pendingActionRef.current = null; sourcePanelRef.current = null; setPanel(source); }, []); return ( <> {panel === "main" && ( setPanel("rename")} onChangeType={() => setPanel("changeType")} onOptions={() => setPanel("options")} onDelete={() => setPanel("confirmDelete")} onEditFormula={onEditFormula} /> )} {panel === "rename" && ( {t("Rename property")} setRenameValue(e.currentTarget.value)} onKeyDown={handleRenameKeyDown} /> )} {panel === "changeType" && ( setPanel("main")} > {t("Change type")} )} {panel === "confirmTypeChange" && pendingTargetType && ( {t("Change type to {{label}}?", { label: t( propertyTypes.find((pt) => pt.type === pendingTargetType) ?.labelKey ?? pendingTargetType, ), })} {t(conversionWarning(property.type, pendingTargetType))} )} {(panel === "options" || panel === "confirmDiscard") && ( {t("Property options")} )} {panel === "confirmDelete" && ( {t("Delete property")} {t("Are you sure you want to delete")} {property.name}?{" "} {t("All data in this column will be lost.")} )} {panel === "confirmDiscard" && ( {t("Unsaved changes")} {t("You have unsaved changes. Do you want to discard them?")} )} ); } PropertyMenuContent.displayName = "PropertyMenuContent"; export function MenuItem({ icon, label, rightIcon, color, onClick, }: { icon: React.ReactNode; label: string; rightIcon?: React.ReactNode; color?: string; onClick: () => void; }) { return ( {icon} {label} {rightIcon} ); } function MainPanel({ property, onRename, onChangeType, onOptions, onDelete, onEditFormula, }: { property: IBaseProperty; onRename: () => void; onChangeType: () => void; onOptions: () => void; onDelete: () => void; onEditFormula?: () => void; }) { const { t } = useTranslation(); const isSystem = isSystemPropertyType(property.type); const isPending = property.pendingType != null; const hasOptions = !isSystem && !isPending && (property.type === "select" || property.type === "multiSelect" || property.type === "status" || property.type === "number" || property.type === "date" || property.type === "text" || property.type === "longText" || property.type === "checkbox" || property.type === "url" || property.type === "email"); const typeDef = propertyTypes.find((pt) => pt.type === property.type); const TypeIcon = typeDef?.icon; return ( } label={t("Rename")} onClick={onRename} /> {property.type === "formula" && !isPending && onEditFormula && ( } label={t("Edit formula")} onClick={onEditFormula} /> )} {isPending && ( {t("Converting…")} )} {!isSystem && !isPending && !property.isPrimary && ( {TypeIcon ? : null} {typeDef ? t(typeDef.labelKey) : property.type} )} {hasOptions && ( } label={t("Options")} rightIcon={} onClick={onOptions} /> )} {!property.isPrimary && !isPending && ( <> } label={t("Delete property")} color="red" onClick={onDelete} /> )} ); }