From 6096cc0670a26fca029b644f98bc2070f8111cd5 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 19 Jun 2025 15:34:36 +0300 Subject: [PATCH] feat(component): implement ItemsList component for managing and displaying items with editable properties --- src/components/ItemsList.tsx | 608 +++++++++++++++++++++++++++++++++++ 1 file changed, 608 insertions(+) create mode 100644 src/components/ItemsList.tsx diff --git a/src/components/ItemsList.tsx b/src/components/ItemsList.tsx new file mode 100644 index 0000000..a8d60e2 --- /dev/null +++ b/src/components/ItemsList.tsx @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 = {}; + + 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> { + 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 = {}; + + 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 { + 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([]); + const [selectedProperties, setSelectedProperties] = useState>({}); + const [customProperties, setCustomProperties] = useState([]); + const [propertyLabels, setPropertyLabels] = useState>({}); + const [focusedCell, setFocusedCell] = useState(null); + const [focusedItemId, setFocusedItemId] = useState(null); + const [wikidataSuggestions, setWikidataSuggestions] = useState>({}); + const [propertyCache, setPropertyCache] = useState>>({}); + + // Refs + const newPropertyInputRef = useRef(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 = {}; + 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) => { + 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
Loading items...
; + } + + if (error) { + return
Error loading items: {error.message}
; + } + + return ( +
+

Items List

+ +
+ + + + + {items.map((item, index) => ( + + ))} + + + + {/* Name row */} + + + {items.map((item, index) => ( + + ))} + + + {/* Description row */} + + + {items.map((item, index) => ( + + ))} + + + {/* Custom property rows */} + {customProperties.map((property) => { + const propertyLabel = propertyLabels[property] || property; + return ( + + + {items.map((item, index) => ( + + ))} + + ); + })} + +
Property + {item.name || `Item ${index + 1}`} + +
Name + updateItem(index, 'name', value)} + onSelect={(suggestion) => handleWikidataSelect(suggestion, item.id)} + fetchSuggestions={fetchWikidataSuggestions} + onFocus={() => setFocusedItemId(item.id)} + placeholder="Enter item name" + /> +
Description + updateItem(index, 'description', value)} + focusedCell={focusedCell} + setFocusedCell={setFocusedCell} + cellKey={`description-${index}`} + inputType="textarea" + /> +
+ {propertyLabel} + + + updateItem(index, property, value)} + focusedCell={focusedCell} + setFocusedCell={setFocusedCell} + cellKey={`${property}-${index}`} + inputType="textarea" + /> +
+
+ + {/* Add new property input */} +
+ + + {Object.entries(propertyLabels).map(([propertyId, label]) => { + const optionValue = `${label} (${propertyId})`; + return ( + + ); + })} + +
+
+ ); +} \ No newline at end of file