import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { Popover, TextInput, Button, Group, Stack, Divider, UnstyledButton, Text, ScrollArea, } from "@mantine/core"; import { IconPlus, IconChevronRight } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { BasePropertyType, IBaseProperty, TypeOptions, } from "@/ee/base/types/base.types"; import { useCreatePropertyMutation } from "@/ee/base/queries/base-property-query"; import { PropertyTypePicker, propertyTypes } from "./property-type-picker"; import { PropertyOptions } from "./property-options"; import { getDescriptor, defaultTypeOptionsFor, } from "@/ee/base/property-types/property-type.registry"; import { FormulaEditor } from "../formula/formula-editor"; import classes from "@/ee/base/styles/grid.module.css"; type CreatePropertyPopoverProps = { pageId: string; properties?: IBaseProperty[]; onPropertyCreated?: (property: IBaseProperty) => void; /** Custom trigger; must return a ref-forwarding element for Popover.Target. * Defaults to the grid's + column button. */ renderTarget?: (open: () => void) => React.ReactElement; }; type Panel = "typePicker" | "configure" | "confirmDiscard"; const noop = () => {}; export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, renderTarget }: CreatePropertyPopoverProps) { const { t } = useTranslation(); const [opened, setOpened] = useState(false); const [panel, setPanel] = useState("typePicker"); const [selectedType, setSelectedType] = useState(null); const [name, setName] = useState(""); const [typeOptions, setTypeOptions] = useState>({}); // Portal target for nested Select dropdowns to avoid triggering closeOnClickOutside. const [dropdownNode, setDropdownNode] = useState(null); const nameInputRef = useRef(null); const createPropertyMutation = useCreatePropertyMutation(); const selectedTypeDef = useMemo( () => propertyTypes.find((pt) => pt.type === selectedType), [selectedType], ); const selectedTypeLabel = selectedTypeDef ? t(selectedTypeDef.labelKey) : ""; const selectedTypeIcon = selectedTypeDef?.icon; const hasContent = useMemo(() => { return name.trim().length > 0 || Object.keys(typeOptions).length > 0; }, [name, typeOptions]); const nameTaken = useMemo(() => { const trimmed = name.trim().toLowerCase(); if (!trimmed) return false; return (properties ?? []).some( (p) => p.name.trim().toLowerCase() === trimmed, ); }, [name, properties]); // Fall back to the type label when Name is blank, suffixing a counter if taken. const fallbackName = useMemo(() => { const base = selectedTypeLabel || "Property"; const existing = new Set( (properties ?? []).map((p) => p.name.trim().toLowerCase()), ); if (!existing.has(base.toLowerCase())) return base; for (let i = 1; i < 1000; i++) { const candidate = `${base} ${i}`; if (!existing.has(candidate.toLowerCase())) return candidate; } return `${base} ${Date.now()}`; }, [selectedTypeLabel, properties]); const resetState = useCallback(() => { setPanel("typePicker"); setSelectedType(null); setName(""); setTypeOptions({}); }, []); const handleOpen = useCallback(() => { resetState(); setOpened(true); }, [resetState]); const handleClose = useCallback(() => { // Don't reset state here: resetting mid-close flashes the type picker. // handleOpen resets on the next open instead. setOpened(false); }, []); const attemptClose = useCallback(() => { if (panel === "configure" && hasContent) { setPanel("confirmDiscard"); } else { handleClose(); } }, [panel, hasContent, handleClose]); const handleConfirmDiscard = useCallback(() => { handleClose(); }, [handleClose]); const handleCancelDiscard = useCallback(() => { setPanel("configure"); }, []); const handleTypeSelect = useCallback((type: BasePropertyType) => { setSelectedType(type); setTypeOptions(defaultTypeOptionsFor(type)); setPanel("configure"); }, []); useEffect(() => { if (panel === "configure") { setTimeout(() => nameInputRef.current?.focus(), 0); } }, [panel]); const handleCreate = useCallback(() => { if (!selectedType || nameTaken) return; const finalName = name.trim() || fallbackName; createPropertyMutation.mutate( { pageId, name: finalName, type: selectedType, typeOptions: Object.keys(typeOptions).length > 0 ? typeOptions as TypeOptions : undefined, }, { onSuccess: (created) => { onPropertyCreated?.(created); }, }, ); handleClose(); }, [selectedType, nameTaken, name, fallbackName, typeOptions, pageId, createPropertyMutation, handleClose, onPropertyCreated]); const handleBackToTypePicker = useCallback(() => { setPanel("typePicker"); setTypeOptions({}); }, []); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); if (panel === "confirmDiscard") { handleCancelDiscard(); } else if (panel === "configure") { handleBackToTypePicker(); } else { handleClose(); } } }, [panel, handleBackToTypePicker, handleClose, handleCancelDiscard], ); const handleNameKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); handleCreate(); } }, [handleCreate], ); const handleOptionsUpdate = useCallback( (newTypeOptions: Record) => { setTypeOptions(newTypeOptions); }, [], ); const syntheticProperty: IBaseProperty = useMemo(() => ({ id: "", pageId, name: name || "", type: selectedType ?? "text", position: "", typeOptions: typeOptions as TypeOptions, isPrimary: false, workspaceId: "", createdAt: "", updatedAt: "", }), [pageId, name, selectedType, typeOptions]); const TypeIcon = selectedTypeIcon; const showOptions = !!selectedType && (getDescriptor(selectedType)?.hasOptions ?? false); return ( <> { if (!o) attemptClose(); }} position="bottom-start" shadow="md" closeOnClickOutside closeOnEscape={false} withinPortal > {renderTarget ? ( renderTarget(handleOpen) ) : (
)}
e.stopPropagation()} onKeyDown={handleKeyDown} style={{ zIndex: 300, width: selectedType === "formula" ? 460 : undefined, minWidth: selectedType === "formula" ? undefined : 320, maxWidth: "calc(100vw - 32px)", }} > {panel === "typePicker" && ( )} {panel === "configure" && selectedType === "formula" && ( setName(e.currentTarget.value)} error={nameTaken ? t("A property with this name already exists") : undefined} /> { if (nameTaken) return; createPropertyMutation.mutate( { pageId, name: name.trim() || fallbackName, type: "formula", typeOptions: { source, ast, resultType, dependencies, astVersion: 1, } as TypeOptions, }, { onSuccess: (created) => onPropertyCreated?.(created) }, ); handleClose(); }} /> )} {(panel === "configure" || panel === "confirmDiscard") && selectedType !== "formula" && ( setName(e.currentTarget.value)} onKeyDown={handleNameKeyDown} error={nameTaken ? t("A property with this name already exists") : undefined} mb="xs" /> {TypeIcon && } {selectedTypeLabel} {showOptions && ( <> )} )} {panel === "confirmDiscard" && ( {t("Unsaved changes")} {t("You have unsaved changes. Do you want to discard them?")} )}
); }