mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 13:31:22 -05:00
Fix N+1 query and UI flashing
This commit is contained in:
@@ -79,6 +79,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
const [selectedItemForAction, setSelectedItemForAction] = useState<ModerationItem | null>(null);
|
const [selectedItemForAction, setSelectedItemForAction] = useState<ModerationItem | null>(null);
|
||||||
const [interactingWith, setInteractingWith] = useState<Set<string>>(new Set());
|
const [interactingWith, setInteractingWith] = useState<Set<string>>(new Set());
|
||||||
const [newItemsCount, setNewItemsCount] = useState(0);
|
const [newItemsCount, setNewItemsCount] = useState(0);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { isAdmin, isSuperuser } = useUserRole();
|
const { isAdmin, isSuperuser } = useUserRole();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -108,12 +109,21 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent duplicate requests
|
||||||
|
if (isRefreshing && silent) {
|
||||||
|
console.log('⏭️ Skipping refresh - already in progress');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Only show loading on initial load or filter change
|
// Only show loading on initial load or filter change
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setNewItemsCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsRefreshing(true);
|
||||||
|
|
||||||
let reviewStatuses: string[] = [];
|
let reviewStatuses: string[] = [];
|
||||||
let submissionStatuses: string[] = [];
|
let submissionStatuses: string[] = [];
|
||||||
|
|
||||||
@@ -183,7 +193,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
reviews = reviewsData || [];
|
reviews = reviewsData || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch content submissions with entity data
|
// Fetch content submissions with entity data (OPTIMIZED: Single query with all entity data)
|
||||||
let submissions = [];
|
let submissions = [];
|
||||||
if ((entityFilter === 'all' || entityFilter === 'submissions' || entityFilter === 'photos') && submissionStatuses.length > 0) {
|
if ((entityFilter === 'all' || entityFilter === 'submissions' || entityFilter === 'photos') && submissionStatuses.length > 0) {
|
||||||
let query = supabase
|
let query = supabase
|
||||||
@@ -213,13 +223,16 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
|
|
||||||
if (submissionsError) throw submissionsError;
|
if (submissionsError) throw submissionsError;
|
||||||
|
|
||||||
// Get entity data for photo submissions
|
// Collect all entity IDs by type for batch fetching
|
||||||
let submissionsWithEntities = submissionsData || [];
|
const rideIds = new Set<string>();
|
||||||
for (const submission of submissionsWithEntities) {
|
const parkIds = new Set<string>();
|
||||||
|
const companyIds = new Set<string>();
|
||||||
|
|
||||||
|
// First pass: collect all entity IDs
|
||||||
|
for (const submission of submissionsData || []) {
|
||||||
if (submission.submission_type === 'photo' && submission.content && typeof submission.content === 'object') {
|
if (submission.submission_type === 'photo' && submission.content && typeof submission.content === 'object') {
|
||||||
const contentObj = submission.content as any;
|
const contentObj = submission.content as any;
|
||||||
|
|
||||||
// Handle both old format (context as object) and new format (context as string)
|
|
||||||
let contextType = null;
|
let contextType = null;
|
||||||
let entityId = null;
|
let entityId = null;
|
||||||
let rideId = null;
|
let rideId = null;
|
||||||
@@ -227,12 +240,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
let companyId = null;
|
let companyId = null;
|
||||||
|
|
||||||
if (typeof contentObj.context === 'object' && contentObj.context !== null) {
|
if (typeof contentObj.context === 'object' && contentObj.context !== null) {
|
||||||
// OLD FORMAT: context is an object like {ride_id: "...", park_id: "..."}
|
|
||||||
rideId = contentObj.context.ride_id;
|
rideId = contentObj.context.ride_id;
|
||||||
parkId = contentObj.context.park_id;
|
parkId = contentObj.context.park_id;
|
||||||
contextType = rideId ? 'ride' : parkId ? 'park' : null;
|
contextType = rideId ? 'ride' : parkId ? 'park' : null;
|
||||||
} else if (typeof contentObj.context === 'string') {
|
} else if (typeof contentObj.context === 'string') {
|
||||||
// NEW FORMAT: context is a string, IDs are at top level
|
|
||||||
contextType = contentObj.context;
|
contextType = contentObj.context;
|
||||||
entityId = contentObj.entity_id;
|
entityId = contentObj.entity_id;
|
||||||
rideId = contentObj.ride_id;
|
rideId = contentObj.ride_id;
|
||||||
@@ -240,54 +251,115 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
companyId = contentObj.company_id;
|
companyId = contentObj.company_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine entity ID based on context type
|
|
||||||
if (!entityId) {
|
if (!entityId) {
|
||||||
if (contextType === 'ride') entityId = rideId;
|
if (contextType === 'ride') entityId = rideId;
|
||||||
else if (contextType === 'park') entityId = parkId;
|
else if (contextType === 'park') entityId = parkId;
|
||||||
else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType)) entityId = companyId;
|
else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType)) entityId = companyId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contextType === 'ride' && entityId) {
|
// Collect IDs by type
|
||||||
const { data: rideData } = await supabase
|
if (entityId) {
|
||||||
|
if (contextType === 'ride') rideIds.add(entityId);
|
||||||
|
else if (contextType === 'park') parkIds.add(entityId);
|
||||||
|
else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType)) companyIds.add(entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch fetch all entity data (3 queries instead of N queries)
|
||||||
|
const [ridesData, parksData, companiesData] = await Promise.all([
|
||||||
|
rideIds.size > 0 ? supabase
|
||||||
.from('rides')
|
.from('rides')
|
||||||
.select(`
|
.select(`
|
||||||
|
id,
|
||||||
name,
|
name,
|
||||||
parks:park_id (
|
parks:park_id (
|
||||||
name
|
name
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.eq('id', entityId)
|
.in('id', Array.from(rideIds))
|
||||||
.single();
|
.then(res => res.data || []) : Promise.resolve([]),
|
||||||
|
|
||||||
|
parkIds.size > 0 ? supabase
|
||||||
|
.from('parks')
|
||||||
|
.select('id, name')
|
||||||
|
.in('id', Array.from(parkIds))
|
||||||
|
.then(res => res.data || []) : Promise.resolve([]),
|
||||||
|
|
||||||
|
companyIds.size > 0 ? supabase
|
||||||
|
.from('companies')
|
||||||
|
.select('id, name, company_type')
|
||||||
|
.in('id', Array.from(companyIds))
|
||||||
|
.then(res => res.data || []) : Promise.resolve([])
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create lookup maps for O(1) access
|
||||||
|
const ridesMap = new Map(ridesData.map((r: any) => [r.id, r] as [string, any]));
|
||||||
|
const parksMap = new Map(parksData.map((p: any) => [p.id, p] as [string, any]));
|
||||||
|
const companiesMap = new Map(companiesData.map((c: any) => [c.id, c] as [string, any]));
|
||||||
|
|
||||||
|
// Second pass: attach entity data using maps
|
||||||
|
let submissionsWithEntities = (submissionsData || []).map(submission => {
|
||||||
|
if (submission.submission_type === 'photo' && submission.content && typeof submission.content === 'object') {
|
||||||
|
const contentObj = submission.content as any;
|
||||||
|
|
||||||
|
let contextType = null;
|
||||||
|
let entityId = null;
|
||||||
|
let rideId = null;
|
||||||
|
let parkId = null;
|
||||||
|
let companyId = null;
|
||||||
|
|
||||||
|
if (typeof contentObj.context === 'object' && contentObj.context !== null) {
|
||||||
|
rideId = contentObj.context.ride_id;
|
||||||
|
parkId = contentObj.context.park_id;
|
||||||
|
contextType = rideId ? 'ride' : parkId ? 'park' : null;
|
||||||
|
} else if (typeof contentObj.context === 'string') {
|
||||||
|
contextType = contentObj.context;
|
||||||
|
entityId = contentObj.entity_id;
|
||||||
|
rideId = contentObj.ride_id;
|
||||||
|
parkId = contentObj.park_id;
|
||||||
|
companyId = contentObj.company_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entityId) {
|
||||||
|
if (contextType === 'ride') entityId = rideId;
|
||||||
|
else if (contextType === 'park') entityId = parkId;
|
||||||
|
else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType)) entityId = companyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach entity data from maps
|
||||||
|
if (contextType === 'ride' && entityId) {
|
||||||
|
const rideData = ridesMap.get(entityId) as any;
|
||||||
if (rideData) {
|
if (rideData) {
|
||||||
(submission as any).entity_name = rideData.name;
|
return {
|
||||||
(submission as any).park_name = rideData.parks?.name;
|
...submission,
|
||||||
|
entity_name: rideData.name,
|
||||||
|
park_name: rideData.parks?.name
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} else if (contextType === 'park' && entityId) {
|
} else if (contextType === 'park' && entityId) {
|
||||||
const { data: parkData } = await supabase
|
const parkData = parksMap.get(entityId) as any;
|
||||||
.from('parks')
|
|
||||||
.select('name')
|
|
||||||
.eq('id', entityId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (parkData) {
|
if (parkData) {
|
||||||
(submission as any).entity_name = parkData.name;
|
return {
|
||||||
|
...submission,
|
||||||
|
entity_name: parkData.name
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType) && entityId) {
|
} else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType) && entityId) {
|
||||||
const { data: companyData } = await supabase
|
const companyData = companiesMap.get(entityId) as any;
|
||||||
.from('companies')
|
|
||||||
.select('name, company_type')
|
|
||||||
.eq('id', entityId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (companyData) {
|
if (companyData) {
|
||||||
(submission as any).entity_name = companyData.name;
|
return {
|
||||||
(submission as any).company_type = companyData.company_type;
|
...submission,
|
||||||
}
|
entity_name: companyData.name,
|
||||||
|
company_type: companyData.company_type
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return submission;
|
||||||
|
});
|
||||||
|
|
||||||
submissions = submissionsWithEntities;
|
submissions = submissionsWithEntities;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +443,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
// Use smart merging for silent refreshes if strategy is 'merge'
|
// Use smart merging for silent refreshes if strategy is 'merge'
|
||||||
if (silent && refreshStrategy === 'merge') {
|
if (silent && refreshStrategy === 'merge') {
|
||||||
const mergeResult = smartMergeArray(items, formattedItems, {
|
const mergeResult = smartMergeArray(items, formattedItems, {
|
||||||
compareFields: ['status', 'reviewed_at', 'reviewed_by', 'reviewer_notes', 'content', 'submission_type'],
|
compareFields: ['status', 'reviewed_at', 'reviewed_by', 'reviewer_notes', 'submission_type', 'entity_name', 'park_name'],
|
||||||
preserveOrder: true,
|
preserveOrder: true,
|
||||||
addToTop: true,
|
addToTop: true,
|
||||||
});
|
});
|
||||||
@@ -437,6 +509,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
setIsInitialLoad(false);
|
setIsInitialLoad(false);
|
||||||
}
|
}
|
||||||
|
setIsRefreshing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* Smart State Update Utility
|
* Utility functions for performing "smart" updates on arrays
|
||||||
*
|
|
||||||
* Provides intelligent array diffing and merging to prevent
|
|
||||||
* unnecessary re-renders and preserve user interaction state.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a stable content hash for comparison
|
||||||
|
*/
|
||||||
|
function hashContent(obj: any): string {
|
||||||
|
if (obj === null || obj === undefined) return 'null';
|
||||||
|
if (typeof obj !== 'object') return String(obj);
|
||||||
|
|
||||||
|
// Sort keys for stable hashing
|
||||||
|
const sortedKeys = Object.keys(obj).sort();
|
||||||
|
const parts = sortedKeys.map(key => `${key}:${hashContent(obj[key])}`);
|
||||||
|
return parts.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if content has meaningfully changed (not just object reference)
|
||||||
|
*/
|
||||||
|
function hasContentChanged(current: any, next: any): boolean {
|
||||||
|
return hashContent(current) !== hashContent(next);
|
||||||
|
}
|
||||||
|
|
||||||
export interface SmartMergeOptions<T> {
|
export interface SmartMergeOptions<T> {
|
||||||
compareFields?: (keyof T)[];
|
compareFields?: (keyof T)[];
|
||||||
preserveOrder?: boolean;
|
preserveOrder?: boolean;
|
||||||
@@ -130,9 +147,8 @@ function hasItemChanged<T>(
|
|||||||
compareFields?: (keyof T)[]
|
compareFields?: (keyof T)[]
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!compareFields || compareFields.length === 0) {
|
if (!compareFields || compareFields.length === 0) {
|
||||||
// If no fields specified, assume no change (too sensitive to compare everything)
|
// If no fields specified, use content hash comparison
|
||||||
// This prevents false positives from object reference changes
|
return hasContentChanged(currentItem, newItem);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare only specified fields
|
// Compare only specified fields
|
||||||
@@ -140,9 +156,9 @@ function hasItemChanged<T>(
|
|||||||
const currentValue = currentItem[field];
|
const currentValue = currentItem[field];
|
||||||
const newValue = newItem[field];
|
const newValue = newItem[field];
|
||||||
|
|
||||||
// Handle nested objects/arrays
|
// Handle nested objects/arrays with content hash
|
||||||
if (typeof currentValue === 'object' && typeof newValue === 'object') {
|
if (typeof currentValue === 'object' && typeof newValue === 'object') {
|
||||||
if (JSON.stringify(currentValue) !== JSON.stringify(newValue)) {
|
if (hasContentChanged(currentValue, newValue)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else if (currentValue !== newValue) {
|
} else if (currentValue !== newValue) {
|
||||||
|
|||||||
Reference in New Issue
Block a user