478 lines
16 KiB
TypeScript
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; |