Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@
"tableHeaderName": "Name",
"tableHeaderCreated": "Created",
"tableHeaderSynced": "Synced",
"tableHeaderReady": "Ready"
"tableHeaderReady": "Ready",
"tableHeaderDelete": "Delete",
"deleteAction": "Delete resource",
"deleteDialogTitle": "Delete resource",
"advancedOptions": "Advanced options",
"forceDeletion": "Force deletion",
"forceWarningLine": "Force deletion removes finalizers. Related resources may not be deleted and cleanup may be skipped."
},
"ProvidersConfig": {
"headerProviderConfigs": "Provider Configs",
Expand Down
4 changes: 4 additions & 0 deletions src/components/ControlPlane/ManagedResources.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.deletingRow {
opacity: 0.5;
pointer-events: none;
}
111 changes: 80 additions & 31 deletions src/components/ControlPlane/ManagedResources.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,23 @@ import {
AnalyticalTableScaleWidthMode,
Title,
} from '@ui5/webcomponents-react';
import { useApiResource } from '../../lib/api/useApiResource';
import { useApiResource, useCRDItemsMapping } from '../../lib/api/useApiResource';
import { ManagedResourcesRequest } from '../../lib/api/types/crossplane/listManagedResources';
import { formatDateAsTimeAgo } from '../../utils/i18n/timeAgo';
import IllustratedError from '../Shared/IllustratedError';
import '@ui5/webcomponents-icons/dist/sys-enter-2';
import '@ui5/webcomponents-icons/dist/sys-cancel-2';
import { resourcesInterval } from '../../lib/shared/constants';

import { YamlViewButton } from '../Yaml/YamlViewButton.tsx';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import StatusFilter from '../Shared/StatusFilter/StatusFilter.tsx';
import { ResourceStatusCell } from '../Shared/ResourceStatusCell.tsx';
import { Resource } from '../../utils/removeManagedFieldsAndFilterData.ts';
import { ManagedResourceItem } from '../../lib/shared/types.ts';
import { ManagedResourceDeleteDialog } from '../Dialogs/ManagedResourceDeleteDialog.tsx';
import { RowActionsMenu } from './ManagedResourcesActionMenu.tsx';
import styles from './ManagedResources.module.css';

const getItemKey = (item: ManagedResourceItem): string => `${item.kind}-${item.metadata.name}`;

interface CellData<T> {
cell: {
Expand All @@ -43,13 +47,30 @@ type ResourceRow = {

export function ManagedResources() {
const { t } = useTranslation();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<ManagedResourceItem | null>(null);
const [deletingItems, setDeletingItems] = useState<Set<string>>(new Set());

const {
data: managedResources,
error,
isLoading,
} = useApiResource(ManagedResourcesRequest, {
refreshInterval: resourcesInterval, // Resources are quite expensive to fetch, so we refresh every 30 seconds
refreshInterval: resourcesInterval,
});

const openDeleteDialog = (item: ManagedResourceItem) => {
setSelectedItem(item);
setDeleteDialogOpen(true);
};

const handleDeleteStart = (item: ManagedResourceItem) => {
const itemKey = getItemKey(item);
setDeletingItems((prev) => new Set(prev.add(itemKey)));
};

const { data: kindMapping } = useCRDItemsMapping({
refreshInterval: resourcesInterval,
});

const columns: AnalyticalTableColumnDefinition[] = useMemo(
Expand Down Expand Up @@ -106,20 +127,39 @@ export function ManagedResources() {
width: 75,
accessor: 'yaml',
disableFilters: true,
Cell: (cellData: CellData<ResourceRow>) =>
cellData.cell.row.original?.item ? (
Cell: (cellData: CellData<ResourceRow>) => {
return cellData.cell.row.original?.item ? (
<YamlViewButton variant="resource" resource={cellData.cell.row.original?.item as Resource} />
) : undefined,
) : undefined;
},
},
{
Header: ' ',
hAlign: 'Center',
width: 60,
disableFilters: true,
Cell: (cellData: CellData<ResourceRow>) => {
const item = cellData.cell.row.original?.item as ManagedResourceItem;
const itemKey = item ? getItemKey(item) : '';
const isDeleting = deletingItems.has(itemKey);

return cellData.cell.row.original?.item ? (
<RowActionsMenu item={item} isDeleting={isDeleting} onOpen={openDeleteDialog} />
) : undefined;
},
},
],
[t],
[t, deletingItems],
);

const rows: ResourceRow[] =
managedResources
?.filter((managedResource) => managedResource.items)
.flatMap((managedResource) =>
managedResource.items?.map((item) => {
const itemKey = getItemKey(item);
const isDeleting = deletingItems.has(itemKey);

const conditionSynced = item.status?.conditions?.find((condition) => condition.type === 'Synced');
const conditionReady = item.status?.conditions?.find((condition) => condition.type === 'Ready');

Expand All @@ -134,6 +174,7 @@ export function ManagedResources() {
item: item,
conditionSyncedMessage: conditionSynced?.message ?? conditionSynced?.reason ?? '',
conditionReadyMessage: conditionReady?.message ?? conditionReady?.reason ?? '',
className: isDeleting ? styles.deletingRow : undefined,
};
}),
) ?? [];
Expand All @@ -145,28 +186,36 @@ export function ManagedResources() {
{error && <IllustratedError details={error.message} />}

{!error && (
<AnalyticalTable
columns={columns}
data={rows}
minRows={1}
groupBy={['kind']}
scaleWidthMode={AnalyticalTableScaleWidthMode.Smart}
loading={isLoading}
filterable
// Prevent the table from resetting when the data changes
retainColumnWidth
reactTableOptions={{
autoResetHiddenColumns: false,
autoResetPage: false,
autoResetExpanded: false,
autoResetGroupBy: false,
autoResetSelectedRows: false,
autoResetSortBy: false,
autoResetFilters: false,
autoResetRowState: false,
autoResetResize: false,
}}
/>
<>
<AnalyticalTable
columns={columns}
data={rows}
minRows={1}
groupBy={['kind']}
scaleWidthMode={AnalyticalTableScaleWidthMode.Smart}
loading={isLoading}
filterable
retainColumnWidth
reactTableOptions={{
autoResetHiddenColumns: false,
autoResetPage: false,
autoResetExpanded: false,
autoResetGroupBy: false,
autoResetSelectedRows: false,
autoResetSortBy: false,
autoResetFilters: false,
autoResetRowState: false,
autoResetResize: false,
}}
/>
<ManagedResourceDeleteDialog
kindMapping={kindMapping}
open={deleteDialogOpen}
item={selectedItem}
onClose={() => setDeleteDialogOpen(false)}
onDeleteStart={handleDeleteStart}
/>
</>
)}
</>
);
Expand Down
29 changes: 29 additions & 0 deletions src/components/ControlPlane/ManagedResourcesActionMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { FC, useRef } from 'react';
import { Button, Menu, MenuItem, MenuDomRef } from '@ui5/webcomponents-react';
import { useTranslation } from 'react-i18next';
import { ManagedResourceItem } from '../../lib/shared/types';

interface RowActionsMenuProps {
item: ManagedResourceItem;
onOpen: (item: ManagedResourceItem) => void;
isDeleting: boolean;
}

export const RowActionsMenu: FC<RowActionsMenuProps> = ({ item, onOpen, isDeleting }) => {
const { t } = useTranslation();
const popoverRef = useRef<MenuDomRef>(null);

return (
<>
<Button icon="overflow" icon-end disabled={isDeleting} onClick={() => onOpen(item)} />
<Menu
ref={popoverRef}
onItemClick={() => {
onOpen(item);
}}
>
<MenuItem text={t('ManagedResources.deleteAction')} icon="delete" />
</Menu>
</>
);
};
10 changes: 5 additions & 5 deletions src/components/Dialogs/DeleteConfirmationDialog.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,15 @@ describe('DeleteConfirmationDialog', () => {
it('should enable Delete button when correct resource name is typed', () => {
mountDialog();

cy.get('ui5-input[id*="mcp-name-input"]').find(' input[id*="inner"]').type('test-resource', { force: true });
cy.get('ui5-input[id*="delete-confirm-input"]').find(' input[id*="inner"]').type('test-resource', { force: true });

cy.get('ui5-button').contains('Delete').should('not.have.attr', 'disabled');
});

it('should keep Delete button disabled when incorrect name is typed', () => {
mountDialog();

cy.get('ui5-input[id*="mcp-name-input"]').find(' input[id*="inner"]').type('wrong-name', { force: true });
cy.get('ui5-input[id*="delete-confirm-input"]').find(' input[id*="inner"]').type('wrong-name', { force: true });

cy.get('ui5-button').contains('Delete').should('have.attr', 'disabled');
});
Expand All @@ -68,7 +68,7 @@ describe('DeleteConfirmationDialog', () => {
it('should call onDeletionConfirmed and setIsOpen when Delete is confirmed', () => {
mountDialog();

cy.get('ui5-input[id*="mcp-name-input"]').find(' input[id*="inner"]').type('test-resource');
cy.get('ui5-input[id*="delete-confirm-input"]').find(' input[id*="inner"]').type('test-resource');

cy.get('ui5-button').contains('Delete').click();

Expand All @@ -82,15 +82,15 @@ describe('DeleteConfirmationDialog', () => {
mountDialog();

// Type something
cy.get('ui5-input[id*="mcp-name-input"]').find(' input[id*="inner"]').type('test-resource', { force: true });
cy.get('ui5-input[id*="delete-confirm-input"]').find(' input[id*="inner"]').type('test-resource', { force: true });

// Close dialog
cy.get('ui5-button').contains('Cancel').click();

// Reopen dialog
mountDialog();

cy.get('ui5-input[id*="mcp-name-input"]').find(' input[id*="inner"]').should('have.value', '');
cy.get('ui5-input[id*="delete-confirm-input"]').find(' input[id*="inner"]').should('have.value', '');
});

it('should display correct resource name in all labels', () => {
Expand Down
33 changes: 10 additions & 23 deletions src/components/Dialogs/DeleteConfirmationDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { ReactNode, useState } from 'react';
import { Bar, Button, Dialog, Input, InputDomRef, Label } from '@ui5/webcomponents-react';
import { Bar, Button, Dialog, InputDomRef } from '@ui5/webcomponents-react';
import ButtonDesign from '@ui5/webcomponents/dist/types/ButtonDesign.js';
import { Trans, useTranslation } from 'react-i18next';
import { useTranslation } from 'react-i18next';

import styles from './DeleteConfirmationDialog.module.css';
import type { Ui5CustomEvent } from '@ui5/webcomponents-react-base';
import { DeleteConfirmationForm } from './DeleteConfirmationForm.tsx';

interface DeleteConfirmationDialogProps {
isOpen: boolean;
Expand Down Expand Up @@ -67,26 +67,13 @@ export function DeleteConfirmationDialog({
/>
}
>
<div className={styles.dialogContent}>
<span className={styles.message}>
<Trans
i18nKey="DeleteConfirmationDialog.deleteMessage"
values={{ resourceName }}
components={{
b: <b />,
}}
/>
</span>
<Label className={styles.confirmLabel} for="mcp-name-input">
{t('DeleteConfirmationDialog.deleteConfirmation', { resourceName })}
</Label>
<Input
id="mcp-name-input"
value={confirmationText}
className={styles.confirmationInput}
onInput={onConfirmationInputChange}
/>
</div>
<DeleteConfirmationForm
resourceName={resourceName}
confirmationText={confirmationText}
deleteMessageKey="DeleteConfirmationDialog.deleteMessage"
deleteConfirmationLabel={t('DeleteConfirmationDialog.deleteConfirmation', { resourceName })}
onConfirmationInputChange={onConfirmationInputChange}
/>
</Dialog>
);
}
36 changes: 36 additions & 0 deletions src/components/Dialogs/DeleteConfirmationForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Trans } from 'react-i18next';
import { Input, InputDomRef, Label, Ui5CustomEvent } from '@ui5/webcomponents-react';
import styles from './DeleteConfirmationForm.module.css';

interface DeleteConfirmationFormProps {
resourceName: string;
confirmationText: string;
onConfirmationInputChange: (event: Ui5CustomEvent<InputDomRef>) => void;
deleteMessageKey: string;
deleteConfirmationLabel: string;
}

export function DeleteConfirmationForm({
resourceName,
confirmationText,
onConfirmationInputChange,
deleteMessageKey,
deleteConfirmationLabel,
}: DeleteConfirmationFormProps) {
return (
<div className={styles.dialogContent}>
<span className={styles.message}>
<Trans i18nKey={deleteMessageKey} values={{ resourceName }} components={{ b: <b /> }} />
</span>
<Label className={styles.confirmLabel} for="delete-confirm-input">
{deleteConfirmationLabel}
</Label>
<Input
id="delete-confirm-input"
value={confirmationText}
className={styles.confirmationInput}
onInput={onConfirmationInputChange}
/>
</div>
);
}
15 changes: 15 additions & 0 deletions src/components/Dialogs/ManagedResourceDeleteDialog.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.dialog {
width: 520px;
}

.content {
gap: 0.75rem;
}

.advancedOptionsContent {
gap: 0.5rem;
}

.actions {
gap: 0.5rem;
}
Loading
Loading