diff --git a/src/components/ItemsList.tsx b/src/components/ItemsList.tsx index a8d60e2..06e2c11 100644 --- a/src/components/ItemsList.tsx +++ b/src/components/ItemsList.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Item, WikidataSuggestion, PropertyLabel } from '@/types/database'; import { EditableCell } from './EditableCell'; import { TypeaheadInput } from './TypeaheadInput'; +import { PropertyInput } from './PropertyInput'; import { v4 as uuidv4 } from 'uuid'; interface ItemsListProps { @@ -377,6 +378,13 @@ export function ItemsList({ url }: ItemsListProps) { }))); }, [deletePropertyMutation]); + const fetchPropertyLabelsHelper = useCallback(async (propertyIds: string[]): Promise> => { + const labels = await fetchPropertyLabels(propertyIds); + setPropertyLabels(prev => ({ ...prev, ...labels })); + return labels; + }, []); + + // Modify the addProperty function to handle auto-population const addProperty = useCallback(async (property: string) => { const normalizedProperty = property.replace('http://www.wikidata.org/prop/', ''); @@ -394,24 +402,35 @@ export function ItemsList({ url }: ItemsListProps) { setPropertyLabels(prev => ({ ...prev, ...labels })); } - // Add to all items - setItems(prev => prev.map(item => ({ - ...item, - customProperties: { - ...item.customProperties, - [normalizedProperty]: item.customProperties[normalizedProperty] || '' + // Add to all items with auto-population from propertyCache + setItems(prev => prev.map(item => { + const existingValue = item.customProperties[normalizedProperty] || ''; + let autoPopulatedValue = existingValue; + + // If item has Wikidata ID and we have cached properties, use the cached value + if (item.wikidataId && propertyCache[item.wikidataId] && propertyCache[item.wikidataId][normalizedProperty]) { + autoPopulatedValue = propertyCache[item.wikidataId][normalizedProperty]; } - }))); + + return { + ...item, + customProperties: { + ...item.customProperties, + [normalizedProperty]: autoPopulatedValue + } + }; + })); // Save to database addPropertyMutation.mutate(normalizedProperty); - // Fetch Wikidata properties for items with wikidata_id + // Fetch Wikidata properties for items with wikidata_id that don't have cached data items.forEach(async (item) => { if (item.wikidataId && !propertyCache[item.wikidataId]) { const properties = await fetchWikidataProperties(item.wikidataId); setPropertyCache(prev => ({ ...prev, [item.wikidataId!]: properties })); + // Update the item with the fetched property value if (properties[normalizedProperty]) { setItems(prevItems => prevItems.map(prevItem => prevItem.wikidataId === item.wikidataId @@ -432,6 +451,7 @@ export function ItemsList({ url }: ItemsListProps) { const handleWikidataSelect = useCallback(async (suggestion: WikidataSuggestion, itemId: string) => { console.log('Wikidata selection for item:', itemId, suggestion); + // Update item immediately with suggestion data setItems(prev => prev.map(item => item.id === itemId ? { @@ -442,6 +462,18 @@ export function ItemsList({ url }: ItemsListProps) { } : item )); + + // Save the updated item + const updatedItem = items.find(item => item.id === itemId); + if (updatedItem) { + const itemToSave = { + ...updatedItem, + name: suggestion.label || '', + description: suggestion.description || '', + wikidataId: suggestion.id + }; + saveItemMutation.mutate(itemToSave); + } // Fetch properties for this Wikidata item if (suggestion.id) { @@ -463,7 +495,7 @@ export function ItemsList({ url }: ItemsListProps) { : item )); } - }, [selectedProperties]); + }, [selectedProperties, items, saveItemMutation]); const handleNewPropertyKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') { @@ -584,24 +616,13 @@ export function ItemsList({ url }: ItemsListProps) { {/* Add new property input */}
- - - {Object.entries(propertyLabels).map(([propertyId, label]) => { - const optionValue = `${label} (${propertyId})`; - return ( - - ); - })} -
); diff --git a/src/components/PropertyInput.tsx b/src/components/PropertyInput.tsx new file mode 100644 index 0000000..b837efe --- /dev/null +++ b/src/components/PropertyInput.tsx @@ -0,0 +1,292 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; + +interface PropertySuggestion { + id: string; + label: string; + availableInItems: string[]; // Wikidata IDs that have this property +} + +interface PropertyInputProps { + propertyCache: Record>; // wikidataId -> properties + propertyLabels: Record; // propertyId -> label + onAddProperty: (propertyId: string) => void; + onFetchPropertyLabels: (propertyIds: string[]) => Promise>; + className?: string; +} + +export function PropertyInput({ + propertyCache, + propertyLabels, + onAddProperty, + onFetchPropertyLabels, + className = '' +}: PropertyInputProps) { + const [inputValue, setInputValue] = useState(''); + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [isLoading, setIsLoading] = useState(false); + + const inputRef = useRef(null); + const suggestionsRef = useRef(null); + + // Aggregate all available properties from all Wikidata items + const aggregateAvailableProperties = useCallback((): PropertySuggestion[] => { + const propertyMap = new Map>(); + + // Collect all properties from all cached Wikidata items + Object.entries(propertyCache).forEach(([wikidataId, properties]) => { + Object.keys(properties).forEach(propertyId => { + if (!propertyMap.has(propertyId)) { + propertyMap.set(propertyId, new Set()); + } + propertyMap.get(propertyId)!.add(wikidataId); + }); + }); + + // Convert to PropertySuggestion array + const suggestions: PropertySuggestion[] = []; + propertyMap.forEach((wikidataIds, propertyId) => { + const label = propertyLabels[propertyId] || propertyId; + suggestions.push({ + id: propertyId, + label: label, + availableInItems: Array.from(wikidataIds) + }); + }); + + // Sort by label for better UX + return suggestions.sort((a, b) => a.label.localeCompare(b.label)); + }, [propertyCache, propertyLabels]); + + // Update suggestions when propertyCache or propertyLabels change + useEffect(() => { + const availableProperties = aggregateAvailableProperties(); + setSuggestions(availableProperties); + + // Fetch labels for properties that don't have labels yet + const unlabeledProperties = availableProperties + .filter(prop => !propertyLabels[prop.id] || propertyLabels[prop.id] === prop.id) + .map(prop => prop.id); + + if (unlabeledProperties.length > 0) { + setIsLoading(true); + onFetchPropertyLabels(unlabeledProperties) + .finally(() => setIsLoading(false)); + } + }, [propertyCache, propertyLabels, aggregateAvailableProperties, onFetchPropertyLabels]); + + // Filter suggestions based on input + const filteredSuggestions = suggestions.filter(suggestion => + suggestion.label.toLowerCase().includes(inputValue.toLowerCase()) || + suggestion.id.toLowerCase().includes(inputValue.toLowerCase()) + ); + + // Handle input changes + const handleInputChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + setInputValue(value); + setShowSuggestions(value.trim().length > 0); + setSelectedIndex(-1); + }, []); + + // Handle keyboard navigation + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!showSuggestions || filteredSuggestions.length === 0) { + if (e.key === 'Enter') { + e.preventDefault(); + // Try to add property directly if it looks like a property ID + if (inputValue.trim()) { + onAddProperty(inputValue.trim()); + setInputValue(''); + setShowSuggestions(false); + } + } + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex(prev => + prev < filteredSuggestions.length - 1 ? prev + 1 : 0 + ); + break; + + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex(prev => + prev > 0 ? prev - 1 : filteredSuggestions.length - 1 + ); + break; + + case 'Enter': + e.preventDefault(); + if (selectedIndex >= 0 && selectedIndex < filteredSuggestions.length) { + const selectedProperty = filteredSuggestions[selectedIndex]; + handlePropertySelect(selectedProperty); + } else if (inputValue.trim()) { + // Add property directly + onAddProperty(inputValue.trim()); + setInputValue(''); + setShowSuggestions(false); + } + break; + + case 'Escape': + e.preventDefault(); + setShowSuggestions(false); + setSelectedIndex(-1); + inputRef.current?.blur(); + break; + + case 'Tab': + setShowSuggestions(false); + setSelectedIndex(-1); + break; + } + }, [showSuggestions, filteredSuggestions, selectedIndex, inputValue, onAddProperty]); + + // Handle property selection + const handlePropertySelect = useCallback((property: PropertySuggestion) => { + console.log('Property selected:', property); + onAddProperty(property.id); + setInputValue(''); + setShowSuggestions(false); + setSelectedIndex(-1); + inputRef.current?.focus(); + }, [onAddProperty]); + + // Handle focus + const handleFocus = useCallback(() => { + // Show all suggestions immediately on focus + setShowSuggestions(true); + }, []); + + // Handle blur + const handleBlur = useCallback((e: React.FocusEvent) => { + // Delay hiding suggestions to allow for clicks on suggestions + setTimeout(() => { + const relatedTarget = e.relatedTarget as HTMLElement; + if (!suggestionsRef.current?.contains(relatedTarget)) { + setShowSuggestions(false); + setSelectedIndex(-1); + } + }, 150); + }, []); + + // Handle suggestion click + const handleSuggestionClick = useCallback((property: PropertySuggestion) => { + handlePropertySelect(property); + }, [handlePropertySelect]); + + // Handle clicking outside to close suggestions + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if ( + inputRef.current && + !inputRef.current.contains(target) && + suggestionsRef.current && + !suggestionsRef.current.contains(target) + ) { + setShowSuggestions(false); + setSelectedIndex(-1); + } + }; + + if (showSuggestions) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [showSuggestions]); + + return ( +
+ + + {/* Loading indicator */} + {isLoading && ( +
+
+
+ )} + + {/* Suggestions dropdown */} + {showSuggestions && filteredSuggestions.length > 0 && ( +
+
+ {filteredSuggestions.map((suggestion, index) => { + const isSelected = index === selectedIndex; + + return ( +
handleSuggestionClick(suggestion)} + onMouseEnter={() => setSelectedIndex(index)} + > +
+
+
+ {suggestion.label} +
+
+ ID: {suggestion.id} +
+
+ Available in {suggestion.availableInItems.length} item{suggestion.availableInItems.length !== 1 ? 's' : ''} +
+
+
+ + + +
+
+
+ ); + })} +
+
+ )} + + {/* No suggestions message */} + {showSuggestions && inputValue.trim().length > 0 && filteredSuggestions.length === 0 && ( +
+
+ {`No matching properties found. Press Enter to add "${inputValue}" as a custom property.`} +
+
+ )} +
+ ); +} \ No newline at end of file