bugfix(PRM-01)

This commit is contained in:
Ida 2026-04-17 15:05:34 +02:00
parent dc174f570c
commit 5dd4741a0f
3 changed files with 148 additions and 22 deletions

View file

@ -118,15 +118,11 @@ export function DeleteActionButton<T = any>({
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

View file

@ -339,7 +339,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
onRefresh,
className = '',
getRowDataAttributes,
hookData,
hookData: hookDataProp,
emptyMessage,
apiEndpoint,
groupBy,
@ -356,6 +356,108 @@ export function FormGeneratorTable<T extends Record<string, any>>({
// 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<Set<string>>(() => new Set());
const previousPaginationRef = useRef<any>(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<string, any>,
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<string, any> = {};
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)
@ -441,6 +543,18 @@ export function FormGeneratorTable<T extends Record<string, any>>({
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(() => {
if (!supportsBackendPagination || !hookData?.refetch) return;
@ -740,9 +854,13 @@ export function FormGeneratorTable<T extends Record<string, any>>({
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(() => {

View file

@ -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<Prompt | null>(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 = () => {
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>{t('Fehler beim Laden der Prompts: {detail}', { detail: String(error) })}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<button className={styles.secondaryButton} onClick={() => _refreshAll()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
@ -170,7 +182,7 @@ export const PromptsPage: React.FC = () => {
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
onClick={() => _refreshAll()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
@ -217,7 +229,7 @@ export const PromptsPage: React.FC = () => {
]}
onDelete={handleDelete}
hookData={{
refetch,
refetch: _tableRefetch,
permissions,
pagination,
handleDelete: handlePromptDelete,