refactor: support attachmentURL (#5313)

* refactor: support  attachmentURL

* refactor: support attachment field

* fix: bug

* refactor: attachment url field

* fix: bug

* fix: bug

* fix: merge bug

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: kanban appends

* fix: action export

* fix: action import

* fix: test

* fix: bug

* fix(client): fix file type check logic

* fix(client): fix image previewer by file type

* fix(client): fix null file type caused error in matching

* fix(client): fix thumbnail data

* refactor: datetime

* test: fix test

* fix(client): fix preview based on file type when url contains search part

* refactor: remote select

* fix: test

* fix: bug

---------

Co-authored-by: mytharcher <mytharcher@gmail.com>
This commit is contained in:
Katherine 2024-10-11 20:15:55 +08:00 committed by GitHub
parent 664eb3df24
commit 15e05d5a2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 297 additions and 321 deletions

View File

@ -49,6 +49,7 @@
"markdown-it-highlightjs": "3.3.1", "markdown-it-highlightjs": "3.3.1",
"mathjs": "^10.6.0", "mathjs": "^10.6.0",
"mermaid": "9.4.3", "mermaid": "9.4.3",
"mime": "^4.0.4",
"mime-match": "^1.0.2", "mime-match": "^1.0.2",
"react-beautiful-dnd": "^13.1.0", "react-beautiful-dnd": "^13.1.0",
"react-drag-listview": "^0.1.9", "react-drag-listview": "^0.1.9",

View File

@ -15,7 +15,7 @@ export class DatetimeFieldInterface extends CollectionFieldInterface {
type = 'object'; type = 'object';
group = 'datetime'; group = 'datetime';
order = 1; order = 1;
title = '{{t("Datetime(with time zone)")}}'; title = '{{t("Datetime (with time zone)")}}';
sortable = true; sortable = true;
default = { default = {
type: 'date', type: 'date',

View File

@ -15,7 +15,7 @@ export class DatetimeNoTzFieldInterface extends CollectionFieldInterface {
type = 'object'; type = 'object';
group = 'datetime'; group = 'datetime';
order = 2; order = 2;
title = '{{t("Datetime(without time zone)")}}'; title = '{{t("Datetime (without time zone)")}}';
sortable = true; sortable = true;
default = { default = {
type: 'datetimeNoTz', type: 'datetimeNoTz',

View File

@ -166,9 +166,9 @@ describe('transformToFilter', () => {
const getCollectionJoinField = vi.fn((name: string) => { const getCollectionJoinField = vi.fn((name: string) => {
if (name === `${collectionName}.field1`) return {}; if (name === `${collectionName}.field1`) return {};
if (name === `${collectionName}.field2`) return {}; if (name === `${collectionName}.field2`) return {};
if (name === `${collectionName}.field3`) return { target: 'targetCollection', targetKey: 'id' }; if (name === `${collectionName}.field3`) return { target: 'targetCollection', targetKey: 'id', type: 'belongsTo' };
if (name === `${collectionName}.chinaRegion`) if (name === `${collectionName}.chinaRegion`)
return { target: 'chinaRegions', targetKey: 'code', interface: 'chinaRegion' }; return { target: 'chinaRegions', targetKey: 'code', interface: 'chinaRegion', type: 'belongsToMany' };
return {}; return {};
}); });

View File

@ -139,7 +139,10 @@ export const transformToFilter = (
let value = _.get(values, key); let value = _.get(values, key);
const collectionField = getCollectionJoinField(`${collectionName}.${key}`); const collectionField = getCollectionJoinField(`${collectionName}.${key}`);
if (collectionField?.target) { if (
collectionField?.target &&
['hasOne', 'hasMany', 'belongsTo', 'belongsToMany', 'belongsToArray'].includes(collectionField.type)
) {
value = getValuesByPath(value, collectionField.targetKey || 'id'); value = getValuesByPath(value, collectionField.targetKey || 'id');
key = `${key}.${collectionField.targetKey || 'id'}`; key = `${key}.${collectionField.targetKey || 'id'}`;

View File

@ -983,8 +983,8 @@
"Automatically update timestamp on update": "当记录更新时自动设置字段值为当前时间", "Automatically update timestamp on update": "当记录更新时自动设置字段值为当前时间",
"Default value to current server time": "设置字段默认值为当前服务端时间", "Default value to current server time": "设置字段默认值为当前服务端时间",
"Automatically update timestamp to the current server time on update": "当记录更新时自动设置字段值为当前服务端时间", "Automatically update timestamp to the current server time on update": "当记录更新时自动设置字段值为当前服务端时间",
"Datetime(with time zone)": "日期时间(含时区)", "Datetime (with time zone)": "日期时间(含时区)",
"Datetime(without time zone)": "日期时间(不含时区)", "Datetime (without time zone)": "日期时间(不含时区)",
"DateOnly": "仅日期", "DateOnly": "仅日期",
"Enable secondary confirmation": "启用二次确认", "Enable secondary confirmation": "启用二次确认",
"Content": "内容", "Content": "内容",

View File

@ -82,14 +82,13 @@ const useTableSelectorProps = () => {
}; };
function FileSelector(props) { function FileSelector(props) {
const { disabled, multiple, value, onChange, action, onSelect, quickUpload, selectFile } = props; const { disabled, multiple, value, onChange, action, onSelect, quickUpload, selectFile, ...other } = props;
const { wrapSSR, hashId, componentCls: prefixCls } = useStyles(); const { wrapSSR, hashId, componentCls: prefixCls } = useStyles();
const { useFileCollectionStorageRules } = useExpressionScope(); const { useFileCollectionStorageRules } = useExpressionScope();
const { t } = useTranslation(); const { t } = useTranslation();
const rules = useFileCollectionStorageRules(); const rules = useFileCollectionStorageRules();
// 兼容旧版本 // 兼容旧版本
const showSelectButton = selectFile === undefined && quickUpload === undefined; const showSelectButton = selectFile === undefined && quickUpload === undefined;
return wrapSSR( return wrapSSR(
<div className={cls(`${prefixCls}-wrapper`, `${prefixCls}-picture-card-wrapper`, 'nb-upload', hashId)}> <div className={cls(`${prefixCls}-wrapper`, `${prefixCls}-picture-card-wrapper`, 'nb-upload', hashId)}>
<div className={cls(`${prefixCls}-list`, `${prefixCls}-list-picture-card`)}> <div className={cls(`${prefixCls}-list`, `${prefixCls}-list-picture-card`)}>
@ -122,6 +121,8 @@ function FileSelector(props) {
onChange={onChange} onChange={onChange}
action={action} action={action}
rules={rules} rules={rules}
disabled={disabled}
{...other}
/> />
) : null} ) : null}
{selectFile && (multiple || !value) ? ( {selectFile && (multiple || !value) ? (
@ -216,6 +217,7 @@ const InternalFileManager = (props) => {
return ( return (
<div style={{ width: '100%', overflow: 'auto' }}> <div style={{ width: '100%', overflow: 'auto' }}>
<FileSelector <FileSelector
{...others}
value={multiple ? options : options?.[0]} value={multiple ? options : options?.[0]}
multiple={multiple} multiple={multiple}
quickUpload={fieldSchema['x-component-props']?.quickUpload !== false} quickUpload={fieldSchema['x-component-props']?.quickUpload !== false}
@ -268,4 +270,4 @@ const FileManageReadPretty = connect((props) => {
); );
}); });
export { FileManageReadPretty, InternalFileManager }; export { FileManageReadPretty, InternalFileManager, FileSelector };

View File

@ -14,7 +14,7 @@ import { InternalPicker } from './InternalPicker';
import { Nester } from './Nester'; import { Nester } from './Nester';
import { ReadPretty } from './ReadPretty'; import { ReadPretty } from './ReadPretty';
import { SubTable } from './SubTable'; import { SubTable } from './SubTable';
import { FileSelector } from './FileManager';
export { export {
AssociationFieldMode, AssociationFieldMode,
AssociationFieldModeProvider, AssociationFieldModeProvider,
@ -29,3 +29,4 @@ AssociationField.Selector = Action.Container;
AssociationField.Viewer = Action.Container; AssociationField.Viewer = Action.Container;
AssociationField.InternalSelect = InternalPicker; AssociationField.InternalSelect = InternalPicker;
AssociationField.ReadPretty = ReadPretty; AssociationField.ReadPretty = ReadPretty;
AssociationField.FileSelector = FileSelector;

View File

@ -21,6 +21,7 @@ import { FieldNames, Select, SelectProps, defaultFieldNames } from '../select';
import { ReadPretty } from './ReadPretty'; import { ReadPretty } from './ReadPretty';
import { useDataSourceHeaders } from '../../../data-source/utils'; import { useDataSourceHeaders } from '../../../data-source/utils';
import { useDataSourceKey } from '../../../data-source/data-source/DataSourceProvider'; import { useDataSourceKey } from '../../../data-source/data-source/DataSourceProvider';
import { withDynamicSchemaProps } from '../../../hoc/withDynamicSchemaProps';
const EMPTY = 'N/A'; const EMPTY = 'N/A';
export type RemoteSelectProps<P = any> = SelectProps<P, any> & { export type RemoteSelectProps<P = any> = SelectProps<P, any> & {
@ -44,250 +45,254 @@ export type RemoteSelectProps<P = any> = SelectProps<P, any> & {
dataSource?: string; dataSource?: string;
CustomDropdownRender?: (v: any) => any; CustomDropdownRender?: (v: any) => any;
optionFilter?: (option: any) => boolean; optionFilter?: (option: any) => boolean;
toOptionsItem?: (data) => any;
}; };
const InternalRemoteSelect = connect( const InternalRemoteSelect = withDynamicSchemaProps(
(props: RemoteSelectProps) => { connect(
const { (props: RemoteSelectProps) => {
fieldNames = {} as FieldNames, const {
service = {}, fieldNames = {} as FieldNames,
wait = 300, service = {},
value, wait = 300,
defaultValue, value,
objectValue, defaultValue,
manual = true, objectValue,
mapOptions, manual = true,
targetField: _targetField, mapOptions,
CustomDropdownRender, targetField: _targetField,
optionFilter, CustomDropdownRender,
dataSource: propsDataSource, optionFilter,
...others dataSource: propsDataSource,
} = props; toOptionsItem = (value) => value,
const dataSource = useDataSourceKey(); ...others
const headers = useDataSourceHeaders(propsDataSource || dataSource); } = props;
const [open, setOpen] = useState(false); const dataSource = useDataSourceKey();
const firstRun = useRef(false); const headers = useDataSourceHeaders(propsDataSource || dataSource);
const fieldSchema = useFieldSchema(); const [open, setOpen] = useState(false);
const isQuickAdd = fieldSchema['x-component-props']?.addMode === 'quickAdd'; const firstRun = useRef(false);
const { getField } = useCollection_deprecated(); const fieldSchema = useFieldSchema();
const searchData = useRef(null); const isQuickAdd = fieldSchema['x-component-props']?.addMode === 'quickAdd';
const { getCollectionJoinField, getInterface } = useCollectionManager_deprecated(); const { getField } = useCollection_deprecated();
const colletionFieldName = fieldSchema['x-collection-field'] || fieldSchema.name; const searchData = useRef(null);
const collectionField = getField(colletionFieldName) || getCollectionJoinField(colletionFieldName); const { getCollectionJoinField, getInterface } = useCollectionManager_deprecated();
const targetField = const colletionFieldName = fieldSchema['x-collection-field'] || fieldSchema.name;
_targetField || const collectionField = getField(colletionFieldName) || getCollectionJoinField(colletionFieldName);
(collectionField?.target && const targetField =
fieldNames?.label && _targetField ||
getCollectionJoinField(`${collectionField.target}.${fieldNames.label}`)); (collectionField?.target &&
fieldNames?.label &&
getCollectionJoinField(`${collectionField.target}.${fieldNames.label}`));
const operator = useMemo(() => { const operator = useMemo(() => {
if (targetField?.interface) { if (targetField?.interface) {
return getInterface(targetField.interface)?.filterable?.operators[0].value || '$includes'; return getInterface(targetField.interface)?.filterable?.operators[0].value || '$includes';
} }
return '$includes'; return '$includes';
}, [targetField]); }, [targetField]);
const compile = useCompile(); const compile = useCompile();
const mapOptionsToTags = useCallback( const mapOptionsToTags = useCallback(
(options) => { (options) => {
try { try {
return options return options
.filter((v) => { .filter((v) => {
return ['number', 'string'].includes(typeof v[fieldNames.value]) || !v[fieldNames.value]; return ['number', 'string'].includes(typeof v[fieldNames.value]) || !v[fieldNames.value];
}) })
.map((option) => { .map((option) => {
let label = compile(option[fieldNames.label]); let label = compile(option[fieldNames.label]);
if (targetField?.uiSchema?.enum) { if (targetField?.uiSchema?.enum) {
if (Array.isArray(label)) { if (Array.isArray(label)) {
label = label label = label
.map((item, index) => { .map((item, index) => {
const option = targetField.uiSchema.enum.find((i) => i.value === item); const option = targetField.uiSchema.enum.find((i) => i.value === item);
if (option) { if (option) {
return ( return (
<Tag role="button" key={index} color={option.color} style={{ marginRight: 3 }}> <Tag role="button" key={index} color={option.color} style={{ marginRight: 3 }}>
{option?.label || item} {option?.label || item}
</Tag> </Tag>
); );
} else { } else {
return ( return (
<Tag role="button" key={item}> <Tag role="button" key={item}>
{item} {item}
</Tag> </Tag>
); );
} }
}) })
.reverse(); .reverse();
} else { } else {
const item = targetField.uiSchema.enum.find((i) => i.value === label); const item = targetField.uiSchema.enum.find((i) => i.value === label);
if (item) { if (item) {
label = ( label = (
<Tag role="button" color={item.color}> <Tag role="button" color={item.color}>
{item.label} {item.label}
</Tag> </Tag>
); );
}
} }
} }
} if (targetField?.type === 'date') {
if (targetField?.type === 'date') { label = dayjs(label).format('YYYY-MM-DD');
label = dayjs(label).format('YYYY-MM-DD'); }
}
if (mapOptions) { if (mapOptions) {
return mapOptions({ return mapOptions({
[fieldNames.label]: label || EMPTY,
[fieldNames.value]: option[fieldNames.value],
});
}
return {
...option,
[fieldNames.label]: label || EMPTY, [fieldNames.label]: label || EMPTY,
[fieldNames.value]: option[fieldNames.value], [fieldNames.value]: option[fieldNames.value],
}); };
} })
return { .filter(Boolean);
...option, } catch (err) {
[fieldNames.label]: label || EMPTY, console.error(err);
[fieldNames.value]: option[fieldNames.value], return options;
}; }
})
.filter(Boolean);
} catch (err) {
console.error(err);
return options;
}
},
[targetField?.uiSchema, fieldNames],
);
const { data, run, loading } = useRequest(
{
action: 'list',
...service,
headers,
params: {
pageSize: 200,
...service?.params,
filter: service?.params?.filter,
}, },
}, [targetField?.uiSchema, fieldNames],
{ );
manual, const { data, run, loading } = useRequest(
debounceWait: wait, {
}, action: 'list',
); ...service,
const runDep = useMemo( headers,
() => params: {
JSON.stringify({ pageSize: 200,
service, ...service?.params,
fieldNames, filter: service?.params?.filter,
}), },
[service, fieldNames], },
); {
const CustomRenderCom = useCallback(() => { manual,
if (searchData.current && CustomDropdownRender) { debounceWait: wait,
return ( },
<CustomDropdownRender );
search={searchData.current} const runDep = useMemo(
callBack={() => { () =>
searchData.current = null; JSON.stringify({
setOpen(false); service,
}} fieldNames,
/> }),
); [service, fieldNames],
} );
return null; const CustomRenderCom = useCallback(() => {
}, [searchData.current]); if (searchData.current && CustomDropdownRender) {
useEffect(() => {
// Lazy load
if (firstRun.current) {
run();
}
}, [runDep]);
const onSearch = async (search) => {
run({
filter: mergeFilter([
search
? {
[fieldNames.label]: {
[operator]: search,
},
}
: {},
service?.params?.filter,
]),
});
searchData.current = search;
};
const options = useMemo(() => {
const v = value || defaultValue;
if (!data?.data?.length) {
return v != null ? (Array.isArray(v) ? v : [v]) : [];
}
const valueOptions =
(v != null && (Array.isArray(v) ? v : [{ ...v, [fieldNames.value]: v[fieldNames.value] || v }])) || [];
const filtered = typeof optionFilter === 'function' ? data.data.filter(optionFilter) : data.data;
return uniqBy(filtered.concat(valueOptions ?? []), fieldNames.value);
}, [value, defaultValue, data?.data, fieldNames.value, optionFilter]);
const onDropdownVisibleChange = (visible) => {
setOpen(visible);
searchData.current = null;
if (visible) {
run();
}
firstRun.current = true;
};
return (
<Select
open={open}
popupMatchSelectWidth={false}
autoClearSearchValue
filterOption={false}
filterSort={null}
fieldNames={fieldNames as any}
onSearch={onSearch}
onDropdownVisibleChange={onDropdownVisibleChange}
objectValue={objectValue}
value={value}
defaultValue={defaultValue}
{...others}
loading={data! ? loading : true}
options={mapOptionsToTags(options)}
rawOptions={options}
dropdownRender={(menu) => {
const isFullMatch = options.some((v) => v[fieldNames.label] === searchData.current);
return ( return (
<> <CustomDropdownRender
{isQuickAdd ? ( search={searchData.current}
<> callBack={() => {
{!(data?.data.length === 0 && searchData?.current) && menu} searchData.current = null;
{data?.data.length > 0 && searchData?.current && !isFullMatch && <Divider style={{ margin: 0 }} />} setOpen(false);
{!isFullMatch && <CustomRenderCom />} }}
</> />
) : (
menu
)}
</>
); );
}} }
/> return null;
); }, [searchData.current]);
},
mapProps( useEffect(() => {
{ // Lazy load
dataSource: 'options', if (firstRun.current) {
}, run();
(props, field) => { }
const fieldSchema = useFieldSchema(); }, [runDep]);
return {
...props, const onSearch = async (search) => {
fieldNames: { run({
...defaultFieldNames, filter: mergeFilter([
...props.fieldNames, search
...field.componentProps.fieldNames, ? {
...fieldSchema['x-component-props']?.fieldNames, [fieldNames.label]: {
}, [operator]: search,
suffixIcon: field?.['loading'] || field?.['validating'] ? <LoadingOutlined /> : props.suffixIcon, },
}
: {},
service?.params?.filter,
]),
});
searchData.current = search;
}; };
const options = useMemo(() => {
const v = value || defaultValue;
if (!data?.data?.length) {
return v != null ? (Array.isArray(v) ? v : [v]) : [];
}
const valueOptions =
(v != null && (Array.isArray(v) ? v : [{ ...v, [fieldNames.value]: v[fieldNames.value] || v }])) || [];
const filtered = typeof optionFilter === 'function' ? data.data.filter(optionFilter) : data.data;
return uniqBy(filtered.concat(valueOptions ?? []), fieldNames.value);
}, [value, defaultValue, data?.data, fieldNames.value, optionFilter]);
const onDropdownVisibleChange = (visible) => {
setOpen(visible);
searchData.current = null;
if (visible) {
run();
}
firstRun.current = true;
};
return (
<Select
open={open}
popupMatchSelectWidth={false}
autoClearSearchValue
filterOption={false}
filterSort={null}
fieldNames={fieldNames as any}
onSearch={onSearch}
onDropdownVisibleChange={onDropdownVisibleChange}
objectValue={objectValue}
value={value}
defaultValue={defaultValue}
{...others}
loading={data! ? loading : true}
options={toOptionsItem(mapOptionsToTags(options))}
rawOptions={options}
dropdownRender={(menu) => {
const isFullMatch = options.some((v) => v[fieldNames.label] === searchData.current);
return (
<>
{isQuickAdd ? (
<>
{!(data?.data.length === 0 && searchData?.current) && menu}
{data?.data.length > 0 && searchData?.current && !isFullMatch && <Divider style={{ margin: 0 }} />}
{!isFullMatch && <CustomRenderCom />}
</>
) : (
menu
)}
</>
);
}}
/>
);
}, },
mapProps(
{
dataSource: 'options',
},
(props, field) => {
const fieldSchema = useFieldSchema();
return {
...props,
fieldNames: {
...defaultFieldNames,
...props.fieldNames,
...field.componentProps.fieldNames,
...fieldSchema['x-component-props']?.fieldNames,
},
suffixIcon: field?.['loading'] || field?.['validating'] ? <LoadingOutlined /> : props.suffixIcon,
};
},
),
mapReadPretty(ReadPretty),
), ),
mapReadPretty(ReadPretty),
); );
export const RemoteSelect = InternalRemoteSelect as unknown as typeof InternalRemoteSelect & { export const RemoteSelect = InternalRemoteSelect as unknown as typeof InternalRemoteSelect & {

View File

@ -18,7 +18,6 @@ import filesize from 'filesize';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import LightBox from 'react-image-lightbox'; import LightBox from 'react-image-lightbox';
import match from 'mime-match';
import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app
import { withDynamicSchemaProps } from '../../../hoc/withDynamicSchemaProps'; import { withDynamicSchemaProps } from '../../../hoc/withDynamicSchemaProps';
import { useProps } from '../../hooks/useProps'; import { useProps } from '../../hooks/useProps';
@ -26,9 +25,10 @@ import {
FILE_SIZE_LIMIT_DEFAULT, FILE_SIZE_LIMIT_DEFAULT,
attachmentFileTypes, attachmentFileTypes,
getThumbnailPlaceholderURL, getThumbnailPlaceholderURL,
matchMimetype,
normalizeFile, normalizeFile,
toFileList, toFileList,
toValueItem, toValueItem as toValueItemDefault,
useBeforeUpload, useBeforeUpload,
useUploadProps, useUploadProps,
} from './shared'; } from './shared';
@ -37,7 +37,7 @@ import type { ComposedUpload, DraggerProps, DraggerV2Props, UploadProps } from '
attachmentFileTypes.add({ attachmentFileTypes.add({
match(file) { match(file) {
return match(file.mimetype || file.type, 'image/*'); return matchMimetype(file, 'image/*');
}, },
getThumbnailURL(file) { getThumbnailURL(file) {
return file.url ? `${file.url}${file.thumbnailRule || ''}` : URL.createObjectURL(file.originFileObj); return file.url ? `${file.url}${file.thumbnailRule || ''}` : URL.createObjectURL(file.originFileObj);
@ -136,7 +136,7 @@ function IframePreviewer({ index, list, onSwitchIndex }) {
overflowY: 'auto', overflowY: 'auto',
}} }}
> >
{iframePreviewSupportedTypes.some((type) => match(file.mimetype || file.extname, type)) ? ( {iframePreviewSupportedTypes.some((type) => matchMimetype(file, type)) ? (
<iframe <iframe
src={file.url} src={file.url}
style={{ style={{
@ -174,7 +174,6 @@ function ReadPretty({ value, onChange, disabled, multiple, size }: UploadProps)
const useUploadStyleVal = (useUploadStyle as any).default ? (useUploadStyle as any).default : useUploadStyle; const useUploadStyleVal = (useUploadStyle as any).default ? (useUploadStyle as any).default : useUploadStyle;
// 加载 antd 的样式 // 加载 antd 的样式
useUploadStyleVal(prefixCls); useUploadStyleVal(prefixCls);
return wrapSSR( return wrapSSR(
<div <div
className={cls( className={cls(
@ -245,12 +244,12 @@ function AttachmentListItem(props) {
{file.status === 'uploading' ? t('Uploading') : file.title} {file.status === 'uploading' ? t('Uploading') : file.title}
</span>, </span>,
]; ];
const wrappedItem = file.id ? ( const wrappedItem = file.url ? (
<a target="_blank" rel="noopener noreferrer" href={file.url} onClick={handleClick}> <a target="_blank" rel="noopener noreferrer" href={file.url} onClick={handleClick}>
{item} {item}
</a> </a>
) : ( ) : (
<span className={`${prefixCls}-span`}>{item}</span> <span className={`${prefixCls}-span`}>{item}3</span>
); );
const content = ( const content = (
@ -264,7 +263,7 @@ function AttachmentListItem(props) {
<div className={`${prefixCls}-list-item-info`}>{wrappedItem}</div> <div className={`${prefixCls}-list-item-info`}>{wrappedItem}</div>
<span className={`${prefixCls}-list-item-actions`}> <span className={`${prefixCls}-list-item-actions`}>
<Space size={3}> <Space size={3}>
{!readPretty && file.id && ( {!readPretty && file.url && (
<Button size={'small'} type={'text'} icon={<DownloadOutlined />} onClick={onDownload} /> <Button size={'small'} type={'text'} icon={<DownloadOutlined />} onClick={onDownload} />
)} )}
{!readPretty && !disabled && file.status !== 'uploading' && ( {!readPretty && !disabled && file.status !== 'uploading' && (
@ -299,7 +298,6 @@ function Previewer({ index, onSwitchIndex, list }) {
} }
const file = list[index]; const file = list[index];
const { Previewer: Component = IframePreviewer } = attachmentFileTypes.getTypeByFile(file) ?? {}; const { Previewer: Component = IframePreviewer } = attachmentFileTypes.getTypeByFile(file) ?? {};
return <Component index={index} list={list} onSwitchIndex={onSwitchIndex} />; return <Component index={index} list={list} onSwitchIndex={onSwitchIndex} />;
} }
@ -331,12 +329,11 @@ export function AttachmentList(props) {
}, },
[multiple, onChange, value], [multiple, onChange, value],
); );
return ( return (
<> <>
{fileList.map((file, index) => ( {fileList.map((file, index) => (
<AttachmentListItem <AttachmentListItem
key={file.id} key={file.id || file.url}
file={file} file={file}
index={index} index={index}
disabled={disabled} disabled={disabled}
@ -351,7 +348,7 @@ export function AttachmentList(props) {
} }
export function Uploader({ rules, ...props }: UploadProps) { export function Uploader({ rules, ...props }: UploadProps) {
const { disabled, multiple, value, onChange } = props; const { disabled, multiple, value, onChange, toValueItem = toValueItemDefault } = props;
const [pendingList, setPendingList] = useState<any[]>([]); const [pendingList, setPendingList] = useState<any[]>([]);
const { t } = useTranslation(); const { t } = useTranslation();
const { componentCls: prefixCls } = useStyles(); const { componentCls: prefixCls } = useStyles();
@ -378,7 +375,7 @@ export function Uploader({ rules, ...props }: UploadProps) {
if (multiple) { if (multiple) {
const uploadedList = info.fileList.filter((file) => file.status === 'done'); const uploadedList = info.fileList.filter((file) => file.status === 'done');
if (uploadedList.length) { if (uploadedList.length) {
const valueList = [...(value ?? []), ...uploadedList.map(toValueItem)]; const valueList = [...(value ?? []), ...uploadedList.map((v) => toValueItem(v.response?.data))];
onChange?.(valueList); onChange?.(valueList);
} }
setPendingList(info.fileList.filter((file) => file.status !== 'done').map(normalizeFile)); setPendingList(info.fileList.filter((file) => file.status !== 'done').map(normalizeFile));
@ -386,7 +383,7 @@ export function Uploader({ rules, ...props }: UploadProps) {
// NOTE: 用 fileList 里的才有附加的验证状态信息file 没有(不清楚为何) // NOTE: 用 fileList 里的才有附加的验证状态信息file 没有(不清楚为何)
const file = info.fileList.find((f) => f.uid === info.file.uid); const file = info.fileList.find((f) => f.uid === info.file.uid);
if (file.status === 'done') { if (file.status === 'done') {
onChange?.(toValueItem(file)); onChange?.(toValueItem(file.response?.data));
setPendingList([]); setPendingList([]);
} else { } else {
setPendingList([normalizeFile(file)]); setPendingList([normalizeFile(file)]);
@ -408,7 +405,6 @@ export function Uploader({ rules, ...props }: UploadProps) {
const sizeHint = useSizeHint(size); const sizeHint = useSizeHint(size);
const selectable = const selectable =
!disabled && (multiple || ((!value || (Array.isArray(value) && !value.length)) && !pendingList.length)); !disabled && (multiple || ((!value || (Array.isArray(value) && !value.length)) && !pendingList.length));
return ( return (
<> <>
{pendingList.map((file, index) => ( {pendingList.map((file, index) => (
@ -451,7 +447,6 @@ export function Uploader({ rules, ...props }: UploadProps) {
function Attachment(props: UploadProps) { function Attachment(props: UploadProps) {
const { wrapSSR, hashId, componentCls: prefixCls } = useStyles(); const { wrapSSR, hashId, componentCls: prefixCls } = useStyles();
return wrapSSR( return wrapSSR(
<div className={cls(`${prefixCls}-wrapper`, `${prefixCls}-picture-card-wrapper`, 'nb-upload', hashId)}> <div className={cls(`${prefixCls}-wrapper`, `${prefixCls}-picture-card-wrapper`, 'nb-upload', hashId)}>
<div className={cls(`${prefixCls}-list`, `${prefixCls}-list-picture-card`)}> <div className={cls(`${prefixCls}-list`, `${prefixCls}-list-picture-card`)}>

View File

@ -10,6 +10,7 @@
import { isArr, isValid, toArr as toArray } from '@formily/shared'; import { isArr, isValid, toArr as toArray } from '@formily/shared';
import { UploadFile } from 'antd/es/upload/interface'; import { UploadFile } from 'antd/es/upload/interface';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import mime from 'mime';
import match from 'mime-match'; import match from 'mime-match';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useAPIClient } from '../../../api-client'; import { useAPIClient } from '../../../api-client';
@ -58,6 +59,17 @@ export class AttachmentFileTypes {
*/ */
export const attachmentFileTypes = new AttachmentFileTypes(); export const attachmentFileTypes = new AttachmentFileTypes();
export function matchMimetype(file: FileModel, type: string) {
if (file.mimetype) {
return match(file.mimetype, type);
}
if (file.url) {
const [fileUrl] = file.url.split('?');
return match(mime.getType(fileUrl) || '', type);
}
return false;
}
const toArr = (value) => { const toArr = (value) => {
if (!isValid(value)) { if (!isValid(value)) {
return []; return [];
@ -81,6 +93,9 @@ const testOpts = (ext: RegExp, options: { exclude?: string[]; include?: string[]
}; };
export function getThumbnailPlaceholderURL(file, options: any = {}) { export function getThumbnailPlaceholderURL(file, options: any = {}) {
if (file.url) {
return file.url;
}
for (let i = 0; i < UPLOAD_PLACEHOLDER.length; i++) { for (let i = 0; i < UPLOAD_PLACEHOLDER.length; i++) {
// console.log(UPLOAD_PLACEHOLDER[i].ext, testOpts(UPLOAD_PLACEHOLDER[i].ext, options)); // console.log(UPLOAD_PLACEHOLDER[i].ext, testOpts(UPLOAD_PLACEHOLDER[i].ext, options));
if (UPLOAD_PLACEHOLDER[i].ext.test(file.extname || file.filename || file.url || file.name)) { if (UPLOAD_PLACEHOLDER[i].ext.test(file.extname || file.filename || file.url || file.name)) {
@ -166,11 +181,16 @@ export function useUploadProps<T extends IUploadProps = UploadProps>(props: T) {
}; };
} }
export function toValueItem(file) { export function toValueItem(data) {
return file.response?.data; return data;
} }
export const toItem = (file) => { export const toItem = (file) => {
if (typeof file === 'string') {
return {
url: file,
};
}
if (file?.response?.data) { if (file?.response?.data) {
file = { file = {
uid: file.uid, uid: file.uid,

View File

@ -19,6 +19,7 @@ export type UploadProps = Omit<AntdUploadProps, 'onChange'> & {
value?: any; value?: any;
size?: string; size?: string;
rules?: PropsRules; rules?: PropsRules;
toValueItem?: function;
}; };
export type DraggerProps = Omit<AntdDraggerProps, 'onChange'> & { export type DraggerProps = Omit<AntdDraggerProps, 'onChange'> & {

View File

@ -32,7 +32,7 @@ export const useFieldModeOptions = (props?) => {
return; return;
} }
if ( if (
!['o2o', 'oho', 'obo', 'o2m', 'linkTo', 'm2o', 'm2m', 'updatedBy', 'createdBy', 'mbm'].includes( !['o2o', 'oho', 'obo', 'o2m', 'linkTo', 'm2o', 'm2m', 'updatedBy', 'createdBy', 'mbm', 'attachmentURL'].includes(
collectionField.interface, collectionField.interface,
) )
) )

View File

@ -510,6 +510,7 @@ export const useAssociatedFormItemInitializerFields = (options?: any) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { readPretty = form.readPretty, block = 'Form' } = options || {}; const { readPretty = form.readPretty, block = 'Form' } = options || {};
const interfaces = block === 'Form' ? ['m2o'] : ['o2o', 'oho', 'obo', 'm2o']; const interfaces = block === 'Form' ? ['m2o'] : ['o2o', 'oho', 'obo', 'm2o'];
const groups = fields const groups = fields
?.filter((field) => { ?.filter((field) => {
return interfaces.includes(field.interface); return interfaces.includes(field.interface);
@ -592,8 +593,9 @@ const associationFieldToMenu = (
export const useFilterAssociatedFormItemInitializerFields = () => { export const useFilterAssociatedFormItemInitializerFields = () => {
const { name, fields } = useCollection_deprecated(); const { name, fields } = useCollection_deprecated();
const { getCollectionFields } = useCollectionManager_deprecated(); const { getCollectionFields } = useCollectionManager_deprecated();
const interfaces = ['o2o', 'oho', 'obo', 'm2o', 'm2m'];
return fields return fields
?.filter((field) => field.target && field.uiSchema) ?.filter((field) => field.target && field.uiSchema && interfaces.includes(field.interface))
.map((field) => associationFieldToMenu(field, field.name, name, getCollectionFields, [])) .map((field) => associationFieldToMenu(field, field.name, name, getCollectionFields, []))
.filter(Boolean); .filter(Boolean);
}; };

View File

@ -28,7 +28,7 @@ export const useFields = (collectionName: string) => {
return option; return option;
} }
if (field.target) { if (field.target && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany', 'belongsToArray'].includes(field.type)) {
const targetFields = getCollectionFields(field.target); const targetFields = getCollectionFields(field.target);
const options = getOptions(targetFields, depth + 1).filter(Boolean); const options = getOptions(targetFields, depth + 1).filter(Boolean);
option['children'] = option['children'] || []; option['children'] = option['children'] || [];

View File

@ -39,7 +39,7 @@ export const useFields = (collectionName: string) => {
return option; return option;
} }
if (field.target) { if (field.target && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany', 'belongsToArray'].includes(field.type)) {
const targetFields = getCollectionFields(field.target); const targetFields = getCollectionFields(field.target);
const options = getOptions(targetFields, depth + 1).filter(Boolean); const options = getOptions(targetFields, depth + 1).filter(Boolean);
option['children'] = option['children'] || []; option['children'] = option['children'] || [];

View File

@ -75,8 +75,8 @@ test.describe('configure fields', () => {
await addField('Attachment'); await addField('Attachment');
// 添加 date & time 字段 // 添加 date & time 字段
await addField('Datetime(with time zone)'); await addField('Datetime (with time zone)');
await addField('Datetime(without time zone)'); await addField('Datetime (without time zone)');
await addField('DateOnly'); await addField('DateOnly');
await addField('Unix Timestamp'); await addField('Unix Timestamp');
await addField('Time'); await addField('Time');

View File

@ -430,8 +430,8 @@ export type FieldInterface =
| 'Markdown' | 'Markdown'
| 'Rich Text' | 'Rich Text'
| 'Attachment' | 'Attachment'
| 'Datetime(with time zone)' | 'Datetime (with time zone)'
| 'Datetime(without time zone)' | 'Datetime (without time zone)'
| 'Date' | 'Date'
| 'Time' | 'Time'
| 'Unix Timestamp' | 'Unix Timestamp'

View File

@ -8,14 +8,12 @@
*/ */
import { ArrayField } from '@formily/core'; import { ArrayField } from '@formily/core';
import { Schema, useField, useFieldSchema } from '@formily/react'; import { useField } from '@formily/react';
import { Spin } from 'antd'; import { Spin } from 'antd';
import uniq from 'lodash/uniq';
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { import {
useACLRoleContext, useACLRoleContext,
useCollection_deprecated, useCollection_deprecated,
useCollectionManager_deprecated,
FixedBlockWrapper, FixedBlockWrapper,
BlockProvider, BlockProvider,
useBlockRequestContext, useBlockRequestContext,
@ -70,60 +68,8 @@ const InternalKanbanBlockProvider = (props) => {
); );
}; };
const recursiveProperties = (schema: Schema, component = 'CollectionField', associationFields, appends: any = []) => {
schema.mapProperties((s: any) => {
const name = s.name.toString();
if (s['x-component'] === component && !appends.includes(name)) {
// 关联字段和关联的关联字段
const [firstName] = name.split('.');
if (associationFields.has(name)) {
appends.push(name);
} else if (associationFields.has(firstName) && !appends.includes(firstName)) {
appends.push(firstName);
}
} else {
recursiveProperties(s, component, associationFields, appends);
}
});
};
const useAssociationNames = (collection) => {
const { getCollectionFields } = useCollectionManager_deprecated(collection.dataSource);
const collectionFields = getCollectionFields(collection);
const associationFields = new Set();
for (const collectionField of collectionFields) {
if (collectionField.target) {
associationFields.add(collectionField.name);
const fields = getCollectionFields(collectionField.target);
for (const field of fields) {
if (field.target) {
associationFields.add(`${collectionField.name}.${field.name}`);
}
}
}
}
const fieldSchema = useFieldSchema();
const kanbanSchema = fieldSchema.reduceProperties((buf, schema) => {
if (schema['x-component'].startsWith('Kanban')) {
return schema;
}
return buf;
}, new Schema({}));
const gridSchema: any = kanbanSchema?.properties?.card?.properties?.grid;
const appends = [];
if (gridSchema) {
recursiveProperties(gridSchema, 'CollectionField', associationFields, appends);
}
return uniq(appends);
};
export const KanbanBlockProvider = (props) => { export const KanbanBlockProvider = (props) => {
const params = { ...props.params }; const params = { ...props.params };
const appends = useAssociationNames(props.association || props.collection);
if (!Object.keys(params).includes('appends')) {
params['appends'] = appends;
}
return ( return (
<BlockProvider name="kanban" {...props} params={params}> <BlockProvider name="kanban" {...props} params={params}>
<InternalKanbanBlockProvider {...props} params={params} /> <InternalKanbanBlockProvider {...props} params={params} />

View File

@ -565,7 +565,7 @@ test.describe('field data entry', () => {
await page await page
.locator(`button[aria-label^="schema-initializer-Grid-workflowManual:customForm:configureFields-${randomValue}"]`) .locator(`button[aria-label^="schema-initializer-Grid-workflowManual:customForm:configureFields-${randomValue}"]`)
.hover(); .hover();
await page.getByRole('menuitem', { name: 'Datetime(with time zone)' }).click(); await page.getByRole('menuitem', { name: 'Datetime (with time zone)' }).click();
await page await page
.getByLabel(`block-item-Input-${randomValue}-Field display name`) .getByLabel(`block-item-Input-${randomValue}-Field display name`)
.getByRole('textbox') .getByRole('textbox')