import { __ } from "@wordpress/i18n"; import { PanelBody, TextControl, Button, Spinner, SelectControl, } from "@wordpress/components"; import { useState, useEffect, useRef, useMemo, RefObject } from "react"; import { useSelect } from "@wordpress/data"; import { useQuery } from "@tanstack/react-query"; import { store as editorStore } from "@wordpress/editor"; import { BindingSource, ElementBindings, BindableAttribute, } from "../../dynamic-data/types"; import { postFieldKeys } from "../../dynamic-data/sources"; import { useDynamicData, fetchAvailableMetaKeys, } from "../../dynamic-data/hooks/useDynamicData"; import { getElementBindableAttributes } from "../../elements/bindable-attributes"; import { useTableStore } from "../../store"; interface DynamicDataPanelProps { elementType: string; bindings: ElementBindings | undefined; postId?: number; } export function DynamicDataPanel({ elementType, bindings = {}, postId: propPostId, }: DynamicDataPanelProps) { const [isAdding, setIsAdding] = useState(false); const bindingDefinitions = useTableStore(state => state.bindings); const updateSelectedElementBindings = useTableStore( state => state.updateSelectedElementBindings ); const createBindingDefinition = useTableStore( state => state.createBindingDefinition ); const updateBindingDefinition = useTableStore( state => state.updateBindingDefinition ); const removeBindingDefinition = useTableStore( state => state.removeBindingDefinition ); const effectivePostId = useSelect( select => { if (propPostId) { return propPostId; } const editorSelect = select(editorStore) as { getCurrentPostId?: () => number | undefined; }; return editorSelect.getCurrentPostId?.() ?? undefined; }, [propPostId] ); const bindableAttributes = getElementBindableAttributes(elementType); if (bindableAttributes.length === 0) { return; } const boundAttributes = bindableAttributes.filter( attr => attr.path in bindings && !!bindingDefinitions[bindings[attr.path]] ); const unboundAttributes = bindableAttributes.filter( attr => !(attr.path in bindings) ); const handleAddBinding = (path: string, binding: BindingSource) => { const bindingId = createBindingDefinition(binding); const newBindings = { ...bindings, [path]: bindingId, }; updateSelectedElementBindings(newBindings); }; const handleUpdateBinding = ( previousPath: string, nextPath: string, bindingId: string, binding: BindingSource ) => { updateBindingDefinition(bindingId, binding); const nextBindings = { ...bindings }; if (previousPath !== nextPath) { delete nextBindings[previousPath]; } nextBindings[nextPath] = bindingId; updateSelectedElementBindings(nextBindings); }; const handleRemoveBinding = (path: string, bindingId: string) => { const newBindings = { ...bindings }; delete newBindings[path]; updateSelectedElementBindings( Object.keys(newBindings).length > 0 ? newBindings : undefined ); removeBindingDefinition(bindingId); }; return ( 0} > {boundAttributes.map(attr => { const bindingId = bindings[attr.path]; const binding = bindingDefinitions[bindingId]; if (!bindingId || !binding) { return null; } return ( a.path)} onBindingChange={(newPath, newBinding) => { handleUpdateBinding( attr.path, newPath, bindingId, newBinding ); }} onRemove={() => handleRemoveBinding(attr.path, bindingId) } postId={effectivePostId} /> ); })} {isAdding && ( { handleAddBinding(path, binding); setIsAdding(false); }} onCancel={() => setIsAdding(false)} postId={effectivePostId} /> )} {!isAdding && unboundAttributes.length > 0 && (
)}
); } interface NewBindingFormProps { unboundAttributes: BindableAttribute[]; onApply: (path: string, binding: BindingSource) => void; onCancel: () => void; postId?: number; } function BindingFormFields({ selectedPath, setSelectedPath, pathOptions, keyValue, setKeyValue, customPostId, setCustomPostId, fallback, setFallback, submitLabel, cancelLabel, onCancel, showRefresh, onRefresh, refreshLabel, isLoadingKeys, hasSuggestions, suggestions, isSuggestionsOpen, setIsSuggestionsOpen, fieldInputRef, previewValue, isDestructiveRemove, onRemove, }: { selectedPath: string; setSelectedPath: (value: string) => void; pathOptions: Array<{ value: string; label: string }>; keyValue: string; setKeyValue: (value: string) => void; customPostId: string; setCustomPostId: (value: string) => void; fallback: string; setFallback: (value: string) => void; submitLabel: string; cancelLabel?: string; onCancel?: () => void; showRefresh?: boolean; onRefresh?: () => void; refreshLabel?: string; isLoadingKeys: boolean; hasSuggestions: boolean; suggestions: { postFields: Array<{ key: string; label?: string }>; metaKeys: Array<{ key: string; label?: string }>; }; isSuggestionsOpen: boolean; setIsSuggestionsOpen: (value: boolean) => void; fieldInputRef: RefObject; previewValue?: string | null; isDestructiveRemove?: boolean; onRemove?: () => void; }) { return ( <>
} className="tableberg-dynamic-data-row__field-autocomplete" > { setKeyValue(value); setIsSuggestionsOpen(true); }} onFocus={() => setIsSuggestionsOpen(true)} placeholder={__("Search or type field key...", "tableberg")} autoComplete="off" /> {isSuggestionsOpen && !isLoadingKeys && hasSuggestions && (
{suggestions.postFields.length > 0 && ( <>
{__("Post Fields", "tableberg")}
{suggestions.postFields.map(item => ( ))} )} {suggestions.metaKeys.length > 0 && ( <>
{__("Custom Fields", "tableberg")}
{suggestions.metaKeys.map(item => ( ))} )}
)} {isSuggestionsOpen && isLoadingKeys && }
{previewValue !== undefined && previewValue !== null && (
{__("Preview:", "tableberg")}{" "} {String(previewValue)}
)}
{showRefresh ? ( ) : ( )} {onRemove && ( )} {onCancel && ( )}
); } function useBindingFormState({ keyValue, postId, }: { keyValue: string; postId?: number; }) { const [isSuggestionsOpen, setIsSuggestionsOpen] = useState(false); const fieldInputRef = useRef(null); const { data: metaKeys = [], isLoading: isLoadingKeys } = useQuery({ queryKey: ["metaKeys", postId], queryFn: () => fetchAvailableMetaKeys(postId), staleTime: Infinity, }); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( fieldInputRef.current && !fieldInputRef.current.contains(event.target as Node) ) { setIsSuggestionsOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); const suggestions = useMemo(() => { const query = keyValue.toLowerCase(); return { postFields: postFieldKeys.filter( item => item.key.toLowerCase().includes(query) || (item.label && item.label.toLowerCase().includes(query)) ), metaKeys: metaKeys.filter( item => item.key.toLowerCase().includes(query) || (item.label && item.label.toLowerCase().includes(query)) ), }; }, [keyValue, metaKeys]); return { fieldInputRef, isSuggestionsOpen, setIsSuggestionsOpen, suggestions, hasSuggestions: suggestions.postFields.length > 0 || suggestions.metaKeys.length > 0, isLoadingKeys, }; } function createBindingSource( keyValue: string, customPostId: string, fallback: string ): BindingSource { const binding: BindingSource = { key: keyValue }; if (customPostId) { const parsedId = parseInt(customPostId, 10); if (!isNaN(parsedId) && parsedId > 0) { binding.postId = parsedId; } } if (fallback) { binding.fallback = fallback; } return binding; } function NewBindingForm({ unboundAttributes, onApply, onCancel, postId, }: NewBindingFormProps) { const [selectedPath, setSelectedPath] = useState(""); const [keyValue, setKeyValue] = useState(""); const [customPostId, setCustomPostId] = useState(""); const [fallback, setFallback] = useState(""); const metaPostId = customPostId ? parseInt(customPostId, 10) || undefined : postId; const { fieldInputRef, isSuggestionsOpen, setIsSuggestionsOpen, suggestions, hasSuggestions, isLoadingKeys, } = useBindingFormState({ keyValue, postId: metaPostId, }); return (
{ event.preventDefault(); if (!selectedPath || !keyValue) { return; } onApply( selectedPath, createBindingSource(keyValue, customPostId, fallback) ); }} > ({ value: attribute.path, label: attribute.label, })), ]} keyValue={keyValue} setKeyValue={setKeyValue} customPostId={customPostId} setCustomPostId={setCustomPostId} fallback={fallback} setFallback={setFallback} submitLabel={__("Apply Binding", "tableberg")} cancelLabel={__("Cancel", "tableberg")} onCancel={onCancel} isLoadingKeys={isLoadingKeys} hasSuggestions={hasSuggestions} suggestions={suggestions} isSuggestionsOpen={isSuggestionsOpen} setIsSuggestionsOpen={setIsSuggestionsOpen} fieldInputRef={fieldInputRef} />
); } interface AttributeBindingProps { attribute: BindableAttribute; binding: BindingSource; bindableAttributes: BindableAttribute[]; boundPaths: string[]; onBindingChange: (path: string, binding: BindingSource) => void; onRemove: () => void; postId?: number; } function AttributeBinding({ attribute, binding, bindableAttributes, boundPaths, onBindingChange, onRemove, postId, }: AttributeBindingProps) { const [isExpanded, setIsExpanded] = useState(false); const [selectedPath, setSelectedPath] = useState(attribute.path); const [keyValue, setKeyValue] = useState(binding.key); const [customPostId, setCustomPostId] = useState( binding.postId?.toString() ?? "" ); const [fallback, setFallback] = useState(binding.fallback ?? ""); const metaPostId = customPostId ? parseInt(customPostId, 10) || undefined : postId; const { fieldInputRef, isSuggestionsOpen, setIsSuggestionsOpen, suggestions, hasSuggestions, isLoadingKeys, } = useBindingFormState({ keyValue, postId: metaPostId, }); const hasUnsavedChanges = selectedPath !== attribute.path || keyValue !== binding.key || customPostId !== (binding.postId?.toString() ?? "") || fallback !== (binding.fallback ?? ""); const { data: previewValue, refresh } = useDynamicData(binding, postId); const summary = customPostId ? `${binding.key} (post #${customPostId})` : binding.key; return (
{isExpanded && (
{ event.preventDefault(); if (!keyValue) { return; } onBindingChange( selectedPath, createBindingSource( keyValue, customPostId, fallback ) ); }} > item.path === attribute.path || !boundPaths.includes(item.path) ) .map(item => ({ value: item.path, label: item.label, }))} keyValue={keyValue} setKeyValue={setKeyValue} customPostId={customPostId} setCustomPostId={setCustomPostId} fallback={fallback} setFallback={setFallback} submitLabel={__("Update", "tableberg")} showRefresh={!hasUnsavedChanges} onRefresh={() => { void refresh(); }} refreshLabel={__("Refresh", "tableberg")} isLoadingKeys={isLoadingKeys} hasSuggestions={hasSuggestions} suggestions={suggestions} isSuggestionsOpen={isSuggestionsOpen} setIsSuggestionsOpen={setIsSuggestionsOpen} fieldInputRef={fieldInputRef} previewValue={previewValue} isDestructiveRemove onRemove={onRemove} /> )}
); } export default DynamicDataPanel;