Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
84 changes: 68 additions & 16 deletions packages/backend.ai-ui/src/components/Table/BAINameActionCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import BAIButton from '../BAIButton';
import BAILink from '../BAILink';
import BAIText from '../BAIText';
import { MoreOutlined } from '@ant-design/icons';
import { Dropdown, theme, Tooltip } from 'antd';
import type { MenuProps } from 'antd';
import { App, Dropdown, Popconfirm, theme, Tooltip } from 'antd';
import type { MenuProps, PopconfirmProps } from 'antd';
import { createStyles } from 'antd-style';
import React, { useEffect, useRef, useState, useTransition } from 'react';
import type { LinkProps } from 'react-router-dom';
Expand Down Expand Up @@ -38,6 +38,19 @@ export interface BAINameActionCellAction {
* - 'always': always shown only in the more menu
*/
showInMenu?: 'auto' | 'always';
/**
* Ant Design Popconfirm props to gate the action behind a confirmation
* popover. When set, the visible icon button is wrapped with `<Popconfirm>`
* and the confirm action should be wired via `popConfirm.onConfirm`.
*
* When the action overflows into the more menu, the menu item falls back
* to a `Modal.confirm` dialog that mirrors the popConfirm title,
* description, okText, cancelText, and button props — so the
* confirmation UI is preserved in both visible and overflow states.
* If `onClick`/`action` is also set, those take precedence and the
* popConfirm is ignored in the overflow menu.
*/
popConfirm?: Omit<PopconfirmProps, 'children'>;
}

export interface BAINameActionCellProps {
Expand Down Expand Up @@ -156,6 +169,7 @@ const BAINameActionCell: React.FC<BAINameActionCellProps> = ({
'use memo';
const { styles, cx } = useStyles();
const { token } = theme.useToken();
const { modal } = App.useApp();
const [, startTransition] = useTransition();
const containerRef = useRef<HTMLDivElement>(null);
const titleAreaRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -255,10 +269,40 @@ const BAINameActionCell: React.FC<BAINameActionCellProps> = ({
danger: action.type === 'danger',
disabled: action.disabled,
onClick: () => {
action.onClick?.();
if (action.action) {
startTransition(async () => {
await action.action!();
if (action.onClick || action.action) {
action.onClick?.();
if (action.action) {
startTransition(async () => {
await action.action!();
});
}
return;
}
if (action.popConfirm) {
const {
title: confirmTitle,
description,
okText,
cancelText,
okButtonProps,
cancelButtonProps,
onConfirm,
onCancel,
} = action.popConfirm;
const resolveNode = (
value: PopconfirmProps['title'] | PopconfirmProps['description'],
): React.ReactNode =>
typeof value === 'function' ? value() : (value ?? null);
modal.confirm({
title: resolveNode(confirmTitle),
content: resolveNode(description),
okText,
cancelText,
okButtonProps,
cancelButtonProps,
okType: okButtonProps?.danger ? 'danger' : 'primary',
onOk: () => onConfirm?.(),
onCancel: () => onCancel?.(),
});
}
},
Expand Down Expand Up @@ -351,21 +395,29 @@ const BAINameActionCell: React.FC<BAINameActionCellProps> = ({
? styles.actionButtonDanger
: styles.actionButtonDefault;

const button = (
<BAIButton
type="text"
size="small"
icon={action.icon}
disabled={action.disabled}
className={buttonClassName}
style={action.style}
onClick={action.onClick}
action={action.action}
/>
);

return (
<Tooltip
key={action.key}
title={action.disabled ? action.disabledReason : action.title}
>
<BAIButton
type="text"
size="small"
icon={action.icon}
disabled={action.disabled}
className={buttonClassName}
style={action.style}
onClick={action.onClick}
action={action.action}
/>
{action.popConfirm && !action.disabled ? (
<Popconfirm {...action.popConfirm}>{button}</Popconfirm>
) : (
button
)}
</Tooltip>
);
})}
Expand Down
154 changes: 154 additions & 0 deletions react/src/components/DeleteForeverVFolderModalV2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
@license
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
*/
import { DeleteForeverVFolderModalV2Fragment$key } from '../__generated__/DeleteForeverVFolderModalV2Fragment.graphql';
import { DeleteForeverVFolderModalV2Mutation } from '../__generated__/DeleteForeverVFolderModalV2Mutation.graphql';
import { Alert, App, theme, Typography } from 'antd';
import {
BAIConfirmModalWithInput,
BAIConfirmModalWithInputProps,
BAIFlex,
toLocalId,
useErrorMessageResolver,
} from 'backend.ai-ui';
import * as _ from 'lodash-es';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { graphql, useFragment, useMutation } from 'react-relay';

interface DeleteForeverVFolderModalV2Props extends Omit<
BAIConfirmModalWithInputProps,
'confirmText' | 'content' | 'title' | 'onOk' | 'onCancel'
> {
vfolderFrgmts?: DeleteForeverVFolderModalV2Fragment$key;
onRequestClose?: (success: boolean) => void;
}

const DeleteForeverVFolderModalV2: React.FC<
DeleteForeverVFolderModalV2Props
> = ({ vfolderFrgmts, onRequestClose, ...modalProps }) => {
'use memo';
const { t } = useTranslation();
Comment thread
ironAiken2 marked this conversation as resolved.
const { message } = App.useApp();
const { token } = theme.useToken();
const { getErrorMessage } = useErrorMessageResolver();

const vfolders = useFragment(
graphql`
fragment DeleteForeverVFolderModalV2Fragment on VirtualFolderNode
@relay(plural: true) {
id
name
}
`,
vfolderFrgmts,
);

const [commitBulkPurgeMutation, isInFlightBulkPurge] =
useMutation<DeleteForeverVFolderModalV2Mutation>(graphql`
mutation DeleteForeverVFolderModalV2Mutation(
$input: BulkPurgeVFoldersV2Input!
) {
bulkPurgeVfoldersV2(input: $input) {
purgedCount
}
}
`);

const purgeable = vfolders ?? [];
// For single-folder deletion the user must type the folder's own name —
// matches the BAIConfirmModalWithInput convention used elsewhere for
// irreversible per-resource actions. Bulk deletion falls back to a
// generic confirmation word since there is no single name to bind to.
const confirmText =
purgeable.length === 1
? (purgeable[0]?.name ?? t('data.folders.DeleteForeverConfirmText'))
: t('data.folders.DeleteForeverConfirmText');

return (
<BAIConfirmModalWithInput
Comment thread
ironAiken2 marked this conversation as resolved.
{...modalProps}
title={t('dialog.title.DeleteForever')}
okText={t('data.folders.DeleteForever')}
confirmLoading={isInFlightBulkPurge}
confirmText={confirmText}
content={
<BAIFlex
direction="column"
gap="md"
align="stretch"
style={{ marginBottom: token.marginXS, width: '100%' }}
>
<Alert
type="warning"
title={t('dialog.warning.DeleteForeverDesc')}
style={{ width: '100%' }}
/>
<Typography.Text>
{purgeable.length === 1
? t('data.folders.DeleteForeverDescription', {
folderName: purgeable[0]?.name,
})
: t('data.folders.DeleteForeverMultipleDescription', {
folderLength: purgeable.length,
})}
</Typography.Text>
<BAIFlex>
<Typography.Text style={{ marginRight: token.marginXXS }}>
{t('data.folders.TypeToConfirmDeleteForever')}
</Typography.Text>
(<Typography.Text code>{confirmText}</Typography.Text>)
</BAIFlex>
</BAIFlex>
}
onOk={() => {
if (purgeable.length === 0) {
onRequestClose?.(false);
return;
}
const ids = _.map(purgeable, (vfolder) => toLocalId(vfolder.id));
commitBulkPurgeMutation({
variables: { input: { ids } },
onCompleted: (data, errors) => {
if (errors && errors.length > 0) {
const firstError = errors[0];
message.error(firstError?.message ?? getErrorMessage(firstError));
return;
}
const purgedCount = data?.bulkPurgeVfoldersV2?.purgedCount ?? 0;
if (purgedCount === 0) {
message.error(
t('data.folders.FailedToDeleteFolders', {
folderNames: _.map(purgeable, 'name').join(', '),
}),
);
return;
}
if (purgeable.length === 1) {
message.success(
t('data.folders.FolderDeletedForever', {
folderName: purgeable[0]?.name,
}),
);
} else {
message.success(
t('data.folders.MultipleFolderDeletedForever', {
count: purgedCount,
total: purgeable.length,
}),
);
}
onRequestClose?.(true);
},
onError: (error) => {
message.error(getErrorMessage(error));
},
});
}}
onCancel={() => onRequestClose?.(false)}
/>
);
};

export default DeleteForeverVFolderModalV2;
Loading
Loading