mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-07-02 11:12:20 +08:00
feat: support on-demand loading of collections from external sources
This commit is contained in:
parent
a9cde1b168
commit
38cf079b95
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import React, { useCallback, useState, useMemo, useEffect, useRef } from 'react';
|
import React, { useCallback, useState, useMemo, useEffect, useRef } from 'react';
|
||||||
import { Button, Table, Checkbox, Input, message, Tooltip } from 'antd';
|
import { Button, Table, Checkbox, Input, message, Tooltip } from 'antd';
|
||||||
import { ReloadOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
import { ReloadOutlined, QuestionCircleOutlined, ClearOutlined } from '@ant-design/icons';
|
||||||
import { observer } from '@formily/react';
|
import { observer } from '@formily/react';
|
||||||
import { useAPIClient } from '@nocobase/client';
|
import { useAPIClient } from '@nocobase/client';
|
||||||
|
|
||||||
@ -17,21 +17,64 @@ const CollectionsTable = observer((tableProps: any) => {
|
|||||||
const api = useAPIClient();
|
const api = useAPIClient();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [pagination, setPagination] = useState({
|
const [allCollections, setAllCollections] = useState([]);
|
||||||
current: 1,
|
const [selectedMap, setSelectedMap] = useState(new Map());
|
||||||
pageSize: 20,
|
const [selectAllForCurrentView, setSelectAllForCurrentView] = useState(false);
|
||||||
total: 0,
|
|
||||||
totalPage: 0
|
|
||||||
});
|
|
||||||
const searchTimeoutRef = useRef<NodeJS.Timeout>();
|
const searchTimeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
|
const MAX_SELECTION_LIMIT = 5000;
|
||||||
|
|
||||||
const { NAMESPACE, t } = tableProps;
|
const { NAMESPACE, t } = tableProps;
|
||||||
const defaultAddAllCollections =
|
const defaultAddAllCollections =
|
||||||
tableProps.formValues?.options?.addAllCollections === undefined
|
tableProps.formValues?.options?.addAllCollections === undefined
|
||||||
? true
|
? true
|
||||||
: tableProps.formValues?.options?.addAllCollections;
|
: tableProps.formValues?.options?.addAllCollections;
|
||||||
const [addAllCollections, setaddAllCollections] = useState(defaultAddAllCollections);
|
const [addAllCollections, setaddAllCollections] = useState(defaultAddAllCollections);
|
||||||
const collections = tableProps.value || [];
|
|
||||||
|
const filteredCollections = useMemo(() => {
|
||||||
|
if (!searchText.trim()) {
|
||||||
|
return allCollections;
|
||||||
|
}
|
||||||
|
return allCollections.filter((item: any) =>
|
||||||
|
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [allCollections, searchText]);
|
||||||
|
|
||||||
|
const displayCollections = useMemo(() => {
|
||||||
|
const baseData = tableProps.value && tableProps.value.length > 0 ? tableProps.value : allCollections;
|
||||||
|
|
||||||
|
if (!searchText.trim()) {
|
||||||
|
return baseData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseData.filter((item: any) =>
|
||||||
|
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [tableProps.value, allCollections, searchText]);
|
||||||
|
|
||||||
|
const allData = useMemo(() => {
|
||||||
|
return tableProps.value && tableProps.value.length > 0 ? tableProps.value : allCollections;
|
||||||
|
}, [tableProps.value, allCollections]);
|
||||||
|
|
||||||
|
const enrichedDisplayCollections = useMemo(() => {
|
||||||
|
return displayCollections.map(item => ({
|
||||||
|
...item,
|
||||||
|
selected: selectedMap.get(item.name) || item.required || selectAllForCurrentView
|
||||||
|
}));
|
||||||
|
}, [displayCollections, selectedMap, selectAllForCurrentView]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (allData.length > 0) {
|
||||||
|
const newSelectedMap = new Map();
|
||||||
|
allData.forEach(item => {
|
||||||
|
if (item.selected) {
|
||||||
|
newSelectedMap.set(item.name, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setSelectedMap(newSelectedMap);
|
||||||
|
setSelectAllForCurrentView(false);
|
||||||
|
}
|
||||||
|
}, [allData.length]);
|
||||||
|
|
||||||
const handleAddAllCollectionsChange = useCallback(
|
const handleAddAllCollectionsChange = useCallback(
|
||||||
(checked: boolean) => {
|
(checked: boolean) => {
|
||||||
@ -44,7 +87,7 @@ const CollectionsTable = observer((tableProps: any) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleLoadCollections = useCallback(
|
const handleLoadCollections = useCallback(
|
||||||
async (page = 1, pageSize = 20, keywords?: string) => {
|
async () => {
|
||||||
const { dataSourceKey: key, formValues, onChange, from } = tableProps;
|
const { dataSourceKey: key, formValues, onChange, from } = tableProps;
|
||||||
const options = formValues?.options || {};
|
const options = formValues?.options || {};
|
||||||
const requiredText = t('is required');
|
const requiredText = t('is required');
|
||||||
@ -80,29 +123,17 @@ const CollectionsTable = observer((tableProps: any) => {
|
|||||||
const params: any = {
|
const params: any = {
|
||||||
isFirst: from === 'create',
|
isFirst: from === 'create',
|
||||||
dbOptions: { ...options, type: formValues.type || 'mysql' },
|
dbOptions: { ...options, type: formValues.type || 'mysql' },
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (keywords) {
|
|
||||||
params.filter = { keywords };
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await api.request({
|
const response = await api.request({
|
||||||
url: `dataSources/${key}/collections:all`,
|
url: `dataSources/${key}/collections:all`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
const { data, meta } = response?.data || {};
|
const { data } = response?.data || {};
|
||||||
const collectionsData = data || [];
|
const collectionsData = data || [];
|
||||||
const { count: total, page: current = 0, totalPage } = meta;
|
|
||||||
|
|
||||||
setPagination({
|
setAllCollections(collectionsData);
|
||||||
current,
|
|
||||||
pageSize,
|
|
||||||
total,
|
|
||||||
totalPage
|
|
||||||
});
|
|
||||||
|
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange(collectionsData);
|
onChange(collectionsData);
|
||||||
@ -121,24 +152,19 @@ const CollectionsTable = observer((tableProps: any) => {
|
|||||||
[tableProps.dataSourceKey, tableProps.formValues, tableProps.options, tableProps.from, api, t, NAMESPACE],
|
[tableProps.dataSourceKey, tableProps.formValues, tableProps.options, tableProps.from, api, t, NAMESPACE],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 防抖搜索
|
|
||||||
const debouncedSearch = useCallback(
|
const debouncedSearch = useCallback(
|
||||||
(keywords: string) => {
|
(keywords: string) => {
|
||||||
if (addAllCollections) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (searchTimeoutRef.current) {
|
if (searchTimeoutRef.current) {
|
||||||
clearTimeout(searchTimeoutRef.current);
|
clearTimeout(searchTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
searchTimeoutRef.current = setTimeout(() => {
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
handleLoadCollections(1, pagination.pageSize, keywords || undefined);
|
setSearchText(keywords);
|
||||||
}, 500);
|
}, 300);
|
||||||
},
|
},
|
||||||
[handleLoadCollections, pagination.pageSize],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 清理定时器
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (searchTimeoutRef.current) {
|
if (searchTimeoutRef.current) {
|
||||||
@ -147,51 +173,149 @@ const CollectionsTable = observer((tableProps: any) => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { isAllSelected, isIndeterminate } = useMemo(() => {
|
const { isAllSelected, isIndeterminate, selectedCount, isAtLimit } = useMemo(() => {
|
||||||
const selectableCollections = collections.filter((item) => !item.required);
|
const totalSelectedCount = allData.filter(item =>
|
||||||
const selectedCount = collections.filter((item) => item.selected || item.required).length;
|
selectedMap.get(item.name) || item.required
|
||||||
const allSelected = selectableCollections.length > 0 && selectedCount === collections.length;
|
).length;
|
||||||
const indeterminate = selectedCount > 0 && selectedCount < collections.length;
|
|
||||||
|
const atLimit = totalSelectedCount >= MAX_SELECTION_LIMIT;
|
||||||
|
|
||||||
|
const selectableCollections = enrichedDisplayCollections.filter((item) => !item.required);
|
||||||
|
const visibleSelectedCount = enrichedDisplayCollections.filter((item) => item.selected || item.required).length;
|
||||||
|
const allSelected = selectableCollections.length > 0 && visibleSelectedCount === enrichedDisplayCollections.length;
|
||||||
|
const indeterminate = visibleSelectedCount > 0 && visibleSelectedCount < enrichedDisplayCollections.length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAllSelected: allSelected,
|
isAllSelected: allSelected,
|
||||||
isIndeterminate: indeterminate,
|
isIndeterminate: indeterminate,
|
||||||
|
selectedCount: totalSelectedCount,
|
||||||
|
isAtLimit: atLimit,
|
||||||
};
|
};
|
||||||
}, [collections]);
|
}, [enrichedDisplayCollections, allData, selectedMap]);
|
||||||
|
|
||||||
const handleSelectAll = useCallback(
|
const handleSelectAll = useCallback(
|
||||||
(checked: boolean) => {
|
(checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
const currentSelectedCount = allData.filter(item =>
|
||||||
|
selectedMap.get(item.name) || item.required
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const selectableInCurrentView = displayCollections.filter(item =>
|
||||||
|
!item.required && !selectedMap.get(item.name)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (currentSelectedCount >= MAX_SELECTION_LIMIT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingLimit = MAX_SELECTION_LIMIT - currentSelectedCount;
|
||||||
|
|
||||||
|
if (selectableInCurrentView > remainingLimit) {
|
||||||
|
message.warning(
|
||||||
|
t('Maximum selection limit exceeded. To ensure system performance, you can select up to {{limit}} collections.', {
|
||||||
|
ns: NAMESPACE,
|
||||||
|
limit: MAX_SELECTION_LIMIT
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectAllForCurrentView(checked);
|
||||||
|
|
||||||
const updateCollections = () => {
|
const updateCollections = () => {
|
||||||
const updatedCollections = collections.map((item) => {
|
const newSelectedMap = new Map(selectedMap);
|
||||||
|
let selectedInThisOperation = 0;
|
||||||
|
const currentSelectedCount = allData.filter(item =>
|
||||||
|
selectedMap.get(item.name) || item.required
|
||||||
|
).length;
|
||||||
|
const remainingLimit = MAX_SELECTION_LIMIT - currentSelectedCount;
|
||||||
|
|
||||||
|
displayCollections.forEach(item => {
|
||||||
if (!item.required) {
|
if (!item.required) {
|
||||||
return { ...item, selected: checked };
|
if (checked) {
|
||||||
|
if (selectedInThisOperation < remainingLimit && !selectedMap.get(item.name)) {
|
||||||
|
newSelectedMap.set(item.name, true);
|
||||||
|
selectedInThisOperation++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newSelectedMap.delete(item.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return item;
|
|
||||||
});
|
});
|
||||||
tableProps.onChange?.(updatedCollections);
|
|
||||||
|
setSelectedMap(newSelectedMap);
|
||||||
|
|
||||||
|
const updatedAllData = allData.map(item => ({
|
||||||
|
...item,
|
||||||
|
selected: newSelectedMap.get(item.name) || item.required || false
|
||||||
|
}));
|
||||||
|
|
||||||
|
tableProps.onChange?.(updatedAllData);
|
||||||
|
|
||||||
|
setSelectAllForCurrentView(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (window.requestIdleCallback) {
|
if (typeof MessageChannel !== 'undefined') {
|
||||||
window.requestIdleCallback(updateCollections);
|
const channel = new MessageChannel();
|
||||||
|
channel.port2.onmessage = () => updateCollections();
|
||||||
|
channel.port1.postMessage(null);
|
||||||
} else {
|
} else {
|
||||||
setTimeout(updateCollections, 0);
|
setTimeout(updateCollections, 0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[collections, tableProps.onChange],
|
[displayCollections, allData, selectedMap, tableProps.onChange, t, NAMESPACE],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectChange = useCallback(
|
const handleSelectChange = useCallback(
|
||||||
(index: number, checked: boolean) => {
|
(index: number, checked: boolean) => {
|
||||||
const updatedCollections = [...collections];
|
const currentItem = enrichedDisplayCollections[index];
|
||||||
updatedCollections[index] = { ...updatedCollections[index], selected: checked };
|
if (!currentItem || currentItem.selected === checked) return;
|
||||||
tableProps.onChange?.(updatedCollections);
|
|
||||||
|
if (checked) {
|
||||||
|
const currentSelectedCount = allData.filter(item =>
|
||||||
|
selectedMap.get(item.name) || item.required
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (currentSelectedCount >= MAX_SELECTION_LIMIT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSelectedCount + 1 === MAX_SELECTION_LIMIT) {
|
||||||
|
message.warning(
|
||||||
|
t('Maximum selection limit reached. To ensure system performance, you can select up to {{limit}} collections at once.', {
|
||||||
|
ns: NAMESPACE,
|
||||||
|
limit: MAX_SELECTION_LIMIT
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSelectedMap = new Map(selectedMap);
|
||||||
|
if (checked) {
|
||||||
|
newSelectedMap.set(currentItem.name, true);
|
||||||
|
} else {
|
||||||
|
newSelectedMap.delete(currentItem.name);
|
||||||
|
setSelectAllForCurrentView(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedMap(newSelectedMap);
|
||||||
|
|
||||||
|
const updateCollections = () => {
|
||||||
|
const updatedAllData = allData.map(item => ({
|
||||||
|
...item,
|
||||||
|
selected: newSelectedMap.get(item.name) || item.required || false
|
||||||
|
}));
|
||||||
|
|
||||||
|
tableProps.onChange?.(updatedAllData);
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(updateCollections, 0);
|
||||||
},
|
},
|
||||||
[collections, tableProps.onChange],
|
[enrichedDisplayCollections, allData, selectedMap, tableProps.onChange, t, NAMESPACE],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSearch = useCallback(
|
const handleSearch = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
setSearchText(value);
|
|
||||||
debouncedSearch(value);
|
debouncedSearch(value);
|
||||||
},
|
},
|
||||||
[debouncedSearch],
|
[debouncedSearch],
|
||||||
@ -199,57 +323,113 @@ const CollectionsTable = observer((tableProps: any) => {
|
|||||||
|
|
||||||
const handleClearSearch = useCallback(() => {
|
const handleClearSearch = useCallback(() => {
|
||||||
setSearchText('');
|
setSearchText('');
|
||||||
handleLoadCollections(1, pagination.pageSize);
|
if (searchTimeoutRef.current) {
|
||||||
}, [handleLoadCollections, pagination.pageSize]);
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleTableChange = useCallback(
|
const handleReset = useCallback(() => {
|
||||||
(paginationConfig: any) => {
|
setSelectedMap(new Map());
|
||||||
const { current, pageSize } = paginationConfig;
|
setSelectAllForCurrentView(false);
|
||||||
handleLoadCollections(current, pageSize, searchText || undefined);
|
|
||||||
},
|
const updatedAllData = allData.map(item => ({
|
||||||
[handleLoadCollections, searchText],
|
...item,
|
||||||
);
|
selected: item.required || false
|
||||||
|
}));
|
||||||
|
|
||||||
|
tableProps.onChange?.(updatedAllData);
|
||||||
|
}, [allData, tableProps.onChange]);
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
|
const CheckboxCell = React.memo(({ selected, required, index, onChange, name, disabled }: any) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={required || selected}
|
||||||
|
disabled={required || disabled}
|
||||||
|
onChange={(e) => onChange(index, e.target.checked)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
const NameCell = React.memo(({ text }: any) => (
|
||||||
|
<span style={{ paddingLeft: '40px' }}>{text}</span>
|
||||||
|
));
|
||||||
|
|
||||||
const baseColumns: any = [
|
const baseColumns: any = [
|
||||||
{
|
{
|
||||||
title: <div style={{ textAlign: 'center' }}>{t('Display name', { ns: NAMESPACE })}</div>,
|
title: <div style={{ textAlign: 'center' }}>{t('Display name', { ns: NAMESPACE })}</div>,
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
key: 'name',
|
key: 'name',
|
||||||
align: 'left' as const,
|
align: 'left' as const,
|
||||||
render: (text: string) => <span style={{ paddingLeft: '40px' }}>{text}</span>,
|
width: '50%',
|
||||||
|
render: (text: string) => <NameCell text={text} />,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!addAllCollections) {
|
if (!addAllCollections) {
|
||||||
|
const isNearLimit = selectedCount >= MAX_SELECTION_LIMIT * 0.9;
|
||||||
|
const titleStyle = isNearLimit ? { color: '#ff7a00' } : {};
|
||||||
|
|
||||||
|
const currentSelectedCount = allData.filter(item =>
|
||||||
|
selectedMap.get(item.name) || item.required
|
||||||
|
).length;
|
||||||
|
const unselectedInCurrentView = enrichedDisplayCollections.filter(item =>
|
||||||
|
!item.selected && !item.required
|
||||||
|
).length;
|
||||||
|
const canSelectAll = currentSelectedCount + unselectedInCurrentView <= MAX_SELECTION_LIMIT;
|
||||||
|
|
||||||
baseColumns.push({
|
baseColumns.push({
|
||||||
title: (
|
title: (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4 }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isAllSelected}
|
checked={isAllSelected}
|
||||||
indeterminate={isIndeterminate}
|
indeterminate={isIndeterminate}
|
||||||
|
disabled={isAtLimit && !isAllSelected && !canSelectAll}
|
||||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||||
style={{ marginRight: 4 }}
|
|
||||||
/>
|
/>
|
||||||
{t('Add', { ns: NAMESPACE })}
|
<span style={titleStyle}>
|
||||||
|
{t('Add', { ns: NAMESPACE })} ({selectedCount}/{allData.length})
|
||||||
|
</span>
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<Tooltip title={t('Reset selection', { ns: NAMESPACE })}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<ClearOutlined />}
|
||||||
|
onClick={handleReset}
|
||||||
|
style={{
|
||||||
|
padding: '0 4px',
|
||||||
|
height: '16px',
|
||||||
|
minWidth: '16px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#666'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
dataIndex: 'selected',
|
dataIndex: 'selected',
|
||||||
key: 'selected',
|
key: 'selected',
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
width: 150,
|
width: '30%',
|
||||||
render: (selected: boolean, record: any, index: number) => (
|
render: (selected: boolean, record: any, index: number) => {
|
||||||
<Checkbox
|
const shouldDisable = isAtLimit && !selected && !record.required;
|
||||||
checked={record.required || selected}
|
|
||||||
disabled={record.required}
|
return (
|
||||||
onChange={(e) => handleSelectChange(index, e.target.checked)}
|
<CheckboxCell
|
||||||
/>
|
selected={selected}
|
||||||
),
|
required={record.required}
|
||||||
|
index={index}
|
||||||
|
name={record.name}
|
||||||
|
disabled={shouldDisable}
|
||||||
|
onChange={handleSelectChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseColumns;
|
return baseColumns;
|
||||||
}, [t, isAllSelected, isIndeterminate, handleSelectAll, handleSelectChange, addAllCollections, NAMESPACE]);
|
}, [t, isAllSelected, isIndeterminate, handleSelectAll, handleSelectChange, addAllCollections, NAMESPACE, selectedCount, allData.length, isAtLimit, selectedMap, enrichedDisplayCollections, handleReset]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -265,25 +445,18 @@ const CollectionsTable = observer((tableProps: any) => {
|
|||||||
<Checkbox checked={addAllCollections} onChange={(e) => handleAddAllCollectionsChange(e.target.checked)}>
|
<Checkbox checked={addAllCollections} onChange={(e) => handleAddAllCollectionsChange(e.target.checked)}>
|
||||||
{t('Add all collections', { ns: NAMESPACE })}
|
{t('Add all collections', { ns: NAMESPACE })}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Tooltip
|
|
||||||
title={t('When there are too many data tables, it may cause system loading lag.', { ns: NAMESPACE })}
|
|
||||||
placement="right"
|
|
||||||
>
|
|
||||||
<QuestionCircleOutlined style={{ color: '#8c8c8c' }} />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
<Input.Search
|
<Input.Search
|
||||||
placeholder={t('Search collection name', { ns: NAMESPACE })}
|
placeholder={t('Search collection name', { ns: NAMESPACE })}
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 250 }}
|
style={{ width: 250 }}
|
||||||
value={searchText}
|
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
onClear={handleClearSearch}
|
onClear={handleClearSearch}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined />}
|
||||||
onClick={() => handleLoadCollections(pagination.current, pagination.pageSize, searchText || undefined)}
|
onClick={() => handleLoadCollections()}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
>
|
>
|
||||||
{t('Load Collections', { ns: NAMESPACE })}
|
{t('Load Collections', { ns: NAMESPACE })}
|
||||||
@ -291,24 +464,17 @@ const CollectionsTable = observer((tableProps: any) => {
|
|||||||
</div>
|
</div>
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={collections}
|
dataSource={enrichedDisplayCollections}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{
|
pagination={false}
|
||||||
current: pagination.current,
|
scroll={{
|
||||||
pageSize: pagination.pageSize,
|
x: addAllCollections ? 300 : 550,
|
||||||
total: pagination.total,
|
y: 400
|
||||||
showSizeChanger: true,
|
|
||||||
showQuickJumper: false,
|
|
||||||
showTotal: (total, range) => `${range[0]}-${range[1]} of ${total}`,
|
|
||||||
pageSizeOptions: ['20', '50', '100'],
|
|
||||||
size: 'small',
|
|
||||||
simple: false,
|
|
||||||
}}
|
}}
|
||||||
scroll={{ x: addAllCollections ? 300 : 550, y: 400 }}
|
virtual
|
||||||
bordered
|
bordered
|
||||||
rowKey="name"
|
rowKey="name"
|
||||||
size="small"
|
size="small"
|
||||||
onChange={handleTableChange}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -53,12 +53,12 @@ export const EditDatabaseConnectionAction = () => {
|
|||||||
field.data = field.data || {};
|
field.data = field.data || {};
|
||||||
field.data.loading = true;
|
field.data.loading = true;
|
||||||
try {
|
try {
|
||||||
|
await resource.update({ filterByTk, values: _.omit(form.values, 'collections') });
|
||||||
const toBeAddedCollections = form.values.collections || [];
|
const toBeAddedCollections = form.values.collections || [];
|
||||||
if (!form.values.addAllCollections) {
|
if (!form.values.addAllCollections) {
|
||||||
await addDatasourceCollections(api, filterByTk, { collections: toBeAddedCollections, dbOptions: _.omit(form.values.options, 'collections') });
|
await addDatasourceCollections(api, filterByTk, { collections: toBeAddedCollections, dbOptions: form.values.options });
|
||||||
}
|
}
|
||||||
delete form.values.collections;
|
delete form.values.collections;
|
||||||
await resource.update({ filterByTk, values: form.values });
|
|
||||||
ctx.setVisible(false);
|
ctx.setVisible(false);
|
||||||
dm.getDataSource(filterByTk).setOptions(form.values);
|
dm.getDataSource(filterByTk).setOptions(form.values);
|
||||||
dm.getDataSource(filterByTk).reload();
|
dm.getDataSource(filterByTk).reload();
|
||||||
|
@ -0,0 +1 @@
|
|||||||
|
export const ALLOW_MAX_COLLECTIONS_COUNT = 5000;
|
@ -17,13 +17,14 @@ import rolesConnectionResourcesResourcer from './resourcers/data-sources-resourc
|
|||||||
import databaseConnectionsRolesResourcer from './resourcers/data-sources-roles';
|
import databaseConnectionsRolesResourcer from './resourcers/data-sources-roles';
|
||||||
import { rolesRemoteCollectionsResourcer } from './resourcers/roles-data-sources-collections';
|
import { rolesRemoteCollectionsResourcer } from './resourcers/roles-data-sources-collections';
|
||||||
|
|
||||||
import { LoadingProgress } from '@nocobase/data-source-manager';
|
import { DataSourceManager, LoadingProgress } from '@nocobase/data-source-manager';
|
||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
import { DataSourcesRolesResourcesModel } from './models/connections-roles-resources';
|
import { DataSourcesRolesResourcesModel } from './models/connections-roles-resources';
|
||||||
import { DataSourcesRolesResourcesActionModel } from './models/connections-roles-resources-action';
|
import { DataSourcesRolesResourcesActionModel } from './models/connections-roles-resources-action';
|
||||||
import { DataSourceModel } from './models/data-source';
|
import { DataSourceModel } from './models/data-source';
|
||||||
import { DataSourcesRolesModel } from './models/data-sources-roles-model';
|
import { DataSourcesRolesModel } from './models/data-sources-roles-model';
|
||||||
import { mergeRole } from '@nocobase/acl';
|
import { mergeRole } from '@nocobase/acl';
|
||||||
|
import { ALLOW_MAX_COLLECTIONS_COUNT } from './constants';
|
||||||
|
|
||||||
type DataSourceState = 'loading' | 'loaded' | 'loading-failed' | 'reloading' | 'reloading-failed';
|
type DataSourceState = 'loading' | 'loaded' | 'loading-failed' | 'reloading' | 'reloading-failed';
|
||||||
|
|
||||||
@ -356,6 +357,42 @@ export class PluginDataSourceManagerServer extends Plugin {
|
|||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.app.resourceManager.use(async function verifyDatasourceCollectionsCount(ctx, next) {
|
||||||
|
if (!ctx.action) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { actionName, resourceName, params } = ctx.action;
|
||||||
|
if (resourceName === 'dataSources' && (actionName === 'add' || actionName === 'update')) {
|
||||||
|
const { values, filterByTk: dataSourceKey } = params;
|
||||||
|
if (values.options.addAllCollections) {
|
||||||
|
let introspector: { getCollections: () => Promise<string[]> } = null;
|
||||||
|
const dataSourceManager = ctx.app['dataSourceManager'] as DataSourceManager;
|
||||||
|
|
||||||
|
if (actionName === 'add') {
|
||||||
|
const klass = dataSourceManager.factory.getClass(values.options.type);
|
||||||
|
// @ts-ignore
|
||||||
|
const dataSource = new klass(dbOptions);
|
||||||
|
introspector = dataSource.collectionManager.dataSource.createDatabaseIntrospector(
|
||||||
|
dataSource.collectionManager.db,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const dataSource = dataSourceManager.dataSources.get(dataSourceKey);
|
||||||
|
if (!dataSource) {
|
||||||
|
throw new Error(`dataSource ${dataSourceKey} not found`);
|
||||||
|
}
|
||||||
|
introspector = dataSource['introspector'];
|
||||||
|
}
|
||||||
|
const allCollections = await introspector.getCollections();
|
||||||
|
if (allCollections.length > ALLOW_MAX_COLLECTIONS_COUNT) {
|
||||||
|
throw new Error(`The number of collections exceeds the limit of ${ALLOW_MAX_COLLECTIONS_COUNT}. Please remove some collections before adding new ones.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
this.app.use(async function handleAppendDataSourceCollection(ctx, next) {
|
this.app.use(async function handleAppendDataSourceCollection(ctx, next) {
|
||||||
await next();
|
await next();
|
||||||
|
|
||||||
|
@ -11,6 +11,7 @@ import lodash from 'lodash';
|
|||||||
import { filterMatch } from '@nocobase/database';
|
import { filterMatch } from '@nocobase/database';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { DataSourceManager } from '@nocobase/data-source-manager';
|
import { DataSourceManager } from '@nocobase/data-source-manager';
|
||||||
|
import { ALLOW_MAX_COLLECTIONS_COUNT } from '../constants';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'dataSources.collections',
|
name: 'dataSources.collections',
|
||||||
@ -129,11 +130,9 @@ export default {
|
|||||||
},
|
},
|
||||||
async all(ctx, next) {
|
async all(ctx, next) {
|
||||||
const params = ctx.action.params;
|
const params = ctx.action.params;
|
||||||
const { associatedIndex: dataSourceKey, isFirst, dbOptions, filter } = params;
|
const { associatedIndex: dataSourceKey, isFirst, dbOptions } = params;
|
||||||
const page = params.page ? Number(params.page) : 1;
|
|
||||||
const pageSize = params.pageSize ? Number(params.pageSize) : 20;
|
|
||||||
const dataSourceManager = ctx.app.dataSourceManager as DataSourceManager;
|
const dataSourceManager = ctx.app.dataSourceManager as DataSourceManager;
|
||||||
let introspector: { getCollections: (options: { pageIndex?: number, pageSize?: number, keywords?: string }) => Promise<{ tableList: string[], tableCount: number }> } = null;
|
let introspector: { getCollections: () => Promise<string[]> } = null;
|
||||||
if (isFirst) {
|
if (isFirst) {
|
||||||
const klass = dataSourceManager.factory.getClass(dbOptions.type);
|
const klass = dataSourceManager.factory.getClass(dbOptions.type);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -148,7 +147,7 @@ export default {
|
|||||||
}
|
}
|
||||||
introspector = dataSource['introspector'];
|
introspector = dataSource['introspector'];
|
||||||
}
|
}
|
||||||
const { tableList: allCollections, tableCount } = await introspector.getCollections({ pageIndex: page, pageSize, keywords: filter?.keywords });
|
const allCollections = await introspector.getCollections();
|
||||||
const selectedCollections = await ctx.db.getRepository('dataSourcesCollections').find({
|
const selectedCollections = await ctx.db.getRepository('dataSourcesCollections').find({
|
||||||
filter: { dataSourceKey },
|
filter: { dataSourceKey },
|
||||||
});
|
});
|
||||||
@ -159,11 +158,7 @@ export default {
|
|||||||
selected: !!selectedMap[collection],
|
selected: !!selectedMap[collection],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
ctx.withoutDataWrapping = true;
|
ctx.body = result;
|
||||||
ctx.body = {
|
|
||||||
data: result,
|
|
||||||
meta: { count: tableCount, page, pageSize, totalPage: Math.ceil(tableCount / pageSize) },
|
|
||||||
}
|
|
||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
async add(ctx, next) {
|
async add(ctx, next) {
|
||||||
@ -175,6 +170,9 @@ export default {
|
|||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (collections.length > ALLOW_MAX_COLLECTIONS_COUNT) {
|
||||||
|
throw new Error(`The number of collections exceeds the limit of ${ALLOW_MAX_COLLECTIONS_COUNT}. Please remove some collections before adding new ones.`);
|
||||||
|
}
|
||||||
const transaction = await ctx.db.sequelize.transaction();
|
const transaction = await ctx.db.sequelize.transaction();
|
||||||
const repo = ctx.db.getRepository('dataSourcesCollections');
|
const repo = ctx.db.getRepository('dataSourcesCollections');
|
||||||
|
|
||||||
@ -188,7 +186,6 @@ export default {
|
|||||||
const incomingCollections = _.keyBy(collections);
|
const incomingCollections = _.keyBy(collections);
|
||||||
const toBeInserted = collections.filter((collection) => !alreadyInsertedNames[collection]);
|
const toBeInserted = collections.filter((collection) => !alreadyInsertedNames[collection]);
|
||||||
const toBeDeleted = Object.keys(alreadyInsertedNames).filter((name) => !incomingCollections[name]);
|
const toBeDeleted = Object.keys(alreadyInsertedNames).filter((name) => !incomingCollections[name]);
|
||||||
|
|
||||||
if (toBeInserted.length > 0) {
|
if (toBeInserted.length > 0) {
|
||||||
const insertCollections = toBeInserted.map((collection) => {
|
const insertCollections = toBeInserted.map((collection) => {
|
||||||
return { name: collection, dataSourceKey };
|
return { name: collection, dataSourceKey };
|
||||||
|
Loading…
x
Reference in New Issue
Block a user