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