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 { 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>
|
||||||
);
|
);
|
||||||
|
|
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