feat(component): implement ItemsList component for managing and displaying items with editable properties

This commit is contained in:
ryan 2025-06-19 15:34:36 +03:00
parent dcac90109f
commit 6096cc0670

View 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>
);
}