feat: support selecting existing data in subform (#5849)

* feat: subform support selecting existing data

* chore: add new button in sub form

* fix: build

* fix: bug

* fix: test

* fix: test
This commit is contained in:
Katherine 2024-12-12 22:10:14 +08:00 committed by GitHub
parent 5132d460c1
commit e04c404b4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 177 additions and 44 deletions

View File

@ -2,9 +2,7 @@
"version": "1.6.0-alpha.5", "version": "1.6.0-alpha.5",
"npmClient": "yarn", "npmClient": "yarn",
"useWorkspaces": true, "useWorkspaces": true,
"npmClientArgs": [ "npmClientArgs": ["--ignore-engines"],
"--ignore-engines"
],
"command": { "command": {
"version": { "version": {
"forcePublish": true, "forcePublish": true,

View File

@ -22,6 +22,7 @@ import {
} from '../../../../schema-component/antd/form-item/FormItem.Settings'; } from '../../../../schema-component/antd/form-item/FormItem.Settings';
import { linkageRules, setDefaultSortingRules } from '../SubTable/subTablePopoverComponentFieldSettings'; import { linkageRules, setDefaultSortingRules } from '../SubTable/subTablePopoverComponentFieldSettings';
import { SchemaSettingsLayoutItem } from '../../../../schema-settings/SchemaSettingsLayoutItem'; import { SchemaSettingsLayoutItem } from '../../../../schema-settings/SchemaSettingsLayoutItem';
import { allowSelectExistingRecord } from '../SubTable/subTablePopoverComponentFieldSettings';
const allowMultiple: any = { const allowMultiple: any = {
name: 'allowMultiple', name: 'allowMultiple',
@ -108,6 +109,7 @@ export const subformComponentFieldSettings = new SchemaSettings({
items: [ items: [
fieldComponent, fieldComponent,
allowMultiple, allowMultiple,
allowSelectExistingRecord,
{ {
name: 'allowDissociate', name: 'allowDissociate',
type: 'switch', type: 'switch',

View File

@ -72,7 +72,7 @@ const fieldComponent: any = {
}; };
}, },
}; };
const allowSelectExistingRecord = { export const allowSelectExistingRecord = {
name: 'allowSelectExistingRecord', name: 'allowSelectExistingRecord',
type: 'switch', type: 'switch',
useVisible() { useVisible() {

View File

@ -44,6 +44,7 @@ export const InternalNester = observer(
const field = useField(); const field = useField();
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const insertNester = useInsertSchema('Nester'); const insertNester = useInsertSchema('Nester');
const insertSelector = useInsertSchema('Selector');
const { options: collectionField } = useAssociationFieldContext(); const { options: collectionField } = useAssociationFieldContext();
const showTitle = fieldSchema['x-decorator-props']?.showTitle ?? true; const showTitle = fieldSchema['x-decorator-props']?.showTitle ?? true;
const { actionName } = useACLActionParamsContext(); const { actionName } = useACLActionParamsContext();
@ -58,7 +59,11 @@ export const InternalNester = observer(
useEffect(() => { useEffect(() => {
insertNester(schema.Nester); insertNester(schema.Nester);
}, []); }, []);
useEffect(() => {
if (field.componentProps?.allowSelectExistingRecord) {
insertSelector(schema.Selector);
}
}, [field.componentProps?.allowSelectExistingRecord]);
return ( return (
<CollectionProvider_deprecated name={collectionField.target}> <CollectionProvider_deprecated name={collectionField.target}>
<ACLCollectionProvider actionPath={`${collectionField.target}:${actionName || 'view'}`}> <ACLCollectionProvider actionPath={`${collectionField.target}:${actionName || 'view'}`}>

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { CloseOutlined, PlusOutlined } from '@ant-design/icons'; import { CloseOutlined, PlusOutlined, ZoomInOutlined } from '@ant-design/icons';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { ArrayField } from '@formily/core'; import { ArrayField } from '@formily/core';
import { spliceArrayState } from '@formily/core/esm/shared/internals'; import { spliceArrayState } from '@formily/core/esm/shared/internals';
@ -15,11 +15,12 @@ import { observer, useFieldSchema } from '@formily/react';
import { action } from '@formily/reactive'; import { action } from '@formily/reactive';
import { each } from '@formily/shared'; import { each } from '@formily/shared';
import { useUpdate } from 'ahooks'; import { useUpdate } from 'ahooks';
import { Button, Card, Divider, Tooltip } from 'antd'; import { Button, Card, Divider, Tooltip, Space } from 'antd';
import React, { useCallback, useContext } from 'react'; import React, { useCallback, useContext, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FormActiveFieldsProvider } from '../../../block-provider/hooks/useFormActiveFields'; import { FormActiveFieldsProvider } from '../../../block-provider/hooks/useFormActiveFields';
import { useCollection } from '../../../data-source'; import { useCollection, CollectionProvider } from '../../../data-source';
import { useCreateActionProps } from '../../../block-provider/hooks';
import { import {
useCollectionRecord, useCollectionRecord,
useCollectionRecordData, useCollectionRecordData,
@ -29,13 +30,25 @@ import { FlagProvider } from '../../../flag-provider';
import { NocoBaseRecursionField, RefreshComponentProvider } from '../../../formily/NocoBaseRecursionField'; import { NocoBaseRecursionField, RefreshComponentProvider } from '../../../formily/NocoBaseRecursionField';
import { RecordIndexProvider, RecordProvider } from '../../../record-provider'; import { RecordIndexProvider, RecordProvider } from '../../../record-provider';
import { isPatternDisabled, isSystemField } from '../../../schema-settings'; import { isPatternDisabled, isSystemField } from '../../../schema-settings';
import { TableSelectorParamsProvider } from '../../../block-provider/TableSelectorProvider';
import { import {
DefaultValueProvider, DefaultValueProvider,
IsAllowToSetDefaultValueParams, IsAllowToSetDefaultValueParams,
interfacesOfUnsupportedDefaultValue, interfacesOfUnsupportedDefaultValue,
} from '../../../schema-settings/hooks/useIsAllowToSetDefaultValue'; } from '../../../schema-settings/hooks/useIsAllowToSetDefaultValue';
import { AssociationFieldContext } from './context'; import { AssociationFieldContext } from './context';
import { SubFormProvider, useAssociationFieldContext } from './hooks'; import { SubFormProvider, useAssociationFieldContext, useFieldNames } from './hooks';
import { Action, ActionContextProvider } from '../action';
import { useCompile } from '../../hooks';
import {
FormProvider,
RecordPickerProvider,
SchemaComponentOptions,
useActionContext,
RecordPickerContext,
} from '../..';
import { useTableSelectorProps } from './InternalPicker';
import { getLabelFormatValue, useLabelUiSchema } from './util';
export const Nester = (props) => { export const Nester = (props) => {
const { options } = useContext(AssociationFieldContext); const { options } = useContext(AssociationFieldContext);
@ -109,14 +122,32 @@ const ToOneNester = (props) => {
}; };
const ToManyNester = observer( const ToManyNester = observer(
(props) => { (props: any) => {
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const { options, field, allowMultiple, allowDissociate } = useAssociationFieldContext<ArrayField>(); const {
options: collectionField,
field,
allowMultiple,
allowDissociate,
currentMode,
} = useAssociationFieldContext<ArrayField>();
const { allowSelectExistingRecord } = field.componentProps;
const { t } = useTranslation(); const { t } = useTranslation();
const recordData = useCollectionRecordData(); const recordData = useCollectionRecordData();
const collection = useCollection(); const collection = useCollection();
const update = useUpdate(); const update = useUpdate();
const [visibleSelector, setVisibleSelector] = useState(false);
const [selectedRows, setSelectedRows] = useState([]);
const fieldNames = useFieldNames(props);
const compile = useCompile();
const labelUiSchema = useLabelUiSchema(collectionField, fieldNames?.label || 'label');
const useNesterSelectProps = () => {
return {
run() {
setVisibleSelector(true);
},
};
};
if (!Array.isArray(field.value)) { if (!Array.isArray(field.value)) {
field.value = []; field.value = [];
} }
@ -146,6 +177,54 @@ const ToManyNester = observer(
); );
}, []); }, []);
const usePickActionProps = () => {
const { setVisible } = useActionContext();
const { selectedRows, setSelectedRows } = useContext(RecordPickerContext);
return {
onClick() {
selectedRows.map((v) => field.value.push(markRecordAsNew(v)));
field.onInput(field.value);
field.initialValue = field.value;
setSelectedRows([]);
setVisible(false);
},
};
};
const options = useMemo(() => {
if (field.value && Object.keys(field.value).length > 0) {
const opts = (Array.isArray(field.value) ? field.value : field.value ? [field.value] : [])
.filter(Boolean)
.map((option) => {
const label = option?.[fieldNames.label];
return {
...option,
[fieldNames.label]: getLabelFormatValue(compile(labelUiSchema), compile(label)),
};
});
return opts;
}
return [];
}, [field.value, fieldNames?.label]);
const pickerProps = {
size: 'small',
fieldNames: field.componentProps.fieldNames,
multiple: true,
association: {
target: collectionField?.target,
},
options,
onChange: props?.onChange,
selectedRows,
setSelectedRows,
collectionField,
};
const getFilter = () => {
const targetKey = collectionField?.targetKey || 'id';
const list = (field.value || []).map((option) => option?.[targetKey]).filter(Boolean);
const filter = list.length ? { $and: [{ [`${targetKey}.$ne`]: list }] } : {};
return filter;
};
return field.value.length > 0 ? ( return field.value.length > 0 ? (
<Card <Card
bordered={true} bordered={true}
@ -160,32 +239,11 @@ const ToManyNester = observer(
{field.value.map((value, index) => { {field.value.map((value, index) => {
let allowed = allowDissociate; let allowed = allowDissociate;
if (!allowDissociate) { if (!allowDissociate) {
allowed = !value?.[options.targetKey]; allowed = !value?.[collectionField.targetKey];
} }
return ( return (
<React.Fragment key={index}> <React.Fragment key={index}>
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
{field.editable && allowMultiple && (
<Tooltip key={'add'} title={t('Add new')}>
<PlusOutlined
style={{ zIndex: 1000, marginRight: '10px', color: '#a8a3a3' }}
onClick={() => {
action(() => {
if (!Array.isArray(field.value)) {
field.value = [];
}
field.value.splice(index + 1, 0, markRecordAsNew({}));
each(field.form.fields, (targetField, key) => {
if (!targetField) {
delete field.form.fields[key];
}
});
return field.onInput(field.value);
});
}}
/>
</Tooltip>
)}
{!field.readPretty && allowed && ( {!field.readPretty && allowed && (
<Tooltip key={'remove'} title={t('Remove')}> <Tooltip key={'remove'} title={t('Remove')}>
<CloseOutlined <CloseOutlined
@ -219,11 +277,85 @@ const ToManyNester = observer(
</RecordProvider> </RecordProvider>
</SubFormProvider> </SubFormProvider>
</FormActiveFieldsProvider> </FormActiveFieldsProvider>
<Divider /> <Divider />
</React.Fragment> </React.Fragment>
); );
})} })}
<Space>
{field.editable && allowMultiple && (
<Action.Link
useProps={() => {
return {
onClick: () => {
action(() => {
if (!Array.isArray(field.value)) {
field.value = [];
}
const index = field.value.length;
field.value.splice(index, 0, markRecordAsNew({}));
each(field.form.fields, (targetField, key) => {
if (!targetField) {
delete field.form.fields[key];
}
});
return field.onInput(field.value);
});
},
};
}}
title={
<Space style={{ gap: 2 }} className="nb-sub-form-addNew">
<PlusOutlined /> {t('Add new')}
</Space>
}
/>
)}
{field.editable && allowSelectExistingRecord && currentMode === 'Nester' && (
<Action.Link
useAction={useNesterSelectProps}
title={
<Space style={{ gap: 2 }}>
<ZoomInOutlined /> {t('Select record')}
</Space>
}
/>
)}
</Space>
</RefreshComponentProvider> </RefreshComponentProvider>
<ActionContextProvider
value={{
openSize: 'middle',
openMode: 'drawer',
visible: visibleSelector,
setVisible: setVisibleSelector,
}}
>
<RecordPickerProvider {...pickerProps}>
<CollectionProvider name={collectionField?.target}>
<FormProvider>
<TableSelectorParamsProvider params={{ filter: getFilter() }}>
<SchemaComponentOptions
scope={{
usePickActionProps,
useTableSelectorProps,
useCreateActionProps,
}}
>
<NocoBaseRecursionField
onlyRenderProperties
basePath={field.address}
schema={fieldSchema.parent}
filterProperties={(s) => {
return s['x-component'] === 'AssociationField.Selector';
}}
/>
</SchemaComponentOptions>
</TableSelectorParamsProvider>
</FormProvider>
</CollectionProvider>
</RecordPickerProvider>
</ActionContextProvider>
</Card> </Card>
) : ( ) : (
<> <>

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { PlusSquareOutlined, ZoomInOutlined } from '@ant-design/icons'; import { PlusOutlined, ZoomInOutlined } from '@ant-design/icons';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { ArrayField } from '@formily/core'; import { ArrayField } from '@formily/core';
import { exchangeArrayState } from '@formily/core/esm/shared/internals'; import { exchangeArrayState } from '@formily/core/esm/shared/internals';
@ -259,7 +259,7 @@ export const SubTable: any = observer(
useAction={useSubTableAddNewProps} useAction={useSubTableAddNewProps}
title={ title={
<Space style={{ gap: 2 }} className="nb-sub-table-addNew"> <Space style={{ gap: 2 }} className="nb-sub-table-addNew">
<PlusSquareOutlined /> {t('Add new')} <PlusOutlined /> {t('Add new')}
</Space> </Space>
} }
/> />

View File

@ -54,14 +54,10 @@ test.describe('form item & sub form', () => {
unsupportedVariables: ['Parent popup record'], unsupportedVariables: ['Parent popup record'],
variableValue: ['Current user', 'Nickname'], variableValue: ['Current user', 'Nickname'],
expectVariableValue: async () => { expectVariableValue: async () => {
await page await page.locator('.nb-sub-form-addNew').first().click();
.getByLabel('block-item-CollectionField-subform-form-subform.manyToMany-manyToMany')
.getByRole('img', { name: 'plus' })
.first()
.click();
// 在第一条数据下面增加一条数据 // 在最下面增加一条数据
await expect(page.getByLabel('block-item-CollectionField-').nth(2).getByRole('textbox')).toHaveValue( await expect(page.getByLabel('block-item-CollectionField-').last().getByRole('textbox')).toHaveValue(
'Super Admin', 'Super Admin',
); );
}, },