Files
MemoryBear/web/src/views/ApplicationConfig/components/AppSharingModal.tsx
2026-03-18 16:10:20 +08:00

190 lines
7.3 KiB
TypeScript

/*
* @Author: ZhaoYing
* @Date: 2026-03-13 17:19:13
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-18 16:03:46
*/
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Checkbox, App, Form } from 'antd';
import { useTranslation } from 'react-i18next';
import RbModal from '@/components/RbModal';
import { appSharing, getAppShares } from '@/api/application';
import { formatDateTime } from '@/utils/format';
import type { AppSharingModalRef, Release } from '../types';
import type { SpaceItem } from '@/views/KnowledgeBase/types';
import { getWorkspaces } from '@/api/workspaces';
import RadioGroupCard from '@/components/RadioGroupCard';
/** Props for the AppSharingModal component */
interface AppSharingModalProps {
/** ID of the application being shared */
appId: string;
/** The release version to share */
version: Release | null;
}
const AppSharingModal = forwardRef<AppSharingModalRef, AppSharingModalProps>(({ appId, version }, ref) => {
const { t } = useTranslation();
const { message } = App.useApp();
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false);
// All workspaces available to share with (excluding the current one)
const [spaceList, setSpaceList] = useState<SpaceItem[]>([]);
// IDs of workspaces that already have access to this app
const [sharedIds, setSharedIds] = useState<string[]>([]);
const [form] = Form.useForm<{ target_workspace_ids: string[]; permission: 'readonly' | 'editable' }>();
// Reactively track the currently selected workspace IDs in the form
const selectedIds: string[] = Form.useWatch('target_workspace_ids', form) ?? [];
/**
* Fetch workspaces and existing share records in parallel,
* sort already-shared spaces to the top, then open the modal.
* Shows a warning if the user has no shareable workspaces.
*/
const handleOpen = () => {
Promise.all([getWorkspaces({ include_current: false }), getAppShares(appId)]).then(([spaces, shared]) => {
// Normalise the shared workspace ID field across different API response shapes
const ids = ((shared as any[]) || []).map((s: any) => s.workspace_id || s.target_workspace_id || s.id);
// Sort: already-shared workspaces appear first
const sorted = (spaces as SpaceItem[]).sort((a, b) =>
ids.includes(b.id) ? 1 : ids.includes(a.id) ? -1 : 0
);
setSpaceList(sorted);
setSharedIds(ids);
if (sorted.length > 0) {
setVisible(true);
} else {
message.warning(t('application.noShareAuth'));
}
});
};
/** Close the modal and reset form fields */
const handleClose = () => {
setVisible(false);
form.resetFields();
};
// Expose open/close handlers to the parent via ref
useImperativeHandle(ref, () => ({ handleOpen, handleClose }));
/**
* Toggle a workspace in the selected list.
* Already-shared workspaces are read-only and cannot be toggled.
*/
const handleToggle = (id: string, isShared: boolean) => {
if (isShared) return;
const prev: string[] = form.getFieldValue('target_workspace_ids') ?? [];
form.setFieldValue(
'target_workspace_ids',
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
/** Validate the form then submit the sharing request */
const handleConfirm = () => {
form.validateFields().then(values => {
setLoading(true);
appSharing(appId, values)
.then(() => {
message.success(t('common.operateSuccess'));
handleClose();
})
.finally(() => setLoading(false));
});
};
// Normalise the version label to always start with "v"
const versionLabel = version?.version_name
? (version.version_name[0].toLowerCase() === 'v' ? version.version_name : `v${version.version_name}`)
: `v${version?.version}`;
return (
<RbModal
title={t('application.sharingApp')}
open={visible}
onCancel={handleClose}
okText={<>{t('application.confirmSharing')}({selectedIds.length})</>}
onOk={handleConfirm}
confirmLoading={loading}
width={600}
>
<Form form={form} layout="vertical" initialValues={{ target_workspace_ids: [], permission: 'readonly' }}>
{/* Version info: displays version number, release time and publisher */}
<div className="rb:rounded-lg rb:border rb:border-[#EBEBEB] rb:bg-[#FBFDFF] rb:p-4 rb:mb-4">
<div className="rb:text-sm rb:font-medium rb:mb-3">{t('application.VersionInformation')}</div>
<div className="rb:grid rb:grid-cols-3 rb:gap-4 rb:text-sm">
<div>
<div className="rb:text-[#5B6167] rb:mb-1">{t('application.versionList').replace('列表', '号')}</div>
<div className="rb:font-medium">{versionLabel}</div>
</div>
<div>
<div className="rb:text-[#5B6167] rb:mb-1">{t('application.releaseTime')}</div>
<div className="rb:font-medium">{formatDateTime(version?.published_at || 0, 'YYYY-MM-DD HH:mm:ss')}</div>
</div>
<div>
<div className="rb:text-[#5B6167] rb:mb-1">{t('application.publisher')}</div>
<div className="rb:font-medium">{version?.publisher_name}</div>
</div>
</div>
</div>
{/* Target space: scrollable list of workspaces with checkbox selection */}
<Form.Item
label={t('application.selectTargetSpace')}
required
>
<Form.Item
name="target_workspace_ids"
noStyle
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<input type="hidden" />
</Form.Item>
<div className="rb:rounded-lg rb:border rb:border-[#EBEBEB] rb:divide-y rb:divide-[#EBEBEB] rb:max-h-50 rb:overflow-y-auto">
{spaceList.map(space => {
const isShared = sharedIds.includes(space.id);
return (
<div key={space.id} className="rb:flex rb:items-center rb:gap-2 rb:px-4 rb:py-3 rb:cursor-pointer" onClick={() => handleToggle(space.id, isShared)}>
<Checkbox
checked={isShared || selectedIds.includes(space.id)}
disabled={isShared}
onClick={(e) => e.stopPropagation()}
onChange={() => handleToggle(space.id, isShared)}
/>
<span className="rb:flex-1 rb:text-sm">{space.name}</span>
{isShared && (
<span className="rb:text-xs rb:text-[#5B6167]">{t('application.alreadyShared')}</span>
)}
</div>
);
})}
</div>
</Form.Item>
{/* Permission mode: readonly (use only) or editable (full copy) */}
<Form.Item
name="permission"
label={t('application.permissionMode')}
rules={[{ required: true, message: t('common.pleaseSelect') }]}
className="rb:mb-0!"
>
<RadioGroupCard
options={['readonly', 'editable'].map((type) => ({
value: type,
label: t(`application.${type}Mode`),
labelDesc: t(`application.${type}ModeDesc`),
}))}
/>
</Form.Item>
</Form>
</RbModal>
);
});
export default AppSharingModal;