feat(component): implement ItemsList component for managing and displaying items with editable properties
This commit is contained in:
parent
dcac90109f
commit
6096cc0670
1 changed files with 608 additions and 0 deletions
608
src/components/ItemsList.tsx
Normal file
608
src/components/ItemsList.tsx
Normal file
|
@ -0,0 +1,608 @@
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Item, WikidataSuggestion, PropertyLabel } from '@/types/database';
|
||||||
|
import { EditableCell } from './EditableCell';
|
||||||
|
import { TypeaheadInput } from './TypeaheadInput';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
interface ItemsListProps {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemsResponse {
|
||||||
|
items: Item[];
|
||||||
|
selectedProperties: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// API functions
|
||||||
|
async function loadItemsFromDb(url: string): Promise<ItemsResponse> {
|
||||||
|
console.log('[DEBUG] Loading items for URL:', url);
|
||||||
|
|
||||||
|
const encodedUrl = encodeURIComponent(url);
|
||||||
|
const apiUrl = `/api/urls/${encodedUrl}/items`;
|
||||||
|
|
||||||
|
console.log('[DEBUG] Making request to API endpoint:', apiUrl);
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('[ERROR] Server error:', response.status, errorText);
|
||||||
|
throw new Error(`Server error (${response.status}): ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await response.json();
|
||||||
|
console.log('[DEBUG] Successfully parsed', items.length, 'items');
|
||||||
|
|
||||||
|
// Get selected properties
|
||||||
|
const propertiesResponse = await fetch(`/api/urls/${encodedUrl}/properties`);
|
||||||
|
if (!propertiesResponse.ok) {
|
||||||
|
throw new Error(`Failed to fetch properties: ${propertiesResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedProperties = await propertiesResponse.json();
|
||||||
|
console.log('[DEBUG] Successfully received selected properties');
|
||||||
|
|
||||||
|
// Filter items to only include selected properties
|
||||||
|
const filteredItems = items.map((item: Item) => ({
|
||||||
|
...item,
|
||||||
|
customProperties: Object.fromEntries(
|
||||||
|
Object.entries(item.customProperties).filter(([key]) =>
|
||||||
|
selectedProperties.includes(key)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { items: filteredItems, selectedProperties };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveItemToDb(url: string, item: Item): Promise<void> {
|
||||||
|
console.log('[FRONTEND] Saving item - ID:', item.id, 'Name:', item.name);
|
||||||
|
|
||||||
|
const encodedUrl = encodeURIComponent(url);
|
||||||
|
const response = await fetch(`/api/urls/${encodedUrl}/items`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(item)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to save item: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteItemFromDb(url: string, itemId: string): Promise<void> {
|
||||||
|
const encodedUrl = encodeURIComponent(url);
|
||||||
|
const response = await fetch(`/api/urls/${encodedUrl}/items/${itemId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to delete item: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePropertyFromDb(url: string, property: string): Promise<void> {
|
||||||
|
const encodedUrl = encodeURIComponent(url);
|
||||||
|
const response = await fetch(`/api/urls/${encodedUrl}/properties/${property}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to delete property: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPropertyToDb(url: string, property: string): Promise<void> {
|
||||||
|
const encodedUrl = encodeURIComponent(url);
|
||||||
|
const response = await fetch(`/api/urls/${encodedUrl}/properties`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(property)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to add property: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wikidata API functions
|
||||||
|
async function fetchWikidataProperties(wikidataId: string): Promise<Record<string, string>> {
|
||||||
|
const sparqlQuery = `
|
||||||
|
SELECT ?prop ?propLabel ?value ?valueLabel WHERE {
|
||||||
|
wd:${wikidataId} ?prop ?statement.
|
||||||
|
?statement ?ps ?value.
|
||||||
|
?property wikibase:claim ?prop.
|
||||||
|
?property wikibase:statementProperty ?ps.
|
||||||
|
SERVICE wikibase:label {
|
||||||
|
bd:serviceParam wikibase:language "en".
|
||||||
|
?prop rdfs:label ?propLabel.
|
||||||
|
?value rdfs:label ?valueLabel.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const url = `https://query.wikidata.org/sparql?query=${encodeURIComponent(sparqlQuery)}&format=json`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'Accept': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return {};
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (data.results?.bindings) {
|
||||||
|
for (const binding of data.results.bindings) {
|
||||||
|
const propUri = binding.prop?.value;
|
||||||
|
const value = binding.valueLabel?.value || binding.value?.value || '';
|
||||||
|
|
||||||
|
if (propUri) {
|
||||||
|
const propId = propUri.split('/').pop() || '';
|
||||||
|
result[propId] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Wikidata properties:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPropertyLabels(propertyIds: string[]): Promise<Record<string, string>> {
|
||||||
|
const cleanIds = propertyIds.map(id => id.replace('http://www.wikidata.org/prop/', ''));
|
||||||
|
const propertyIdsStr = cleanIds.join(' wd:');
|
||||||
|
|
||||||
|
const sparqlQuery = `
|
||||||
|
SELECT ?prop ?propLabel WHERE {
|
||||||
|
VALUES ?prop { wd:${propertyIdsStr} }
|
||||||
|
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const url = `https://query.wikidata.org/sparql?query=${encodeURIComponent(sparqlQuery)}&format=json`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'Accept': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return {};
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (data.results?.bindings) {
|
||||||
|
for (const binding of data.results.bindings) {
|
||||||
|
const propUri = binding.prop?.value;
|
||||||
|
const label = binding.propLabel?.value || '';
|
||||||
|
|
||||||
|
if (propUri) {
|
||||||
|
const propId = propUri.split('/').pop() || '';
|
||||||
|
result[propId] = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching property labels:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWikidataSuggestions(query: string): Promise<WikidataSuggestion[]> {
|
||||||
|
if (!query.trim()) return [];
|
||||||
|
|
||||||
|
const url = `https://www.wikidata.org/w/api.php?action=wbsearchentities&search=${encodeURIComponent(query)}&language=en&limit=5&format=json&origin=*`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) return [];
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.search || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Wikidata suggestions:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemsList({ url }: ItemsListProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// State management
|
||||||
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
|
const [selectedProperties, setSelectedProperties] = useState<Record<string, boolean>>({});
|
||||||
|
const [customProperties, setCustomProperties] = useState<string[]>([]);
|
||||||
|
const [propertyLabels, setPropertyLabels] = useState<Record<string, string>>({});
|
||||||
|
const [focusedCell, setFocusedCell] = useState<string | null>(null);
|
||||||
|
const [focusedItemId, setFocusedItemId] = useState<string | null>(null);
|
||||||
|
const [wikidataSuggestions, setWikidataSuggestions] = useState<Record<string, WikidataSuggestion[]>>({});
|
||||||
|
const [propertyCache, setPropertyCache] = useState<Record<string, Record<string, string>>>({});
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const newPropertyInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Load items from database
|
||||||
|
const { data: itemsData, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['items', url],
|
||||||
|
queryFn: () => loadItemsFromDb(url),
|
||||||
|
staleTime: 30000, // 30 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize state when data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (itemsData) {
|
||||||
|
const { items: loadedItems, selectedProperties: selectedProps } = itemsData;
|
||||||
|
|
||||||
|
if (loadedItems.length === 0) {
|
||||||
|
// Initialize with one empty item
|
||||||
|
const emptyItem: Item = {
|
||||||
|
id: uuidv4(),
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
wikidataId: undefined,
|
||||||
|
customProperties: {}
|
||||||
|
};
|
||||||
|
setItems([emptyItem]);
|
||||||
|
} else {
|
||||||
|
setItems(loadedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set selected properties
|
||||||
|
const selectedPropsMap: Record<string, boolean> = {};
|
||||||
|
selectedProps.forEach(prop => {
|
||||||
|
selectedPropsMap[prop] = true;
|
||||||
|
});
|
||||||
|
setSelectedProperties(selectedPropsMap);
|
||||||
|
|
||||||
|
// Extract custom properties
|
||||||
|
const customProps = Array.from(new Set(
|
||||||
|
loadedItems.flatMap(item => Object.keys(item.customProperties))
|
||||||
|
));
|
||||||
|
setCustomProperties(customProps);
|
||||||
|
|
||||||
|
// Fetch property labels
|
||||||
|
if (customProps.length > 0) {
|
||||||
|
fetchPropertyLabels(customProps).then(labels => {
|
||||||
|
setPropertyLabels(prev => ({ ...prev, ...labels }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial focus
|
||||||
|
if (loadedItems.length > 0 && !focusedItemId) {
|
||||||
|
setFocusedItemId(loadedItems[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [itemsData, focusedItemId]);
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const saveItemMutation = useMutation({
|
||||||
|
mutationFn: (item: Item) => saveItemToDb(url, item),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items', url] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteItemMutation = useMutation({
|
||||||
|
mutationFn: (itemId: string) => deleteItemFromDb(url, itemId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items', url] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletePropertyMutation = useMutation({
|
||||||
|
mutationFn: (property: string) => deletePropertyFromDb(url, property),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items', url] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const addPropertyMutation = useMutation({
|
||||||
|
mutationFn: (property: string) => addPropertyToDb(url, property),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items', url] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
const updateItem = useCallback((index: number, field: string, value: string) => {
|
||||||
|
setItems(prevItems => {
|
||||||
|
const newItems = [...prevItems];
|
||||||
|
const item = { ...newItems[index] };
|
||||||
|
|
||||||
|
if (field === 'name') {
|
||||||
|
item.name = value;
|
||||||
|
// Fetch Wikidata suggestions
|
||||||
|
if (value.trim()) {
|
||||||
|
fetchWikidataSuggestions(value).then(suggestions => {
|
||||||
|
setWikidataSuggestions(prev => ({
|
||||||
|
...prev,
|
||||||
|
[`name-${index}`]: suggestions
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (field === 'description') {
|
||||||
|
item.description = value;
|
||||||
|
} else {
|
||||||
|
item.customProperties = { ...item.customProperties, [field]: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
newItems[index] = item;
|
||||||
|
|
||||||
|
// Auto-save
|
||||||
|
saveItemMutation.mutate(item);
|
||||||
|
|
||||||
|
// Add new row if editing last row and value is not empty
|
||||||
|
if (index === newItems.length - 1 && value.trim()) {
|
||||||
|
const newItem: Item = {
|
||||||
|
id: uuidv4(),
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
wikidataId: undefined,
|
||||||
|
customProperties: {}
|
||||||
|
};
|
||||||
|
newItems.push(newItem);
|
||||||
|
saveItemMutation.mutate(newItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newItems;
|
||||||
|
});
|
||||||
|
}, [saveItemMutation]);
|
||||||
|
|
||||||
|
const removeItem = useCallback((index: number) => {
|
||||||
|
const itemId = items[index].id;
|
||||||
|
deleteItemMutation.mutate(itemId);
|
||||||
|
setItems(prev => prev.filter((_, i) => i !== index));
|
||||||
|
}, [items, deleteItemMutation]);
|
||||||
|
|
||||||
|
const removeProperty = useCallback((property: string) => {
|
||||||
|
deletePropertyMutation.mutate(property);
|
||||||
|
setCustomProperties(prev => prev.filter(p => p !== property));
|
||||||
|
setSelectedProperties(prev => {
|
||||||
|
const newSelected = { ...prev };
|
||||||
|
delete newSelected[property];
|
||||||
|
return newSelected;
|
||||||
|
});
|
||||||
|
setItems(prev => prev.map(item => ({
|
||||||
|
...item,
|
||||||
|
customProperties: Object.fromEntries(
|
||||||
|
Object.entries(item.customProperties).filter(([key]) => key !== property)
|
||||||
|
)
|
||||||
|
})));
|
||||||
|
}, [deletePropertyMutation]);
|
||||||
|
|
||||||
|
const addProperty = useCallback(async (property: string) => {
|
||||||
|
const normalizedProperty = property.replace('http://www.wikidata.org/prop/', '');
|
||||||
|
|
||||||
|
if (!normalizedProperty || selectedProperties[normalizedProperty]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to selected properties
|
||||||
|
setSelectedProperties(prev => ({ ...prev, [normalizedProperty]: true }));
|
||||||
|
setCustomProperties(prev => [...prev, normalizedProperty]);
|
||||||
|
|
||||||
|
// Fetch label if not exists
|
||||||
|
if (!propertyLabels[normalizedProperty]) {
|
||||||
|
const labels = await fetchPropertyLabels([normalizedProperty]);
|
||||||
|
setPropertyLabels(prev => ({ ...prev, ...labels }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to all items
|
||||||
|
setItems(prev => prev.map(item => ({
|
||||||
|
...item,
|
||||||
|
customProperties: {
|
||||||
|
...item.customProperties,
|
||||||
|
[normalizedProperty]: item.customProperties[normalizedProperty] || ''
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
addPropertyMutation.mutate(normalizedProperty);
|
||||||
|
|
||||||
|
// Fetch Wikidata properties for items with wikidata_id
|
||||||
|
items.forEach(async (item) => {
|
||||||
|
if (item.wikidataId && !propertyCache[item.wikidataId]) {
|
||||||
|
const properties = await fetchWikidataProperties(item.wikidataId);
|
||||||
|
setPropertyCache(prev => ({ ...prev, [item.wikidataId!]: properties }));
|
||||||
|
|
||||||
|
if (properties[normalizedProperty]) {
|
||||||
|
setItems(prevItems => prevItems.map(prevItem =>
|
||||||
|
prevItem.wikidataId === item.wikidataId
|
||||||
|
? {
|
||||||
|
...prevItem,
|
||||||
|
customProperties: {
|
||||||
|
...prevItem.customProperties,
|
||||||
|
[normalizedProperty]: properties[normalizedProperty]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: prevItem
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [selectedProperties, propertyLabels, items, propertyCache, addPropertyMutation]);
|
||||||
|
|
||||||
|
const handleWikidataSelect = useCallback(async (suggestion: WikidataSuggestion, itemId: string) => {
|
||||||
|
console.log('Wikidata selection for item:', itemId, suggestion);
|
||||||
|
|
||||||
|
setItems(prev => prev.map(item =>
|
||||||
|
item.id === itemId
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
name: suggestion.label || '',
|
||||||
|
description: suggestion.description || '',
|
||||||
|
wikidataId: suggestion.id
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
));
|
||||||
|
|
||||||
|
// Fetch properties for this Wikidata item
|
||||||
|
if (suggestion.id) {
|
||||||
|
const properties = await fetchWikidataProperties(suggestion.id);
|
||||||
|
setPropertyCache(prev => ({ ...prev, [suggestion.id]: properties }));
|
||||||
|
|
||||||
|
// Update item with fetched properties
|
||||||
|
setItems(prev => prev.map(item =>
|
||||||
|
item.id === itemId
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
customProperties: {
|
||||||
|
...item.customProperties,
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(properties).filter(([key]) => selectedProperties[key])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}, [selectedProperties]);
|
||||||
|
|
||||||
|
const handleNewPropertyKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const inputValue = input.value.trim();
|
||||||
|
|
||||||
|
if (inputValue) {
|
||||||
|
// Extract property ID from "Label (P123)" format
|
||||||
|
const propertyId = inputValue.includes(' (') && inputValue.endsWith(')')
|
||||||
|
? inputValue.split(' (').pop()?.slice(0, -1) || inputValue
|
||||||
|
: inputValue;
|
||||||
|
|
||||||
|
addProperty(propertyId);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [addProperty]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="p-4">Loading items...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="p-4 text-red-600">Error loading items: {error.message}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Items List</h1>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full border-collapse border border-gray-300">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="border border-gray-300 p-2 bg-gray-100">Property</th>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<th key={item.id} className="border border-gray-300 p-2 bg-gray-100 relative">
|
||||||
|
{item.name || `Item ${index + 1}`}
|
||||||
|
<button
|
||||||
|
onClick={() => removeItem(index)}
|
||||||
|
className="absolute top-1 right-1 w-5 h-5 bg-red-500 text-white rounded-full text-xs hover:bg-red-600"
|
||||||
|
title="Delete item"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{/* Name row */}
|
||||||
|
<tr>
|
||||||
|
<td className="border border-gray-300 p-2 font-medium">Name</td>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<td key={`name-${item.id}`} className="border border-gray-300 p-2">
|
||||||
|
<TypeaheadInput
|
||||||
|
value={item.name}
|
||||||
|
onInput={(value) => updateItem(index, 'name', value)}
|
||||||
|
onSelect={(suggestion) => handleWikidataSelect(suggestion, item.id)}
|
||||||
|
fetchSuggestions={fetchWikidataSuggestions}
|
||||||
|
onFocus={() => setFocusedItemId(item.id)}
|
||||||
|
placeholder="Enter item name"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Description row */}
|
||||||
|
<tr>
|
||||||
|
<td className="border border-gray-300 p-2 font-medium">Description</td>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<td key={`description-${item.id}`} className="border border-gray-300 p-2">
|
||||||
|
<EditableCell
|
||||||
|
value={item.description}
|
||||||
|
onInput={(value) => updateItem(index, 'description', value)}
|
||||||
|
focusedCell={focusedCell}
|
||||||
|
setFocusedCell={setFocusedCell}
|
||||||
|
cellKey={`description-${index}`}
|
||||||
|
inputType="textarea"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Custom property rows */}
|
||||||
|
{customProperties.map((property) => {
|
||||||
|
const propertyLabel = propertyLabels[property] || property;
|
||||||
|
return (
|
||||||
|
<tr key={property}>
|
||||||
|
<td className="border border-gray-300 p-2 font-medium relative">
|
||||||
|
{propertyLabel}
|
||||||
|
<button
|
||||||
|
onClick={() => removeProperty(property)}
|
||||||
|
className="absolute top-1 right-1 w-5 h-5 bg-red-500 text-white rounded-full text-xs hover:bg-red-600"
|
||||||
|
title="Delete property"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<td key={`${property}-${item.id}`} className="border border-gray-300 p-2">
|
||||||
|
<EditableCell
|
||||||
|
value={item.customProperties[property] || ''}
|
||||||
|
onInput={(value) => updateItem(index, property, value)}
|
||||||
|
focusedCell={focusedCell}
|
||||||
|
setFocusedCell={setFocusedCell}
|
||||||
|
cellKey={`${property}-${index}`}
|
||||||
|
inputType="textarea"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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"
|
||||||
|
/>
|
||||||
|
<datalist id="properties">
|
||||||
|
{Object.entries(propertyLabels).map(([propertyId, label]) => {
|
||||||
|
const optionValue = `${label} (${propertyId})`;
|
||||||
|
return (
|
||||||
|
<option key={propertyId} value={optionValue}>
|
||||||
|
{optionValue}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue