feat(PropertyInput): add new component for managing property input and suggestions

This commit is contained in:
ryan 2025-06-20 18:06:00 +03:00
parent fa65fb666b
commit c6a90805af
2 changed files with 339 additions and 26 deletions

View file

@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Item, WikidataSuggestion, PropertyLabel } from '@/types/database'; import { Item, WikidataSuggestion, PropertyLabel } from '@/types/database';
import { EditableCell } from './EditableCell'; import { EditableCell } from './EditableCell';
import { TypeaheadInput } from './TypeaheadInput'; import { TypeaheadInput } from './TypeaheadInput';
import { PropertyInput } from './PropertyInput';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
interface ItemsListProps { interface ItemsListProps {
@ -377,6 +378,13 @@ export function ItemsList({ url }: ItemsListProps) {
}))); })));
}, [deletePropertyMutation]); }, [deletePropertyMutation]);
const fetchPropertyLabelsHelper = useCallback(async (propertyIds: string[]): Promise<Record<string, string>> => {
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 addProperty = useCallback(async (property: string) => {
const normalizedProperty = property.replace('http://www.wikidata.org/prop/', ''); const normalizedProperty = property.replace('http://www.wikidata.org/prop/', '');
@ -394,24 +402,35 @@ export function ItemsList({ url }: ItemsListProps) {
setPropertyLabels(prev => ({ ...prev, ...labels })); setPropertyLabels(prev => ({ ...prev, ...labels }));
} }
// Add to all items // Add to all items with auto-population from propertyCache
setItems(prev => prev.map(item => ({ 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, ...item,
customProperties: { customProperties: {
...item.customProperties, ...item.customProperties,
[normalizedProperty]: item.customProperties[normalizedProperty] || '' [normalizedProperty]: autoPopulatedValue
} }
}))); };
}));
// Save to database // Save to database
addPropertyMutation.mutate(normalizedProperty); 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) => { items.forEach(async (item) => {
if (item.wikidataId && !propertyCache[item.wikidataId]) { if (item.wikidataId && !propertyCache[item.wikidataId]) {
const properties = await fetchWikidataProperties(item.wikidataId); const properties = await fetchWikidataProperties(item.wikidataId);
setPropertyCache(prev => ({ ...prev, [item.wikidataId!]: properties })); setPropertyCache(prev => ({ ...prev, [item.wikidataId!]: properties }));
// Update the item with the fetched property value
if (properties[normalizedProperty]) { if (properties[normalizedProperty]) {
setItems(prevItems => prevItems.map(prevItem => setItems(prevItems => prevItems.map(prevItem =>
prevItem.wikidataId === item.wikidataId prevItem.wikidataId === item.wikidataId
@ -432,6 +451,7 @@ export function ItemsList({ url }: ItemsListProps) {
const handleWikidataSelect = useCallback(async (suggestion: WikidataSuggestion, itemId: string) => { const handleWikidataSelect = useCallback(async (suggestion: WikidataSuggestion, itemId: string) => {
console.log('Wikidata selection for item:', itemId, suggestion); console.log('Wikidata selection for item:', itemId, suggestion);
// Update item immediately with suggestion data
setItems(prev => prev.map(item => setItems(prev => prev.map(item =>
item.id === itemId item.id === itemId
? { ? {
@ -443,6 +463,18 @@ export function ItemsList({ url }: ItemsListProps) {
: item : 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 // Fetch properties for this Wikidata item
if (suggestion.id) { if (suggestion.id) {
const properties = await fetchWikidataProperties(suggestion.id); const properties = await fetchWikidataProperties(suggestion.id);
@ -463,7 +495,7 @@ export function ItemsList({ url }: ItemsListProps) {
: item : item
)); ));
} }
}, [selectedProperties]); }, [selectedProperties, items, saveItemMutation]);
const handleNewPropertyKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => { const handleNewPropertyKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
@ -584,24 +616,13 @@ export function ItemsList({ url }: ItemsListProps) {
{/* Add new property input */} {/* Add new property input */}
<div className="mt-4"> <div className="mt-4">
<input <PropertyInput
ref={newPropertyInputRef} propertyCache={propertyCache}
type="text" propertyLabels={propertyLabels}
placeholder="Add New Property" onAddProperty={addProperty}
onKeyDown={handleNewPropertyKeyDown} onFetchPropertyLabels={fetchPropertyLabelsHelper}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-80"
list="properties"
/> />
<datalist id="properties">
{Object.entries(propertyLabels).map(([propertyId, label]) => {
const optionValue = `${label} (${propertyId})`;
return (
<option key={propertyId} value={optionValue}>
{optionValue}
</option>
);
})}
</datalist>
</div> </div>
</div> </div>
); );

View file

@ -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<string, Record<string, string>>; // wikidataId -> properties
propertyLabels: Record<string, string>; // propertyId -> label
onAddProperty: (propertyId: string) => void;
onFetchPropertyLabels: (propertyIds: string[]) => Promise<Record<string, string>>;
className?: string;
}
export function PropertyInput({
propertyCache,
propertyLabels,
onAddProperty,
onFetchPropertyLabels,
className = ''
}: PropertyInputProps) {
const [inputValue, setInputValue] = useState('');
const [suggestions, setSuggestions] = useState<PropertySuggestion[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const suggestionsRef = useRef<HTMLDivElement>(null);
// Aggregate all available properties from all Wikidata items
const aggregateAvailableProperties = useCallback((): PropertySuggestion[] => {
const propertyMap = new Map<string, Set<string>>();
// 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<HTMLInputElement>) => {
const value = e.target.value;
setInputValue(value);
setShowSuggestions(value.trim().length > 0);
setSelectedIndex(-1);
}, []);
// Handle keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
// 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 (
<div className={`relative ${className}`}>
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder="Add New Property"
className="px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors duration-200 text-sm"
autoComplete="off"
/>
{/* Loading indicator */}
{isLoading && (
<div className="absolute right-2 top-1/2 transform -translate-y-1/2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
</div>
)}
{/* Suggestions dropdown */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div
ref={suggestionsRef}
className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-xl max-h-80 overflow-y-auto"
style={{
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)'
}}
>
<div className="py-2">
{filteredSuggestions.map((suggestion, index) => {
const isSelected = index === selectedIndex;
return (
<div
key={suggestion.id}
className={`
px-4 py-3 cursor-pointer transition-colors duration-150 ease-in-out
${isSelected
? 'bg-blue-50 text-blue-900 border-l-4 border-blue-500'
: 'hover:bg-gray-50 border-l-4 border-transparent'
}
`}
onClick={() => handleSuggestionClick(suggestion)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-gray-900 truncate">
{suggestion.label}
</div>
<div className="text-xs text-gray-500 mt-1">
ID: {suggestion.id}
</div>
<div className="text-xs text-blue-600 mt-1 font-medium">
Available in {suggestion.availableInItems.length} item{suggestion.availableInItems.length !== 1 ? 's' : ''}
</div>
</div>
<div className="ml-3 flex-shrink-0">
<svg
className={`w-4 h-4 transition-colors duration-150 ${isSelected ? 'text-blue-500' : 'text-gray-400'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* No suggestions message */}
{showSuggestions && inputValue.trim().length > 0 && filteredSuggestions.length === 0 && (
<div className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-xl p-4">
<div className="text-sm text-gray-600 text-center">
{`No matching properties found. Press Enter to add "${inputValue}" as a custom property.`}
</div>
</div>
)}
</div>
);
}