feat: optimize file collection (#1666)

* feat(Upload.DraggerV2): add loading

* feat: auto close on uploading over

* feat: auto selected when upload is complete

* feat: filter selected options

* feat: add quick-upload and select-file

* fix: should not appear on readPretty mode

* fix: radio

* fix: compatible with older version

* feat: improving the interaction of radio selections

* fix: show two butons by default
This commit is contained in:
被雨水过滤的空气-Rairn 2023-04-09 16:46:53 +08:00 committed by GitHub
parent f35da4a2a0
commit 2050eb5317
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 273 additions and 79 deletions

View File

@ -45,12 +45,14 @@ export const linkTo: IField = {
},
},
},
availableTypes:['belongsToMany'],
availableTypes: ['belongsToMany'],
schemaInitialize(schema: ISchema, { readPretty, block, targetCollection }) {
if (targetCollection?.template === 'file') {
const fieldNames = schema['x-component-props']['fieldNames'] || { label: 'preview', value: 'id' };
fieldNames.label = 'preview';
schema['x-component-props']['fieldNames'] = fieldNames;
schema['x-component-props'].quickUpload = true;
schema['x-component-props'].selectFile = true;
}
if (block === 'Form') {

View File

@ -57,6 +57,8 @@ export const m2m: IField = {
const fieldNames = schema['x-component-props']['fieldNames'] || { label: 'preview', value: 'id' };
fieldNames.label = 'preview';
schema['x-component-props']['fieldNames'] = fieldNames;
schema['x-component-props'].quickUpload = true;
schema['x-component-props'].selectFile = true;
}
if (block === 'Form') {

View File

@ -56,6 +56,8 @@ export const m2o: IField = {
const fieldNames = schema['x-component-props']['fieldNames'] || { label: 'preview', value: 'id' };
fieldNames.label = 'preview';
schema['x-component-props']['fieldNames'] = fieldNames;
schema['x-component-props'].quickUpload = true;
schema['x-component-props'].selectFile = true;
}
if (block === 'Form') {

View File

@ -51,13 +51,7 @@ export const o2m: IField = {
},
},
availableTypes: ['hasMany'],
schemaInitialize(schema: ISchema, { field, block, readPretty, targetCollection }) {
if (targetCollection?.template === 'file') {
const fieldNames = schema['x-component-props']['fieldNames'] || { label: 'preview', value: 'id' };
fieldNames.label = 'preview';
schema['x-component-props']['fieldNames'] = fieldNames;
}
schemaInitialize(schema: ISchema, { field, block, readPretty }) {
if (block === 'Form') {
if (schema['x-component'] === 'TableField') {
const association = `${field.collectionName}.${field.name}`;

View File

@ -118,7 +118,7 @@ export const o2o: IField = {
},
},
availableTypes: ['hasOne'],
schemaInitialize(schema: ISchema, { field, block, readPretty, action, targetCollection }) {
schemaInitialize(schema: ISchema, { field, block, readPretty, action }) {
internalSchameInitialize(schema, { field, block, readPretty, action });
if (['Table', 'Kanban'].includes(block)) {
schema['x-component-props'] = schema['x-component-props'] || {};
@ -127,12 +127,6 @@ export const o2o: IField = {
// 预览文件时需要的参数
schema['x-component-props']['size'] = 'small';
}
if (targetCollection?.template === 'file') {
const fieldNames = schema['x-component-props']['fieldNames'] || { label: 'preview', value: 'id' };
fieldNames.label = 'preview';
schema['x-component-props']['fieldNames'] = fieldNames;
}
},
properties: {
'uiSchema.title': {
@ -302,7 +296,7 @@ export const oho: IField = {
},
},
},
schemaInitialize(schema: ISchema, { field, block, readPretty, action, targetCollection }) {
schemaInitialize(schema: ISchema, { field, block, readPretty, action }) {
internalSchameInitialize(schema, { field, block, readPretty, action });
if (['Table', 'Kanban'].includes(block)) {
schema['x-component-props'] = schema['x-component-props'] || {};
@ -311,12 +305,6 @@ export const oho: IField = {
// 预览文件时需要的参数
schema['x-component-props']['size'] = 'small';
}
if (targetCollection?.template === 'file') {
const fieldNames = schema['x-component-props']['fieldNames'] || { label: 'preview', value: 'id' };
fieldNames.label = 'preview';
schema['x-component-props']['fieldNames'] = fieldNames;
}
},
properties: {
'uiSchema.title': {
@ -491,6 +479,8 @@ export const obo: IField = {
const fieldNames = schema['x-component-props']['fieldNames'] || { label: 'preview', value: 'id' };
fieldNames.label = 'preview';
schema['x-component-props']['fieldNames'] = fieldNames;
schema['x-component-props'].quickUpload = true;
schema['x-component-props'].selectFile = true;
}
},
properties: {

View File

@ -8,7 +8,7 @@ import React, { useContext, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ACLCollectionFieldProvider } from '../../../acl/ACLProvider';
import { BlockRequestContext, useFilterByTk, useFormBlockContext } from '../../../block-provider';
import { useCollection, useCollectionManager } from '../../../collection-manager';
import { Collection, useCollection, useCollectionManager } from '../../../collection-manager';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useCompile, useDesignable, useFieldComponentOptions } from '../../hooks';
import { BlockItem } from '../block-item';
@ -81,6 +81,7 @@ FormItem.Designer = () => {
const { dn, refresh, insertAdjacent } = useDesignable();
const compile = useCompile();
const collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']);
const targetCollection = getCollection(collectionField.target);
const interfaceConfig = getInterface(collectionField?.interface);
const validateSchema = interfaceConfig?.['validateSchema']?.(fieldSchema);
const originalTitle = collectionField?.uiSchema?.title;
@ -229,6 +230,46 @@ FormItem.Designer = () => {
}}
/>
)}
{!form?.readPretty && isFileCollection(targetCollection) ? (
<SchemaSettings.SwitchItem
key="quick-upload"
title={t('Quick upload')}
checked={fieldSchema['x-component-props']?.quickUpload as boolean}
onChange={(value) => {
const schema = {
['x-uid']: fieldSchema['x-uid'],
};
field.componentProps.quickUpload = value;
fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
fieldSchema['x-component-props'].quickUpload = value;
schema['x-component-props'] = fieldSchema['x-component-props'];
dn.emit('patch', {
schema,
});
refresh();
}}
/>
) : null}
{!form?.readPretty && isFileCollection(targetCollection) ? (
<SchemaSettings.SwitchItem
key="select-file"
title={t('Select file')}
checked={fieldSchema['x-component-props']?.selectFile as boolean}
onChange={(value) => {
const schema = {
['x-uid']: fieldSchema['x-uid'],
};
field.componentProps.selectFile = value;
fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
fieldSchema['x-component-props'].selectFile = value;
schema['x-component-props'] = fieldSchema['x-component-props'];
dn.emit('patch', {
schema,
});
refresh();
}}
/>
) : null}
{form && !form?.readPretty && validateSchema && (
<SchemaSettings.ModalItem
title={t('Set validation rules')}
@ -415,7 +456,7 @@ FormItem.Designer = () => {
block: 'Form',
readPretty: field.readPretty,
action: tk ? 'get' : null,
targetCollection: getCollection(collectionField.target),
targetCollection,
});
insertAdjacent('beforeBegin', divWrap(schema), {
@ -547,4 +588,8 @@ FormItem.Designer = () => {
);
};
function isFileCollection(collection: Collection) {
return collection?.template === 'file';
}
FormItem.FilterFormDesigner = FilterFormDesigner;

View File

@ -1,6 +1,6 @@
import { DeleteOutlined, DownloadOutlined, PlusOutlined } from '@ant-design/icons';
import { connect, mapReadPretty } from '@formily/react';
import { Upload as AntdUpload, Button, Space } from 'antd';
import { Upload as AntdUpload, Button, Progress, Space } from 'antd';
import cls from 'classnames';
import { saveAs } from 'file-saver';
import React, { useEffect, useState } from 'react';
@ -8,25 +8,34 @@ import { useTranslation } from 'react-i18next';
import Lightbox from 'react-image-lightbox';
import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app
import { ReadPretty } from '../upload/ReadPretty';
import { isImage, toArr, toFileList } from '../upload/shared';
import { isImage, toFileList, useUploadProps } from '../upload/shared';
import '../upload/style.less';
import { UploadProps } from '../upload/type';
interface TPreview {
(props: any): JSX.Element;
Selector: React.FC<any>;
}
type Props = UploadProps & {
/** 是否显示 Upload 按钮 */
quickUpload: boolean;
/** 是否显示 Select 按钮 */
selectFile: boolean;
onRemove?: (file) => void;
onSelect?: () => void;
};
export const Preview = connect((props) => {
return <ReadPretty.File {...props} />;
}, mapReadPretty(ReadPretty.File));
export const FileSelector = (props: any) => {
const { disabled, multiple, value, onRemove, onSelect } = props;
export const FileSelector = (props: Props) => {
const { disabled, multiple, value, quickUpload, selectFile, onRemove, onSelect } = props;
const uploadProps = useUploadProps({ ...props });
const [fileList, setFileList] = useState([]);
const [photoIndex, setPhotoIndex] = useState(0);
const [visible, setVisible] = useState(false);
const { t } = useTranslation();
// 兼容旧版本
const showSelectButton = selectFile === undefined && quickUpload === undefined;
useEffect(() => {
setFileList(toFileList(value));
}, [value]);
@ -42,11 +51,13 @@ export const FileSelector = (props: any) => {
onSelect?.();
};
const list = fileList.length ? (multiple ? fileList : [fileList[fileList.length - 1]]) : [];
return (
<div>
<div className={cls('ant-upload-picture-card-wrapper nb-upload')}>
<div className={'ant-upload-list ant-upload-list-picture-card'}>
{fileList.map((file) => {
{list.map((file) => {
const handleClick = (e) => {
e.preventDefault();
e.stopPropagation();
@ -59,7 +70,7 @@ export const FileSelector = (props: any) => {
}
};
return (
<div className={'ant-upload-list-picture-card-container'}>
<div key={file.id} className={'ant-upload-list-picture-card-container'}>
<div className="ant-upload-list-item ant-upload-list-item-done ant-upload-list-item-list-type-picture-card">
<div className={'ant-upload-list-item-info'}>
<span className="ant-upload-span">
@ -108,21 +119,25 @@ export const FileSelector = (props: any) => {
)}
</Space>
</span>
{file.status === 'uploading' && (
<div className={'ant-upload-list-item-progress'}>
<Progress strokeWidth={2} type={'line'} showInfo={false} percent={file.percent} />
</div>
)}
</div>
</div>
);
})}
{!disabled && (multiple || toArr(value).length < 1) && (
<>
{showSelectButton ? (
<div className={'ant-upload-list-picture-card-container'}>
<AntdUpload
disabled={disabled}
multiple={multiple}
listType={'picture-card'}
fileList={fileList}
showUploadList={false}
onRemove={handleRemove}
>
{!disabled && (multiple || toArr(value).length < 1) && (
<div
style={{
width: '100%',
@ -137,10 +152,68 @@ export const FileSelector = (props: any) => {
<PlusOutlined />
{t('Select')}
</div>
)}
</AntdUpload>
</div>
)}
) : null}
{quickUpload ? (
<div className={'ant-upload-list-picture-card-container'}>
<AntdUpload
{...uploadProps}
disabled={disabled}
multiple={multiple}
listType={'picture-card'}
fileList={fileList}
showUploadList={false}
onRemove={handleRemove}
onChange={(info) => {
// 如果不在这里 setFileList 的话,会导致 onChange 只会执行一次
setFileList([...info.fileList]);
uploadProps.onChange?.(info);
}}
>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<PlusOutlined />
{t('Upload')}
</div>
</AntdUpload>
</div>
) : null}
{selectFile ? (
<div className={'ant-upload-list-picture-card-container'}>
<AntdUpload
disabled={disabled}
multiple={multiple}
listType={'picture-card'}
showUploadList={false}
onRemove={handleRemove}
>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={handleSelect}
>
<PlusOutlined />
{t('Select')}
</div>
</AntdUpload>
</div>
) : null}
</>
</div>
</div>
{/* 预览图片的弹框 */}

View File

@ -12,7 +12,8 @@ import { FileSelector } from '../preview';
import { useFieldNames } from './useFieldNames';
import { getLabelFormatValue, useLabelUiSchema } from './util';
const RecordPickerContext = createContext(null);
export const RecordPickerContext = createContext(null);
function flatData(data) {
let newArr = [];
for (let i = 0; i < data.length; i++) {
@ -27,28 +28,40 @@ function flatData(data) {
const useTableSelectorProps = () => {
const field = useField<ArrayField>();
const { multiple, value, setSelectedRows, selectedRows: rcSelectRows } = useContext(RecordPickerContext);
const { onRowSelectionChange, rowKey, ...others } = useTsp();
const {
multiple,
options = [],
setSelectedRows,
selectedRows: rcSelectRows = [],
onChange,
} = useContext(RecordPickerContext);
const { onRowSelectionChange, rowKey = 'id', ...others } = useTsp();
const { setVisible } = useActionContext();
return {
...others,
rowKey,
rowSelection: {
type: multiple ? 'checkbox' : 'radio',
// defaultSelectedRowKeys: rcSelectRows?.map((item) => item[rowKey||'id']),
selectedRowKeys: rcSelectRows?.map((item) => item[rowKey || 'id']),
selectedRowKeys: rcSelectRows
.filter((item) => options.every((row) => row[rowKey] !== item[rowKey]))
.map((item) => item[rowKey]),
},
dataSource: field.value?.filter((item) => options.every((row) => row[rowKey] !== item[rowKey])),
onRowSelectionChange(selectedRowKeys, selectedRows) {
if (multiple) {
const scopeRows = flatData(field.value) || [];
const allSelectedRows = rcSelectRows || [];
const otherRows = differenceBy(allSelectedRows, scopeRows, rowKey || 'id');
const unionSelectedRows = unionBy(otherRows, selectedRows, rowKey || 'id');
const unionSelectedRowKeys = unionSelectedRows.map((item) => item[rowKey || 'id']);
const otherRows = differenceBy(allSelectedRows, scopeRows, rowKey);
const unionSelectedRows = unionBy(otherRows, selectedRows, rowKey);
const unionSelectedRowKeys = unionSelectedRows.map((item) => item[rowKey]);
setSelectedRows?.(unionSelectedRows);
onRowSelectionChange?.(unionSelectedRowKeys, unionSelectedRows);
} else {
setSelectedRows?.(selectedRows);
onRowSelectionChange?.(selectedRowKeys, selectedRows);
onChange(selectedRows?.[0] || null);
setVisible(false);
}
},
};
@ -56,11 +69,11 @@ const useTableSelectorProps = () => {
const usePickActionProps = () => {
const { setVisible } = useActionContext();
const { multiple, selectedRows, onChange } = useContext(RecordPickerContext);
const { multiple, selectedRows, onChange, options } = useContext(RecordPickerContext);
return {
onClick() {
if (multiple) {
onChange(selectedRows);
onChange([...options, ...selectedRows]);
} else {
onChange(selectedRows?.[0] || null);
}
@ -79,8 +92,20 @@ const useAssociation = (props) => {
return getField(fieldSchema.name);
};
export const InputRecordPicker: React.FC<any> = (props) => {
const { value, multiple, onChange, ...others } = props;
interface IRecordPickerProps {
multiple?: boolean;
association?: string;
value?: any;
/** 是否显示 Upload 按钮(仅适用于文件场景下) */
quickUpload?: boolean;
/** 是否显示 Select 按钮(仅适用于文件场景下) */
selectFile?: boolean;
onChange?: (value: any) => void;
[key: string]: any;
}
export const InputRecordPicker: React.FC<any> = (props: IRecordPickerProps) => {
const { value, multiple, onChange, quickUpload, selectFile, ...others } = props;
const fieldNames = useFieldNames(props);
const [visible, setVisible] = useState(false);
const fieldSchema = useFieldSchema();
@ -101,7 +126,6 @@ export const InputRecordPicker: React.FC<any> = (props) => {
};
});
setOptions(opts);
setSelectedRows(opts);
}
}, [value, fieldNames?.label]);
@ -113,13 +137,12 @@ export const InputRecordPicker: React.FC<any> = (props) => {
const handleSelect = () => {
setVisible(true);
setSelectedRows(options);
setSelectedRows([]);
};
const handleRemove = (file) => {
const newOptions = options.filter((option) => option.id !== file.id);
setOptions(newOptions);
setSelectedRows(newOptions);
if (newOptions.length === 0) {
return onChange(null);
}
@ -129,7 +152,25 @@ export const InputRecordPicker: React.FC<any> = (props) => {
return (
<div>
{showFilePicker ? (
<FileSelector value={options} multiple onSelect={handleSelect} onRemove={handleRemove} />
<FileSelector
value={options}
multiple={multiple}
quickUpload={quickUpload}
selectFile={selectFile}
action={`${collectionField.target}:create`}
onSelect={handleSelect}
onRemove={handleRemove}
onChange={(changed) => {
if (changed.every((file) => file.status !== 'uploading')) {
changed = changed.filter((file) => file.status === 'done').map((file) => file.response.data);
if (multiple) {
onChange([...options, ...changed]);
} else {
onChange(changed[0]);
}
}
}}
/>
) : (
<Select
{...others}
@ -157,7 +198,17 @@ export const InputRecordPicker: React.FC<any> = (props) => {
open={false}
/>
)}
{Drawer({ multiple, onChange, selectedRows, setSelectedRows, collectionField, visible, setVisible, fieldSchema })}
{Drawer({
multiple,
onChange,
selectedRows,
setSelectedRows,
collectionField,
visible,
setVisible,
fieldSchema,
options,
})}
</div>
);
};
@ -171,9 +222,20 @@ const Drawer: React.FunctionComponent<{
visible: boolean;
setVisible: React.Dispatch<React.SetStateAction<boolean>>;
fieldSchema;
}> = ({ multiple, onChange, selectedRows, setSelectedRows, collectionField, visible, setVisible, fieldSchema }) => {
options: any[];
}> = ({
multiple,
onChange,
selectedRows,
setSelectedRows,
collectionField,
visible,
setVisible,
fieldSchema,
options,
}) => {
return (
<RecordPickerContext.Provider value={{ multiple, onChange, selectedRows, setSelectedRows }}>
<RecordPickerContext.Provider value={{ multiple, onChange, selectedRows, setSelectedRows, options }}>
<CollectionProvider allowNull name={collectionField?.target}>
<ActionContext.Provider value={{ openMode: 'drawer', visible, setVisible }}>
<FormProvider>

View File

@ -15,7 +15,7 @@ import {
RecordProvider,
useSchemaInitializer,
useTableBlockContext,
useTableSelectorContext
useTableSelectorContext,
} from '../../../';
import { useACLFieldWhitelist } from '../../../acl/ACLProvider';
import { extractIndex, getIdsWithChildren, isCollectionFieldComponent, isColumnComponent } from './utils';
@ -473,6 +473,7 @@ export const Table: any = observer((props: any) => {
}
}}
rowKey={rowKey ?? defaultRowKey}
dataSource={field?.value?.slice?.()}
{...others}
{...restProps}
pagination={paginationProps}
@ -492,7 +493,6 @@ export const Table: any = observer((props: any) => {
},
expandedRowKeys: expandedKeys,
}}
dataSource={field?.value?.slice?.()}
/>
</SortableWrapper>
{field.errors.length > 0 && (

View File

@ -1,4 +1,4 @@
import { DeleteOutlined, DownloadOutlined, InboxOutlined, PlusOutlined } from '@ant-design/icons';
import { DeleteOutlined, DownloadOutlined, InboxOutlined, LoadingOutlined, PlusOutlined } from '@ant-design/icons';
import { usePrefixCls } from '@formily/antd/lib/__builtins__';
import { connect, mapProps, mapReadPretty } from '@formily/react';
import { Upload as AntdUpload, Button, Progress, Space } from 'antd';
@ -24,7 +24,7 @@ export const Upload: ComposedUpload = connect(
);
Upload.Attachment = connect((props: UploadProps) => {
const { disabled, multiple, value, onChange, onRemove } = props;
const { disabled, multiple, value, onChange } = props;
const [fileList, setFileList] = useState([]);
const [sync, setSync] = useState(true);
const images = fileList;
@ -216,12 +216,24 @@ Upload.DraggerV2 = connect(
const defaultSubTitle = t('Support for a single or bulk upload, file size should not exceed') + ` 10MB`;
const { title = defaultTitle, subTitle = defaultSubTitle, useProps } = props;
const extraProps: Record<string, any> = useProps?.() || {};
const [loading, setLoading] = useState(false);
const handleChange = (fileList = []) => {
const { onChange } = extraProps;
onChange?.(fileList);
if (fileList.some((file) => file.status === 'uploading')) {
setLoading(true);
} else {
setLoading(false);
}
};
return (
<div className={usePrefixCls('upload-dragger')}>
<AntdUpload.Dragger {...useUploadProps({ ...props, ...extraProps })}>
<AntdUpload.Dragger {...useUploadProps({ ...props, ...extraProps, onChange: handleChange })}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
{loading ? <LoadingOutlined style={{ fontSize: 36 }} spin /> : <InboxOutlined />}
</p>
<p className="ant-upload-text">{title}</p>
<p className="ant-upload-hint">{subTitle}</p>

View File

@ -1,5 +1,5 @@
import { Field } from '@formily/core';
import { useField, useForm } from '@formily/react';
import { useField } from '@formily/react';
import { reaction } from '@formily/reactive';
import { isArr, isValid, toArr as toArray } from '@formily/shared';
import { UploadChangeParam } from 'antd/lib/upload';

View File

@ -1,5 +1,6 @@
import { useBlockRequestContext } from '@nocobase/client';
import { RecordPickerContext, useActionContext, useBlockRequestContext } from '@nocobase/client';
import { notification } from 'antd';
import { useContext } from 'react';
import { useFmTranslation } from '../locale';
// 限制上传文件大小为 10M
@ -8,6 +9,8 @@ export const FILE_LIMIT_SIZE = 10 * 1024 * 1024;
export const useUploadFiles = () => {
const { service } = useBlockRequestContext();
const { t } = useFmTranslation();
const { setVisible } = useActionContext();
const { setSelectedRows } = useContext(RecordPickerContext) || {};
const uploadingFiles = {};
let pendingNumber = 0;
@ -32,13 +35,21 @@ export const useUploadFiles = () => {
pendingNumber++;
uploadingFiles[file.uid] = true;
}
if (file.status === 'done' && uploadingFiles[file.uid]) {
if (file.status !== 'uploading' && uploadingFiles[file.uid]) {
delete uploadingFiles[file.uid];
if (--pendingNumber === 0) {
service?.refresh?.();
setSelectedRows?.((preRows) => [
...preRows,
...fileList.filter((file) => file.status === 'done').map((file) => file.response.data),
]);
}
}
});
if (fileList.every((file) => file.status === 'done')) {
setVisible(false);
}
},
};
};

View File

@ -12,7 +12,7 @@ export const UploadActionInitializer = (props) => {
'x-component': 'Action',
'x-decorator': 'ACLActionProvider',
'x-component-props': {
openMode: 'modal',
openMode: 'drawer',
type: 'primary',
icon: 'UploadOutlined',
},
@ -27,10 +27,11 @@ export const UploadActionInitializer = (props) => {
title: '{{ t("Upload files") }}',
'x-component': 'Upload.DraggerV2',
'x-component-props': {
height: '60vh',
height: '50vh',
action: `${collection.name}:create`,
useProps: '{{useUploadFiles}}',
multiple: true,
listType: 'picture',
},
},
},