feat(PropertyInput): add new component for managing property input and suggestions
This commit is contained in:
parent
fa65fb666b
commit
c6a90805af
2 changed files with 339 additions and 26 deletions
|
@ -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<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 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 => ({
|
||||
// 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]: item.customProperties[normalizedProperty] || ''
|
||||
[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
|
||||
? {
|
||||
|
@ -443,6 +463,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) {
|
||||
const properties = await fetchWikidataProperties(suggestion.id);
|
||||
|
@ -463,7 +495,7 @@ export function ItemsList({ url }: ItemsListProps) {
|
|||
: item
|
||||
));
|
||||
}
|
||||
}, [selectedProperties]);
|
||||
}, [selectedProperties, items, saveItemMutation]);
|
||||
|
||||
const handleNewPropertyKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
|
@ -584,24 +616,13 @@ export function ItemsList({ url }: ItemsListProps) {
|
|||
|
||||
{/* Add new property input */}
|
||||
<div className="mt-4">
|
||||
<input
|
||||
ref={newPropertyInputRef}
|
||||
type="text"
|
||||
placeholder="Add New Property"
|
||||
onKeyDown={handleNewPropertyKeyDown}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
list="properties"
|
||||
<PropertyInput
|
||||
propertyCache={propertyCache}
|
||||
propertyLabels={propertyLabels}
|
||||
onAddProperty={addProperty}
|
||||
onFetchPropertyLabels={fetchPropertyLabelsHelper}
|
||||
className="w-80"
|
||||
/>
|
||||
<datalist id="properties">
|
||||
{Object.entries(propertyLabels).map(([propertyId, label]) => {
|
||||
const optionValue = `${label} (${propertyId})`;
|
||||
return (
|
||||
<option key={propertyId} value={optionValue}>
|
||||
{optionValue}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
292
src/components/PropertyInput.tsx
Normal file
292
src/components/PropertyInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
Loading…
Add table
Reference in a new issue