Skip to content

Commit 65ed4ca

Browse files
committed
refactor: enhance bulk actions and release management with theme integration and improved delete handling
Signed-off-by: Quentin Lafond <[email protected]>
1 parent 9c2ae5e commit 65ed4ca

File tree

6 files changed

+191
-36
lines changed

6 files changed

+191
-36
lines changed

app-catalog/src/components/releases/BulkActionsToolbar.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Icon } from '@iconify/react';
22
import { Box, IconButton, Typography } from '@mui/material';
3+
import { useTheme } from '@mui/material';
34

45
interface BulkActionsToolbarProps {
56
selectedCount: number;
@@ -10,6 +11,7 @@ export function BulkActionsToolbar({ selectedCount, onDelete }: BulkActionsToolb
1011
if (selectedCount === 0) {
1112
return null;
1213
}
14+
const theme = useTheme();
1315

1416
return (
1517
<Box
@@ -18,7 +20,7 @@ export function BulkActionsToolbar({ selectedCount, onDelete }: BulkActionsToolb
1820
alignItems: 'center',
1921
justifyContent: 'space-between',
2022
padding: '8px 16px',
21-
backgroundColor: 'rgba(0, 0, 0, 0.04)',
23+
backgroundColor: theme.palette.action.hover,
2224
borderRadius: '4px',
2325
marginBottom: '16px',
2426
}}

app-catalog/src/components/releases/BulkDeleteDialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function BulkDeleteDialog({
2424
</DialogContentText>
2525
</DialogContent>
2626
<DialogActions>
27-
<Button onClick={onClose}>{isDeleting ? 'Close' : 'No'}</Button>
27+
<Button disabled={isDeleting} onClick={onClose}>{isDeleting ? 'Close' : 'No'}</Button>
2828
<Button disabled={isDeleting} onClick={onConfirm} color="error">
2929
{isDeleting ? 'Deleting...' : 'Yes'}
3030
</Button>

app-catalog/src/components/releases/List.tsx

Lines changed: 159 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,38 @@ interface ReleasesResponse {
6060
}
6161

6262
const DELETE_STATUS_POLLING_INTERVAL = 1000;
63+
const DELETE_STATUS_MAX_RETRIES = 60;
64+
const RELEASE_KEY_DELIMITER = '|:|';
6365

6466
interface ReleaseListProps {
6567
fetchReleases?: () => Promise<ReleasesResponse>;
6668
}
6769

70+
/**
71+
* Creates a unique key for a release by combining namespace and name.
72+
* Uses a delimiter that is unlikely to appear in Kubernetes resource names.
73+
* @param namespace - The namespace of the release
74+
* @param name - The name of the release
75+
* @returns A unique key string
76+
*/
77+
function createReleaseKey(namespace: string, name: string): string {
78+
return `${namespace}${RELEASE_KEY_DELIMITER}${name}`;
79+
}
80+
81+
/**
82+
* Parses a release key back into namespace and name.
83+
* @param key - The release key to parse
84+
* @returns Object with namespace and name, or null if invalid
85+
*/
86+
function parseReleaseKey(key: string): { namespace: string; name: string } | null {
87+
const parts = key.split(RELEASE_KEY_DELIMITER);
88+
if (parts.length !== 2) {
89+
console.error(`Invalid release key format: ${key}`);
90+
return null;
91+
}
92+
return { namespace: parts[0], name: parts[1] };
93+
}
94+
6895
/**
6996
* @returns formatted version string
7097
* @param v - version string
@@ -119,6 +146,7 @@ export default function ReleaseList({ fetchReleases = listReleases }: ReleaseLis
119146
console.error('Failed to fetch releases:', error);
120147
enqueueSnackbar('Failed to load releases', { variant: 'error' });
121148
setReleases([]);
149+
setLatestMap({});
122150
});
123151
}, [update, fetchReleases, enqueueSnackbar]);
124152

@@ -195,24 +223,42 @@ export default function ReleaseList({ fetchReleases = listReleases }: ReleaseLis
195223
}, []);
196224

197225
const checkDeleteReleaseStatus = useCallback(
198-
(name: string, namespace: string) => {
226+
(name: string, namespace: string, retryCount = 0) => {
227+
if (retryCount >= DELETE_STATUS_MAX_RETRIES) {
228+
enqueueSnackbar(`Delete status check timeout for ${name}`, { variant: 'error' });
229+
setIsDeleting(false);
230+
if (deleteStatusTimeoutRef.current) {
231+
clearTimeout(deleteStatusTimeoutRef.current);
232+
deleteStatusTimeoutRef.current = null;
233+
}
234+
return;
235+
}
236+
199237
getActionStatus(name, 'uninstall')
200238
.then(response => {
201239
if (response.status === 'processing') {
202240
deleteStatusTimeoutRef.current = setTimeout(
203-
() => checkDeleteReleaseStatus(name, namespace),
241+
() => checkDeleteReleaseStatus(name, namespace, retryCount + 1),
204242
DELETE_STATUS_POLLING_INTERVAL
205243
);
206244
} else if (response.status !== 'success') {
207245
enqueueSnackbar(`Failed to delete release ${name}: ${response.message}`, {
208246
variant: 'error',
209247
});
210248
setIsDeleting(false);
249+
if (deleteStatusTimeoutRef.current) {
250+
clearTimeout(deleteStatusTimeoutRef.current);
251+
deleteStatusTimeoutRef.current = null;
252+
}
211253
} else {
212254
enqueueSnackbar(`Successfully deleted release ${name}`, { variant: 'success' });
213255
setOpenDeleteAlert(false);
214256
setIsDeleting(false);
215257
setUpdate(prev => !prev);
258+
if (deleteStatusTimeoutRef.current) {
259+
clearTimeout(deleteStatusTimeoutRef.current);
260+
deleteStatusTimeoutRef.current = null;
261+
}
216262
}
217263
})
218264
.catch(error => {
@@ -221,21 +267,28 @@ export default function ReleaseList({ fetchReleases = listReleases }: ReleaseLis
221267
setIsDeleting(false);
222268
if (deleteStatusTimeoutRef.current) {
223269
clearTimeout(deleteStatusTimeoutRef.current);
270+
deleteStatusTimeoutRef.current = null;
224271
}
225272
});
226273
},
227-
[enqueueSnackbar]
274+
[enqueueSnackbar, update, setUpdate]
228275
);
229276

230277
const handleConfirmDelete = useCallback(() => {
231278
if (selectedRelease) {
279+
// Clear any existing timeout to prevent race conditions
280+
if (deleteStatusTimeoutRef.current) {
281+
clearTimeout(deleteStatusTimeoutRef.current);
282+
deleteStatusTimeoutRef.current = null;
283+
}
284+
232285
deleteRelease(selectedRelease.namespace, selectedRelease.name)
233286
.then(() => {
234287
setIsDeleting(true);
235288
enqueueSnackbar(`Delete request for release ${selectedRelease.name} accepted`, {
236289
variant: 'info',
237290
});
238-
setOpenDeleteAlert(false);
291+
// Keep dialog open while polling - it will close on success in checkDeleteReleaseStatus
239292
checkDeleteReleaseStatus(selectedRelease.name, selectedRelease.namespace);
240293
})
241294
.catch(error => {
@@ -267,7 +320,7 @@ export default function ReleaseList({ fetchReleases = listReleases }: ReleaseLis
267320
}, [selectedRelease, revertVersion, enqueueSnackbar]);
268321

269322
const handleSelectRelease = useCallback((releaseName: string, namespace: string) => {
270-
const key = `${namespace}/${releaseName}`;
323+
const key = createReleaseKey(namespace, releaseName);
271324
setSelectedReleases(prev => {
272325
const newSet = new Set(prev);
273326
if (newSet.has(key)) {
@@ -286,42 +339,123 @@ export default function ReleaseList({ fetchReleases = listReleases }: ReleaseLis
286339
if (prev.size === filteredReleases.length) {
287340
return new Set();
288341
} else {
289-
const allKeys = filteredReleases.map(r => `${r.namespace}/${r.name}`);
342+
const allKeys = filteredReleases.map(r => createReleaseKey(r.namespace, r.name));
290343
return new Set(allKeys);
291344
}
292345
});
293346
}, [filteredReleases]);
294347

348+
const checkBulkDeleteComplete = useCallback(
349+
(deletedKeys: string[], retryCount = 0) => {
350+
if (retryCount >= DELETE_STATUS_MAX_RETRIES) {
351+
enqueueSnackbar('Bulk delete verification timeout, please refresh the page', {
352+
variant: 'warning',
353+
});
354+
setIsBulkDeleting(false);
355+
setUpdate(prev => !prev);
356+
return;
357+
}
358+
359+
// If releases is null or empty, consider all deletions complete
360+
if (!releases || releases.length === 0) {
361+
setIsBulkDeleting(false);
362+
return;
363+
}
364+
365+
// Check if any deleted releases still exist in the list
366+
const stillExist = deletedKeys.some(key => {
367+
const parsed = parseReleaseKey(key);
368+
if (!parsed) return false;
369+
const release = releases.find(
370+
r => r.namespace === parsed.namespace && r.name === parsed.name
371+
);
372+
return release !== undefined;
373+
});
374+
375+
if (stillExist) {
376+
setTimeout(() => {
377+
setUpdate(prev => !prev); // Trigger fetch
378+
setTimeout(() => checkBulkDeleteComplete(deletedKeys, retryCount + 1), 500);
379+
}, DELETE_STATUS_POLLING_INTERVAL);
380+
} else {
381+
setIsBulkDeleting(false);
382+
}
383+
},
384+
[releases, enqueueSnackbar]
385+
);
386+
295387
const handleBulkDelete = useCallback(() => {
296388
setOpenBulkDeleteAlert(true);
297389
}, []);
298390

299391
const handleConfirmBulkDelete = useCallback(() => {
300-
if (selectedReleases.size === 0 || !releases) return;
392+
if (selectedReleases.size === 0) return;
301393

302394
setIsBulkDeleting(true);
303-
const releasesToDelete = Array.from(selectedReleases).map(key => {
304-
const [namespace, name] = key.split('/');
305-
return { namespace, name };
306-
});
395+
const releasesToDelete = Array.from(selectedReleases)
396+
.map(key => {
397+
const parsed = parseReleaseKey(key);
398+
if (!parsed) return null;
399+
return { namespace: parsed.namespace, name: parsed.name, key };
400+
})
401+
.filter((item): item is { namespace: string; name: string; key: string } => item !== null);
402+
403+
if (releasesToDelete.length === 0) {
404+
enqueueSnackbar('No valid releases to delete', { variant: 'error' });
405+
setIsBulkDeleting(false);
406+
setOpenBulkDeleteAlert(false);
407+
return;
408+
}
409+
410+
Promise.allSettled(
411+
releasesToDelete.map(({ namespace, name }) => deleteRelease(namespace, name))
412+
)
413+
.then(results => {
414+
const succeeded = results.filter(r => r.status === 'fulfilled').length;
415+
const failed = results.filter(r => r.status === 'rejected').length;
416+
417+
const successfullyDeletedKeys = releasesToDelete
418+
.filter((_, index) => results[index].status === 'fulfilled')
419+
.map(item => item.key);
420+
421+
if (failed === 0) {
422+
enqueueSnackbar(
423+
`Successfully initiated deletion of ${succeeded} release(s)`,
424+
{ variant: 'info' }
425+
);
426+
} else if (succeeded === 0) {
427+
enqueueSnackbar(`Failed to delete all ${failed} release(s)`, { variant: 'error' });
428+
setIsBulkDeleting(false);
429+
setOpenBulkDeleteAlert(false);
430+
setSelectedReleases(new Set());
431+
return;
432+
} else {
433+
enqueueSnackbar(
434+
`Initiated deletion of ${succeeded} release(s), failed ${failed}`,
435+
{ variant: 'warning' }
436+
);
437+
}
307438

308-
Promise.all(releasesToDelete.map(({ namespace, name }) => deleteRelease(namespace, name)))
309-
.then(() => {
310-
enqueueSnackbar(
311-
`Successfully initiated deletion of ${releasesToDelete.length} release(s)`,
312-
{ variant: 'info' }
313-
);
314439
setOpenBulkDeleteAlert(false);
315440
setSelectedReleases(new Set());
316-
setIsBulkDeleting(false);
317-
setUpdate(prev => !prev);
441+
442+
if (successfullyDeletedKeys.length > 0) {
443+
setUpdate(prev => !prev);
444+
setTimeout(
445+
() => checkBulkDeleteComplete(successfullyDeletedKeys),
446+
DELETE_STATUS_POLLING_INTERVAL
447+
);
448+
} else {
449+
setIsBulkDeleting(false);
450+
}
318451
})
319452
.catch(error => {
320-
console.error('Failed to delete releases:', error);
321-
enqueueSnackbar('Failed to delete some releases', { variant: 'error' });
453+
console.error('Unexpected error in bulk delete:', error);
454+
enqueueSnackbar('Unexpected error during bulk deletion', { variant: 'error' });
322455
setIsBulkDeleting(false);
456+
setOpenBulkDeleteAlert(false);
323457
});
324-
}, [selectedReleases, releases, enqueueSnackbar]);
458+
}, [selectedReleases, enqueueSnackbar, checkBulkDeleteComplete]);
325459

326460
return (
327461
<>
@@ -390,17 +524,19 @@ export default function ReleaseList({ fetchReleases = listReleases }: ReleaseLis
390524
: false
391525
}
392526
onChange={handleSelectAll}
527+
aria-label="Select all releases"
393528
/>
394529
),
395530
gridTemplate: 'min-content',
396531
getter: (release: Release) => {
397-
const key = `${release.namespace}/${release.name}`;
532+
const key = createReleaseKey(release.namespace, release.name);
398533
const isSelected = selectedReleases.has(key);
399534
return (
400535
<Checkbox
401536
size="small"
402537
checked={isSelected}
403538
onChange={() => handleSelectRelease(release.name, release.namespace)}
539+
aria-label={`Select ${release.name}`}
404540
/>
405541
);
406542
},

app-catalog/src/components/releases/ReleaseActionsMenu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export function ReleaseActionsMenu({
5050

5151
return (
5252
<>
53-
<IconButton onClick={handleMenuClick} size="small">
53+
<IconButton onClick={handleMenuClick} size="small" aria-label="Release actions">
5454
<Icon icon="mdi:dots-vertical" />
5555
</IconButton>
5656
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>

app-catalog/src/components/releases/ReleaseFilters.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export function ReleaseFilters({
3333
clearTimeout(timeoutRef.current);
3434
}
3535
};
36-
}, [inputValue, onNameFilterChange]);
36+
}, [inputValue]);
3737

3838
return (
3939
<Box display="flex" gap={2} alignItems="center">

0 commit comments

Comments
 (0)