bugfix(PRM-01)
This commit is contained in:
parent
dc174f570c
commit
5dd4741a0f
3 changed files with 148 additions and 22 deletions
|
|
@ -118,15 +118,11 @@ export function DeleteActionButton<T = any>({
|
||||||
const success = await handleDelete(itemId);
|
const success = await handleDelete(itemId);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// If we used optimistic removal, don't refetch immediately
|
// Always refetch after a successful delete. The server has actually
|
||||||
// The item is already removed from UI, and refetch might bring it back
|
// removed the row, so fresh data won't bring it back — and this is
|
||||||
if (removeOptimistically) {
|
// what re-syncs pagination.totalItems (and clears any optimistic
|
||||||
// Only refetch if there was an error or if we need to sync other changes
|
// hidden-row state maintained by FormGeneratorTable).
|
||||||
// For now, we trust the optimistic removal worked
|
refetch();
|
||||||
} else {
|
|
||||||
// No optimistic removal, refetch immediately
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
onSuccess?.(row);
|
onSuccess?.(row);
|
||||||
} else {
|
} else {
|
||||||
// Refetch to restore the item in case of failure
|
// Refetch to restore the item in case of failure
|
||||||
|
|
|
||||||
|
|
@ -339,7 +339,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
onRefresh,
|
onRefresh,
|
||||||
className = '',
|
className = '',
|
||||||
getRowDataAttributes,
|
getRowDataAttributes,
|
||||||
hookData,
|
hookData: hookDataProp,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
apiEndpoint,
|
apiEndpoint,
|
||||||
groupBy,
|
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
|
// 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 onDeleteMultiple = onDeleteMultipleProp ?? (onDelete ? (rows: T[]) => rows.forEach((r) => onDelete(r)) : undefined);
|
||||||
const currentLanguage = useMemo(() => contextLanguage || 'en', [contextLanguage]);
|
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
|
// Use provided columns from Pydantic attribute definitions
|
||||||
// NO AUTO-DETECTION - columns must come from backend 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)
|
// Use a ref to cache columns so they persist across data changes (e.g., when filtering)
|
||||||
|
|
@ -440,6 +542,18 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchTerm]);
|
}, [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
|
// Call backend when filters/search/sort/pagination change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -740,9 +854,13 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
return value.length > 8 ? `${value.substring(0, 8)}...` : value;
|
return value.length > 8 ? `${value.substring(0, 8)}...` : value;
|
||||||
}, [fkCache]);
|
}, [fkCache]);
|
||||||
|
|
||||||
// Data is already filtered, sorted, and paginated by the backend
|
// Data is already filtered, sorted, and paginated by the backend.
|
||||||
// No client-side processing needed
|
// Client-side only filters out rows that were just optimistically deleted
|
||||||
const displayData = data;
|
// 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
|
// Grouping: Group data by groupBy field if specified
|
||||||
const groupedData = useMemo(() => {
|
const groupedData = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Follows the pattern established in AdminUsersPage/WorkflowsPage.
|
* 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 { usePrompts, usePromptOperations } from '../../hooks/usePrompts';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
|
|
@ -49,10 +49,22 @@ export const PromptsPage: React.FC = () => {
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [editingPrompt, setEditingPrompt] = useState<Prompt | null>(null);
|
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
|
// Initial fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refetch();
|
_tableRefetch({ page: 1, pageSize: 25 });
|
||||||
}, []);
|
}, [_tableRefetch]);
|
||||||
|
|
||||||
// Generate columns from attributes - exclude ID fields from display
|
// Generate columns from attributes - exclude ID fields from display
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
|
|
@ -114,7 +126,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
});
|
});
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
refetch();
|
_refreshAll();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -127,7 +139,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
});
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setEditingPrompt(null);
|
setEditingPrompt(null);
|
||||||
refetch();
|
_refreshAll();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -135,7 +147,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
const handleDelete = async (prompt: Prompt) => {
|
const handleDelete = async (prompt: Prompt) => {
|
||||||
const success = await handlePromptDelete(prompt.id);
|
const success = await handlePromptDelete(prompt.id);
|
||||||
if (success) {
|
if (success) {
|
||||||
refetch();
|
_refreshAll();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -152,7 +164,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>{t('Fehler beim Laden der Prompts: {detail}', { detail: String(error) })}</p>
|
<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')}
|
<FaSync /> {t('Erneut versuchen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -170,7 +182,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<button
|
<button
|
||||||
className={styles.secondaryButton}
|
className={styles.secondaryButton}
|
||||||
onClick={() => refetch()}
|
onClick={() => _refreshAll()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||||
|
|
@ -217,7 +229,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
]}
|
]}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch,
|
refetch: _tableRefetch,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
handleDelete: handlePromptDelete,
|
handleDelete: handlePromptDelete,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue