Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
10 changes: 9 additions & 1 deletion public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,15 @@
"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.",
"deleteStarted": "Deleting {{resourceName}} initialized",
"actionColumnHeader": " "
},
"ProvidersConfig": {
"tableHeaderProvider": "Provider",
Expand Down
135 changes: 103 additions & 32 deletions src/components/ControlPlane/ManagedResources.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,28 @@ import {
Toolbar,
ToolbarSpacer,
} from '@ui5/webcomponents-react';
import { useApiResource } from '../../lib/api/useApiResource';
import { useApiResource, useApiResourceMutation } 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 { useToast } from '../../context/ToastContext.tsx';
import {
DeleteManagedResourceType,
DeleteMCPManagedResource,
PatchResourceForForceDeletion,
PatchResourceForForceDeletionBody,
} from '../../lib/api/types/crate/deleteResource';
import { useResourcePluralNames } from '../../hooks/useResourcePluralNames';

interface CellData<T> {
cell: {
Expand All @@ -46,15 +55,32 @@ type ResourceRow = {

export function ManagedResources() {
const { t } = useTranslation();
const toast = useToast();
const [pendingDeleteItem, setPendingDeleteItem] = useState<ManagedResourceItem | null>(null);

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

const { getPluralKind, isLoading: isLoadingPluralNames, error: pluralNamesError } = useResourcePluralNames();

const resourceName = pendingDeleteItem?.metadata?.name ?? '';
const apiVersion = pendingDeleteItem?.apiVersion ?? '';
const pluralKind = pendingDeleteItem ? getPluralKind(pendingDeleteItem.kind) : '';
const namespace = pendingDeleteItem?.metadata?.namespace ?? '';

const { trigger: deleteTrigger } = useApiResourceMutation<DeleteManagedResourceType>(
DeleteMCPManagedResource(apiVersion, pluralKind, resourceName, namespace),
);

const { trigger: patchTrigger } = useApiResourceMutation<DeleteManagedResourceType>(
PatchResourceForForceDeletion(apiVersion, pluralKind, resourceName, namespace),
);

const columns: AnalyticalTableColumnDefinition[] = useMemo(
() => [
{
Expand Down Expand Up @@ -109,10 +135,24 @@ 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: t('ManagedResources.actionColumnHeader'),
hAlign: 'Center',
width: 60,
disableFilters: true,
Cell: (cellData: CellData<ResourceRow>) => {
const item = cellData.cell.row.original?.item as ManagedResourceItem;

return cellData.cell.row.original?.item ? (
<RowActionsMenu item={item} onOpen={openDeleteDialog} />
) : undefined;
},
},
],
[t],
Expand Down Expand Up @@ -141,11 +181,34 @@ export function ManagedResources() {
}),
) ?? [];

const openDeleteDialog = (item: ManagedResourceItem) => {
setPendingDeleteItem(item);
};

const handleDeletionConfirmed = async (item: ManagedResourceItem, force: boolean) => {
toast.show(t('ManagedResources.deleteStarted', { resourceName: item.metadata.name }));

try {
await deleteTrigger();

if (force) {
await patchTrigger(PatchResourceForForceDeletionBody);
}
} catch (_) {
// Ignore errors - will be handled by the mutation hook
} finally {
setPendingDeleteItem(null);
}
};

const combinedError = error || pluralNamesError;
const combinedLoading = isLoading || isLoadingPluralNames;

return (
<>
{error && <IllustratedError details={error.message} />}
{combinedError && <IllustratedError details={combinedError.message} />}

{!error && (
{!combinedError && (
<Panel
fixed
header={
Expand All @@ -155,28 +218,36 @@ export function ManagedResources() {
</Toolbar>
}
>
<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={combinedLoading}
filterable
retainColumnWidth
reactTableOptions={{
autoResetHiddenColumns: false,
autoResetPage: false,
autoResetExpanded: false,
autoResetGroupBy: false,
autoResetSelectedRows: false,
autoResetSortBy: false,
autoResetFilters: false,
autoResetRowState: false,
autoResetResize: false,
}}
/>

<ManagedResourceDeleteDialog
open={!!pendingDeleteItem}
item={pendingDeleteItem}
onClose={() => {}}
onDeletionConfirmed={handleDeletionConfirmed}
/>
</>
</Panel>
)}
</>
Expand Down
44 changes: 44 additions & 0 deletions src/components/ControlPlane/ManagedResourcesActionMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { FC, useRef, useState } from 'react';
import { Button, Menu, MenuItem, MenuDomRef } from '@ui5/webcomponents-react';
import { useTranslation } from 'react-i18next';
import { ManagedResourceItem } from '../../lib/shared/types';
import type { ButtonClickEventDetail } from '@ui5/webcomponents/dist/Button.js';
import type { Ui5CustomEvent, ButtonDomRef } from '@ui5/webcomponents-react';

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

export const RowActionsMenu: FC<RowActionsMenuProps> = ({ item, onOpen }) => {
const { t } = useTranslation();
const popoverRef = useRef<MenuDomRef>(null);
const [open, setOpen] = useState(false);

const handleOpenerClick = (e: Ui5CustomEvent<ButtonDomRef, ButtonClickEventDetail>) => {
if (popoverRef.current && e.currentTarget) {
popoverRef.current.opener = e.currentTarget as unknown as HTMLElement;
setOpen((prev) => !prev);
}
};

return (
<>
<Button icon="overflow" design="Transparent" onClick={handleOpenerClick} />
<Menu
ref={popoverRef}
open={open}
onItemClick={(event) => {
const element = event.detail.item as HTMLElement;
const action = element.dataset.action;
if (action === 'delete') {
onOpen(item);
}
setOpen(false);
}}
>
<MenuItem text={t('ManagedResources.deleteAction')} icon="delete" data-action="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