diff --git a/docs/pr-assets/data-table-link-autosuggest/autosuggest-dropdown.png b/docs/pr-assets/data-table-link-autosuggest/autosuggest-dropdown.png new file mode 100644 index 000000000..58d55ef15 Binary files /dev/null and b/docs/pr-assets/data-table-link-autosuggest/autosuggest-dropdown.png differ diff --git a/docs/pr-assets/data-table-link-autosuggest/selected.png b/docs/pr-assets/data-table-link-autosuggest/selected.png new file mode 100644 index 000000000..e00a0c6eb Binary files /dev/null and b/docs/pr-assets/data-table-link-autosuggest/selected.png differ diff --git a/frontend/src/components/data-table/DataRecordFormLayout.tsx b/frontend/src/components/data-table/DataRecordFormLayout.tsx index 85c006c40..025728d91 100644 --- a/frontend/src/components/data-table/DataRecordFormLayout.tsx +++ b/frontend/src/components/data-table/DataRecordFormLayout.tsx @@ -9,6 +9,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { LinkFieldInput } from './LinkFieldInput'; import type { DataTableFieldDef } from '@/types/dataTable.types'; export interface LayoutSection { @@ -190,14 +191,11 @@ export function FieldInput({ field, value, onChange }: FieldInputProps) { )} {field.fieldtype === 'Link' && ( - onChange(event.target.value)} + onChange={onChange} disabled={isReadOnly} - placeholder={`Link to ${field.options || 'table'}...`} - className="h-8 text-sm" /> )} diff --git a/frontend/src/components/data-table/LinkFieldInput.tsx b/frontend/src/components/data-table/LinkFieldInput.tsx new file mode 100644 index 000000000..bb2bcaee6 --- /dev/null +++ b/frontend/src/components/data-table/LinkFieldInput.tsx @@ -0,0 +1,159 @@ +import { useEffect, useState, useMemo } from 'react'; +import { Combobox, ComboboxOption } from '@/components/ui/combobox'; +import { Input } from '@/components/ui/input'; +import { getTableRecords, getTableRecord } from '@/services/dataTableApi'; +import { db } from '@/lib/frappe-sdk'; + +export interface LinkFieldInputProps { + targetDoctype?: string; + value: string; + onChange: (value: string) => void; + disabled?: boolean; +} + +export function LinkFieldInput({ targetDoctype, value, onChange, disabled }: LinkFieldInputProps) { + const [options, setOptions] = useState([]); + const [search, setSearch] = useState(''); + const [loading, setLoading] = useState(false); + const [titleField, setTitleField] = useState('name'); + + // Single selected record info to show label if not in current options list + const [selectedRecordLabel, setSelectedRecordLabel] = useState(null); + + // Fetch title_field from schema + useEffect(() => { + if (!targetDoctype) return; + + let isMounted = true; + const init = async () => { + try { + const result = await db.getDocList('Huf Data Table', { + fields: ['name', 'title_field_name'], + filters: [['doctype_name', '=', targetDoctype]], + limit: 1 + }); + if (isMounted && result && result.length > 0 && result[0].title_field_name) { + setTitleField(result[0].title_field_name); + } + } catch (err) { + console.error('Failed to fetch schema for link field', err); + } + }; + init(); + + return () => { isMounted = false; }; + }, [targetDoctype]); + + // Fetch current selected record to show correct label initially + useEffect(() => { + if (!targetDoctype || !value) { + setSelectedRecordLabel(null); + return; + } + + let isMounted = true; + const fetchSelected = async () => { + try { + const record = await getTableRecord(targetDoctype, value); + if (isMounted && record) { + const label = String(record[titleField] || record.name || value); + setSelectedRecordLabel(label); + } + } catch { + // If not found, just fallback + if (isMounted) setSelectedRecordLabel(value); + } + }; + + // We only need to fetch if the selected value isn't already in our options list + if (!options.some(opt => opt.value === value)) { + fetchSelected(); + } else { + const opt = options.find(o => o.value === value); + setSelectedRecordLabel(opt?.label || value); + } + + return () => { isMounted = false; }; + }, [targetDoctype, value, titleField, options]); + + // Debounced search + useEffect(() => { + if (!targetDoctype) return; + + let isMounted = true; + const timeout = setTimeout(async () => { + setLoading(true); + try { + const filters: Array<[string, string, unknown]> = []; + if (search.trim()) { + // Typically we search by title_field or name. Let's use name if title_field is name. + filters.push([titleField, 'like', `%${search}%`]); + } + + // We only need name and the title field + const fields = titleField === 'name' ? ['name'] : ['name', titleField]; + + const result = await getTableRecords(targetDoctype, { + filters: filters.length > 0 ? filters : undefined, + limit: 20, + fields + }); + + if (isMounted && result) { + const newOptions = result.items.map(item => ({ + value: String(item.name), + label: String(item[titleField] || item.name) + })); + setOptions(newOptions); + } + } catch (err) { + console.error('Failed to search link field', err); + } finally { + if (isMounted) setLoading(false); + } + }, 300); + + return () => { + isMounted = false; + clearTimeout(timeout); + }; + }, [targetDoctype, search, titleField]); + + // Ensure current value is included in options so Combobox can display it + const allOptions = useMemo(() => { + const list = [...options]; + if (value && !list.some(opt => opt.value === value)) { + list.push({ + value, + label: selectedRecordLabel || value + }); + } + return list; + }, [options, value, selectedRecordLabel]); + + if (!targetDoctype) { + return ( + + ); + } + + return ( + + ); +} diff --git a/frontend/src/components/ui/combobox.tsx b/frontend/src/components/ui/combobox.tsx index aabc86350..14b07e66c 100644 --- a/frontend/src/components/ui/combobox.tsx +++ b/frontend/src/components/ui/combobox.tsx @@ -32,6 +32,8 @@ export interface ComboboxProps { emptyText?: string; searchPlaceholder?: string; linkTo?: (value: string) => string | undefined; + onSearchChange?: (search: string) => void; + shouldFilter?: boolean; } export function Combobox({ @@ -43,6 +45,8 @@ export function Combobox({ emptyText = 'No option found.', searchPlaceholder = 'Search...', linkTo, + onSearchChange, + shouldFilter, }: ComboboxProps) { const [open, setOpen] = React.useState(false); @@ -72,8 +76,8 @@ export function Combobox({ - - + + {emptyText}