From 5dd4741a0fbebc20fe44ca0ccdd95b4ed2dc69c2 Mon Sep 17 00:00:00 2001 From: Ida Date: Fri, 17 Apr 2026 15:05:34 +0200 Subject: [PATCH] bugfix(PRM-01) --- .../DeleteActionButton/DeleteActionButton.tsx | 14 +- .../FormGeneratorTable/FormGeneratorTable.tsx | 126 +++++++++++++++++- src/pages/basedata/PromptsPage.tsx | 30 +++-- 3 files changed, 148 insertions(+), 22 deletions(-) diff --git a/src/components/FormGenerator/ActionButtons/DeleteActionButton/DeleteActionButton.tsx b/src/components/FormGenerator/ActionButtons/DeleteActionButton/DeleteActionButton.tsx index b040d45..f3e9ab4 100644 --- a/src/components/FormGenerator/ActionButtons/DeleteActionButton/DeleteActionButton.tsx +++ b/src/components/FormGenerator/ActionButtons/DeleteActionButton/DeleteActionButton.tsx @@ -118,15 +118,11 @@ export function DeleteActionButton({ const success = await handleDelete(itemId); if (success) { - // If we used optimistic removal, don't refetch immediately - // The item is already removed from UI, and refetch might bring it back - if (removeOptimistically) { - // Only refetch if there was an error or if we need to sync other changes - // For now, we trust the optimistic removal worked - } else { - // No optimistic removal, refetch immediately - refetch(); - } + // Always refetch after a successful delete. The server has actually + // removed the row, so fresh data won't bring it back — and this is + // what re-syncs pagination.totalItems (and clears any optimistic + // hidden-row state maintained by FormGeneratorTable). + refetch(); onSuccess?.(row); } else { // Refetch to restore the item in case of failure diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 6b71363..ef353ca 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -339,7 +339,7 @@ export function FormGeneratorTable>({ onRefresh, className = '', getRowDataAttributes, - hookData, + hookData: hookDataProp, emptyMessage, apiEndpoint, groupBy, @@ -356,6 +356,108 @@ export function FormGeneratorTable>({ // When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected const onDeleteMultiple = onDeleteMultipleProp ?? (onDelete ? (rows: T[]) => rows.forEach((r) => onDelete(r)) : undefined); const currentLanguage = useMemo(() => contextLanguage || 'en', [contextLanguage]); + + // ── Optimistic row hiding + adjusted header count ─────────────────────── + // We synthesize `removeOptimistically` at the FormGenerator layer so every + // page gets instant delete feedback (row hidden + "N Einträge" decremented) + // regardless of whether the underlying hook implements it. + // + // 1. `optimisticallyDeletedIds` tracks which rows are currently hidden. + // 2. `displayData` below is filtered to exclude those IDs. + // 3. `pagination.totalItems`/`totalPages` are reduced by the set size. + // 4. The set is cleared whenever a fresh `pagination` reference arrives + // from the hook (i.e. after a successful refetch establishes server + // truth). + const [optimisticallyDeletedIds, setOptimisticallyDeletedIds] = useState>(() => new Set()); + const previousPaginationRef = useRef(hookDataProp?.pagination); + useEffect(() => { + if (hookDataProp?.pagination !== previousPaginationRef.current) { + previousPaginationRef.current = hookDataProp?.pagination; + if (optimisticallyDeletedIds.size > 0) setOptimisticallyDeletedIds(new Set()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hookDataProp?.pagination]); + + // Snapshot of the current table state so a "naked" refetch() call (e.g. + // from DeleteActionButton) still includes page/pageSize/filters/sort/search. + // Without this, the hook issues a no-params GET which, for paginated + // endpoints, returns `pagination: null` and leaves `totalItems` stale. + const tableStateRef = useRef({ + page: 1, + pageSize: pageSize, + search: '', + filters: {} as Record, + sort: [] as Array<{ key: string; direction: 'asc' | 'desc' }>, + }); + + const hookData = useMemo(() => { + if (!hookDataProp) return hookDataProp; + + const origRemove = + hookDataProp.removeOptimistically || hookDataProp.removeFileOptimistically; + const wrappedRemove = (id: string) => { + if (origRemove) origRemove(id); + setOptimisticallyDeletedIds(prev => { + const next = new Set(prev); + next.add(String(id)); + return next; + }); + }; + + const wrappedRefetch = hookDataProp.refetch + ? async (params?: any) => { + const hasPaginationInfo = + params && (params.page !== undefined || params.pageSize !== undefined); + if (hasPaginationInfo) { + return await hookDataProp.refetch(params); + } + const s = tableStateRef.current; + const finalParams: any = { + page: s.page, + pageSize: s.pageSize, + ...(params || {}), + }; + if (s.search && s.search.trim()) finalParams.search = s.search.trim(); + const activeFilters: Record = {}; + Object.entries(s.filters).forEach(([k, v]) => { + if (v !== undefined && v !== '') activeFilters[k] = v; + }); + if (Object.keys(activeFilters).length) finalParams.filters = activeFilters; + if (s.sort.length) { + finalParams.sort = s.sort.map(sc => ({ field: sc.key, direction: sc.direction })); + } + return await hookDataProp.refetch(finalParams); + } + : hookDataProp.refetch; + + const origPagination = hookDataProp.pagination; + const hasNumericTotal = + origPagination && typeof origPagination.totalItems === 'number'; + const delta = -optimisticallyDeletedIds.size; + const adjustedTotalItems = hasNumericTotal + ? Math.max(0, origPagination.totalItems + delta) + : undefined; + const adjustedTotalPages = + hasNumericTotal && origPagination.pageSize + ? Math.max(1, Math.ceil((adjustedTotalItems ?? 0) / origPagination.pageSize)) + : origPagination?.totalPages; + + const adjustedPagination = hasNumericTotal + ? { + ...origPagination, + totalItems: adjustedTotalItems, + totalPages: adjustedTotalPages, + } + : origPagination; + + return { + ...hookDataProp, + removeOptimistically: wrappedRemove, + removeFileOptimistically: wrappedRemove, + refetch: wrappedRefetch, + pagination: adjustedPagination, + }; + }, [hookDataProp, optimisticallyDeletedIds]); // Use provided columns from Pydantic attribute definitions // NO AUTO-DETECTION - columns must come from backend attribute definitions // Use a ref to cache columns so they persist across data changes (e.g., when filtering) @@ -440,6 +542,18 @@ export function FormGeneratorTable>({ return () => clearTimeout(timer); }, [searchTerm]); + + // Keep tableStateRef in sync so a "naked" refetch() call (from e.g. + // DeleteActionButton) can inject the current page/pageSize/filters/sort. + useEffect(() => { + tableStateRef.current = { + page: currentPage, + pageSize: currentPageSize, + search: debouncedSearchTerm, + filters, + sort: sortConfigs, + }; + }, [currentPage, currentPageSize, debouncedSearchTerm, filters, sortConfigs]); // Call backend when filters/search/sort/pagination change useEffect(() => { @@ -740,9 +854,13 @@ export function FormGeneratorTable>({ return value.length > 8 ? `${value.substring(0, 8)}...` : value; }, [fkCache]); - // Data is already filtered, sorted, and paginated by the backend - // No client-side processing needed - const displayData = data; + // Data is already filtered, sorted, and paginated by the backend. + // Client-side only filters out rows that were just optimistically deleted + // so the UI updates instantly before the server's next refetch response. + const displayData = useMemo(() => { + if (optimisticallyDeletedIds.size === 0) return data; + return data.filter(row => !optimisticallyDeletedIds.has(String(row?.[_idField]))); + }, [data, optimisticallyDeletedIds, _idField]); // Grouping: Group data by groupBy field if specified const groupedData = useMemo(() => { diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index 2fa7740..8e56068 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -5,7 +5,7 @@ * Follows the pattern established in AdminUsersPage/WorkflowsPage. */ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, useCallback } from 'react'; import { usePrompts, usePromptOperations } from '../../hooks/usePrompts'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; @@ -49,10 +49,22 @@ export const PromptsPage: React.FC = () => { const [showCreateModal, setShowCreateModal] = useState(false); const [editingPrompt, setEditingPrompt] = useState(null); + // ── Table refetch wrapper (stable signature used by FormGeneratorTable) ── + const _tableRefetch = useCallback(async (params?: any) => { + await refetch(params); + }, [refetch]); + + // ── Refresh-All for the header "Aktualisieren" button ──────────────────── + // Forces a paginated request so the cache key matches what the table uses + // internally. This guarantees fresh (non-cached) data is pulled in. + const _refreshAll = useCallback(async () => { + await _tableRefetch({ page: 1, pageSize: 25 }); + }, [_tableRefetch]); + // Initial fetch useEffect(() => { - refetch(); - }, []); + _tableRefetch({ page: 1, pageSize: 25 }); + }, [_tableRefetch]); // Generate columns from attributes - exclude ID fields from display const columns = useMemo(() => { @@ -114,7 +126,7 @@ export const PromptsPage: React.FC = () => { }); if (result?.success) { setShowCreateModal(false); - refetch(); + _refreshAll(); } }; @@ -127,7 +139,7 @@ export const PromptsPage: React.FC = () => { }); if (result.success) { setEditingPrompt(null); - refetch(); + _refreshAll(); } }; @@ -135,7 +147,7 @@ export const PromptsPage: React.FC = () => { const handleDelete = async (prompt: Prompt) => { const success = await handlePromptDelete(prompt.id); if (success) { - refetch(); + _refreshAll(); } }; @@ -152,7 +164,7 @@ export const PromptsPage: React.FC = () => {
⚠️

{t('Fehler beim Laden der Prompts: {detail}', { detail: String(error) })}

-
@@ -170,7 +182,7 @@ export const PromptsPage: React.FC = () => {