478 lines
16 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { FaPlus } from 'react-icons/fa';
import apiService from '../services/apiService';
import { supportsQuickCreate } from '../components/QuickCreateConfig';
import { hasCreatePermission } from '../services/permissionService';
import QuickCreateModal from '../components/QuickCreateModal';
interface LinkFieldProps {
label: string;
doctype: string;
value: string;
onChange: (value: string) => void;
/** When true, only the input is rendered (use an outer <label> / FL). */
hideLabel?: boolean;
placeholder?: string;
disabled?: boolean;
/** Frappe filter rows `[['DocType', 'field', 'op', value]]` or legacy dict form */
filters?: any;
compact?: boolean;
usePortal?: boolean;
// New props for QuickCreate functionality
allowQuickCreate?: boolean; // Enable/disable quick create (default: false)
onQuickCreateSuccess?: (newRecord: any) => void; // Callback after quick create
quickCreateInitialValues?: Record<string, any>; // Initial values for quick create form
query?: string;
}
// Stable empty object to avoid re-renders
const EMPTY_FILTERS: Record<string, any> = {};
const LinkField: React.FC<LinkFieldProps> = ({
label,
doctype,
value,
onChange,
hideLabel = false,
placeholder,
disabled = false,
filters,
compact = false,
usePortal = true,
// QuickCreate props with defaults
allowQuickCreate = false, // Default to false - must explicitly enable per field
onQuickCreateSuccess,
quickCreateInitialValues = {},
query,
}) => {
const { t } = useTranslation();
const [searchResults, setSearchResults] = useState<{ value: string; description?: string }[]>([]);
const [searchText, setSearchText] = useState('');
const [isDropdownOpen, setDropdownOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number; width: number }>({ top: 0, left: 0, width: 0 });
// QuickCreate modal state
const [showQuickCreate, setShowQuickCreate] = useState(false);
// Permission state for QuickCreate
// null = not checked yet, true = allowed, false = denied
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSearchRef = useRef<string>('');
const hasLoadedRef = useRef<boolean>(false);
// Use stable empty object if filters not provided
const stableFilters = filters || EMPTY_FILTERS;
// Stringify filters for comparison (avoid object reference issues)
const filtersKey = useMemo(() => JSON.stringify(stableFilters), [stableFilters]);
// Check if doctype has QuickCreate config
const hasQuickCreateConfig = useMemo(() => {
const supported = supportsQuickCreate(doctype);
console.log(`[LinkField] ${doctype} hasQuickCreateConfig: ${supported}`);
return supported;
}, [doctype]);
// Check permission ONLY when allowQuickCreate is enabled AND doctype has config
useEffect(() => {
// Reset state when doctype or allowQuickCreate changes
setHasPermission(null);
// Only check permission if allowQuickCreate is true AND doctype has config
if (allowQuickCreate && hasQuickCreateConfig) {
console.log(`[LinkField] Checking permission for ${doctype}...`);
hasCreatePermission(doctype)
.then((result) => {
console.log(`[LinkField] Permission for ${doctype}: ${result}`);
setHasPermission(result);
})
.catch((err) => {
console.error(`[LinkField] Permission check failed for ${doctype}:`, err);
setHasPermission(false);
});
} else {
// If allowQuickCreate is false or no config, don't show button
setHasPermission(false);
if (allowQuickCreate && !hasQuickCreateConfig) {
console.warn(`[LinkField] ${doctype}: allowQuickCreate=true but no config in QuickCreateConfig.ts`);
}
}
}, [allowQuickCreate, doctype, hasQuickCreateConfig]);
// Final check: show button only if ALL conditions are met:
// 1. allowQuickCreate={true} is set on the field
// 2. Doctype has config in QuickCreateConfig.ts
// 3. Permission check passed (hasPermission === true)
const canQuickCreate = useMemo(() => {
const result = allowQuickCreate && hasQuickCreateConfig && hasPermission === true;
console.log(`[LinkField] canQuickCreate for ${doctype}: ${result}`, {
allowQuickCreate,
hasQuickCreateConfig,
hasPermission
});
return result;
}, [allowQuickCreate, hasQuickCreateConfig, hasPermission, doctype]);
/// Fetch link options from ERPNext with filters
const searchLink = useCallback(async (text: string = '', force: boolean = false) => {
// Prevent duplicate calls for the same search text
const searchKey = `${text}-${filtersKey}-${query || ''}`;
if (!force && lastSearchRef.current === searchKey) {
return;
}
lastSearchRef.current = searchKey;
setIsLoading(true);
try {
let response: { value: string; description?: string }[] | null = null;
if (query) {
// Use custom query method
const params = new URLSearchParams({
txt: text,
doctype: doctype,
searchfield: 'name',
start: '0',
page_len: '50',
});
// Add filters if provided
if (stableFilters && Object.keys(stableFilters).length > 0) {
params.append('filters', JSON.stringify(stableFilters));
}
const customResponse = await apiService.apiCall<any>(
`/api/method/${query}?${params.toString()}`
);
// Custom query returns array of arrays: [[value, description], ...]
// Convert to expected format
if (Array.isArray(customResponse)) {
response = customResponse.map((item: any) => {
if (Array.isArray(item)) {
return { value: item[0], description: item[1] || undefined };
}
return { value: item.value || item.name || item, description: item.description };
});
} else {
response = [];
}
} else {
// Use standard Frappe search_link
const params = new URLSearchParams({
doctype,
txt: text,
page_length: '50',
});
// Add filters if provided
if (stableFilters && Object.keys(stableFilters).length > 0) {
params.append('filters', JSON.stringify(stableFilters));
}
response = await apiService.apiCall<{ value: string; description?: string }[]>(
`/api/method/frappe.desk.search.search_link?${params.toString()}`
);
}
setSearchResults(response || []);
} catch (error) {
console.error(`Error fetching ${doctype} links:`, error);
setSearchResults([]);
} finally {
setIsLoading(false);
}
}, [doctype, filtersKey, stableFilters, query]);
// Debounced search for typing
const debouncedSearch = useCallback((text: string) => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
searchLink(text);
}, 300);
}, [searchLink]);
// Fetch default options ONLY when dropdown first opens
useEffect(() => {
if (isDropdownOpen && !hasLoadedRef.current) {
hasLoadedRef.current = true;
searchLink(searchText || '', true);
}
// Reset the loaded flag when dropdown closes
if (!isDropdownOpen) {
hasLoadedRef.current = false;
lastSearchRef.current = '';
}
}, [isDropdownOpen]); // Only depend on isDropdownOpen
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
// Calculate dropdown position for portal rendering
const updateDropdownPosition = useCallback(() => {
if (usePortal && inputRef.current) {
const rect = inputRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width
});
}
}, [usePortal]);
// Update position when dropdown opens or on scroll/resize
useEffect(() => {
if (isDropdownOpen && usePortal) {
updateDropdownPosition();
const handleUpdate = () => updateDropdownPosition();
window.addEventListener('scroll', handleUpdate, true);
window.addEventListener('resize', handleUpdate);
return () => {
window.removeEventListener('scroll', handleUpdate, true);
window.removeEventListener('resize', handleUpdate);
};
}
}, [isDropdownOpen, usePortal, updateDropdownPosition]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
const clickedOutsideContainer = containerRef.current && !containerRef.current.contains(target);
const clickedOutsideDropdown = usePortal && dropdownRef.current && !dropdownRef.current.contains(target);
// Close if clicked outside both container and dropdown (when using portal)
if (usePortal) {
if (clickedOutsideContainer && clickedOutsideDropdown) {
setDropdownOpen(false);
setSearchText('');
}
} else {
if (clickedOutsideContainer) {
setDropdownOpen(false);
setSearchText('');
}
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [usePortal]);
// Handle selecting an item from dropdown
const handleSelect = (selectedValue: string) => {
onChange(selectedValue);
setSearchText('');
setDropdownOpen(false);
};
// Handle clearing the field
const handleClear = () => {
onChange('');
setSearchText('');
setDropdownOpen(false);
};
// Handle opening QuickCreate modal
const handleOpenQuickCreate = () => {
setDropdownOpen(false);
setSearchText('');
setShowQuickCreate(true);
};
// Handle QuickCreate success
const handleQuickCreateSuccess = (newRecord: any) => {
// Get the name/value from the new record
const newValue = newRecord.name || newRecord[Object.keys(newRecord)[0]];
handleSelect(newValue);
// Call external callback if provided
if (onQuickCreateSuccess) {
onQuickCreateSuccess(newRecord);
}
};
// Render dropdown content
const renderDropdown = () => {
const dropdownClasses = `bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
rounded-md w-full shadow-lg ${compact ? 'mt-0.5' : 'mt-1'}`;
const positionStyle = usePortal ? {
position: 'fixed' as const,
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
zIndex: 1050,
marginTop: compact ? '2px' : '4px'
} : {};
if (!isDropdownOpen || disabled) return null;
const dropdownContent = (
<div ref={dropdownRef}>
{/* Loading indicator */}
{isLoading && (
<div className={`${usePortal ? '' : 'absolute z-[1050]'} ${dropdownClasses} text-center text-gray-500 dark:text-gray-400
${compact ? 'p-1.5 text-[10px]' : 'p-3 text-sm'}`}
style={positionStyle}>
<span className="inline-block animate-spin mr-2"></span>
{t('linkField.loading')}
</div>
)}
{/* Results list with QuickCreate option */}
{!isLoading && (
<div className={`${usePortal ? '' : 'absolute z-[1050]'} ${dropdownClasses} overflow-hidden`}
style={positionStyle}>
{/* Results */}
{searchResults.length > 0 ? (
<ul className={`overflow-auto ${compact ? 'max-h-36' : 'max-h-48'}`}>
{searchResults.map((item, idx) => (
<li
key={idx}
onClick={() => handleSelect(item.value)}
className={`cursor-pointer text-gray-900 dark:text-gray-100
hover:bg-blue-500 dark:hover:bg-blue-600 hover:text-white
${compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm'}
${value === item.value ? 'bg-blue-50 dark:bg-blue-700 font-semibold' : ''}`}
>
{item.value}
{item.description && (
<span className={`text-gray-600 dark:text-gray-300 ml-2
${compact ? 'text-[9px] ml-1' : 'text-xs ml-2'}`}>
{item.description}
</span>
)}
</li>
))}
</ul>
) : (
<div className={`text-center text-gray-500 dark:text-gray-400
${compact ? 'p-1.5 text-[10px]' : 'p-3 text-sm'}`}>
{t('linkField.noResultsFound')}
</div>
)}
{/* QuickCreate Button - Only shows if all conditions are met */}
{canQuickCreate && (
<>
<div className="border-t border-gray-200 dark:border-gray-700" />
<div
onClick={handleOpenQuickCreate}
className={`cursor-pointer flex items-center gap-2
text-green-600 dark:text-green-400
hover:bg-green-50 dark:hover:bg-green-900/20
hover:text-green-700 dark:hover:text-green-300
transition-colors
${compact ? 'px-2 py-1.5 text-xs' : 'px-3 py-2.5 text-sm'}`}
>
<FaPlus size={compact ? 10 : 12} />
<span className="font-medium">
{t('linkField.createNewDoctype', { doctype: doctype.replace(/_/g, ' ') })}
</span>
</div>
</>
)}
</div>
)}
</div>
);
return usePortal ? createPortal(dropdownContent, document.body) : dropdownContent;
};
return (
<>
<div
ref={containerRef}
className={`relative w-full ${compact ? 'mb-2' : hideLabel ? 'mb-0' : 'mb-4'}`}
>
{!hideLabel && (
<label className={`block font-medium text-gray-700 dark:text-gray-300 ${compact ? 'text-[10px] mb-0.5' : 'text-sm mb-1'}`}>
{label}
</label>
)}
<div className="relative">
<input
ref={inputRef}
type="text"
value={isDropdownOpen ? searchText : value}
placeholder={placeholder || t('linkField.selectLabel', { label })}
disabled={disabled}
className={`w-full border border-gray-300 dark:border-gray-600 rounded-md
focus:outline-none disabled:bg-gray-100 dark:disabled:bg-gray-700
bg-white dark:bg-gray-700 text-gray-900 dark:text-white
${compact
? 'px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500 rounded'
: 'px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500'
}
${value ? (compact ? 'pr-5' : 'pr-8') : ''}`}
onFocus={() => {
if (!disabled) {
setDropdownOpen(true);
setSearchText('');
if (usePortal) {
updateDropdownPosition();
}
}
}}
onChange={(e) => {
const text = e.target.value;
setSearchText(text);
debouncedSearch(text);
}}
/>
{/* Clear button */}
{value && !disabled && !isDropdownOpen && (
<button
type="button"
onClick={handleClear}
className={`absolute top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300
${compact ? 'right-1 text-xs' : 'right-2 text-sm'}`}
>
</button>
)}
</div>
{/* Render dropdown */}
{renderDropdown()}
</div>
{/* QuickCreate Modal */}
<QuickCreateModal
doctype={doctype}
isOpen={showQuickCreate}
onClose={() => setShowQuickCreate(false)}
onSuccess={handleQuickCreateSuccess}
initialValues={quickCreateInitialValues}
parentFilters={stableFilters}
/>
</>
);
};
export default LinkField;