refactor(sub-table): sub-table support selection of existing records (#3311)

* refactor: sub-table support selection of existing records

* refactor: local improve

* refactor: sub-table support select existing records

* refactor: create action support updateAssociationValues

* refactor:  sub-table

* fix: omit foreignKey

* refactor: record picker omit foreignKey

* test: manyToMany

* test: subform: basic fields

* test: table column & sub-table in edit form
This commit is contained in:
katherinehhh 2024-01-15 17:46:55 +08:00 committed by GitHub
parent dc71a77d8c
commit 6fb5af993e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 253 additions and 34 deletions

View File

@ -268,10 +268,17 @@ export const useTableSelectorContext = () => {
export const useTableSelectorProps = () => { export const useTableSelectorProps = () => {
const field = useField<ArrayField>(); const field = useField<ArrayField>();
const ctx = useTableSelectorContext(); const ctx = useTableSelectorContext();
const fieldSchema = useFieldSchema();
const { getCollectionJoinField } = useCollectionManager();
const collectionFieldSchema = recursiveParent(fieldSchema, 'CollectionField');
const collectionField = getCollectionJoinField(collectionFieldSchema?.['x-collection-field']);
useEffect(() => { useEffect(() => {
if (!ctx?.service?.loading) { if (!ctx?.service?.loading) {
field.value = ctx?.service?.data?.data; const data = ctx?.service?.data?.data.map((v) => {
field?.setInitialValue?.(ctx?.service?.data?.data); return _.omit(v, collectionField?.foreignKey);
});
field.value = data;
field?.setInitialValue?.(data);
field.data = field.data || {}; field.data = field.data || {};
field.data.selectedRowKeys = ctx?.field?.data?.selectedRowKeys; field.data.selectedRowKeys = ctx?.field?.data?.selectedRowKeys;
field.componentProps.pagination = field.componentProps.pagination || {}; field.componentProps.pagination = field.componentProps.pagination || {};

View File

@ -184,6 +184,7 @@ export const useCreateActionProps = () => {
const compile = useCompile(); const compile = useCompile();
const { modal } = App.useApp(); const { modal } = App.useApp();
const { t } = useTranslation(); const { t } = useTranslation();
const { updateAssociationValues } = useFormBlockContext();
const collectValues = useCollectValuesToSubmit(); const collectValues = useCollectValuesToSubmit();
const action = actionField.componentProps.saveMode || 'create'; const action = actionField.componentProps.saveMode || 'create';
const filterKeys = actionField.componentProps.filterKeys?.checked || []; const filterKeys = actionField.componentProps.filterKeys?.checked || [];
@ -205,6 +206,7 @@ export const useCreateActionProps = () => {
triggerWorkflows: triggerWorkflows?.length triggerWorkflows: triggerWorkflows?.length
? triggerWorkflows.map((row) => [row.workflowKey, row.context].filter(Boolean).join('!')).join(',') ? triggerWorkflows.map((row) => [row.workflowKey, row.context].filter(Boolean).join('!')).join(',')
: undefined, : undefined,
updateAssociationValues,
}); });
setVisible?.(false); setVisible?.(false);
actionField.data.loading = false; actionField.data.loading = false;

View File

@ -749,5 +749,7 @@
"Current form": "Formulaire actuel", "Current form": "Formulaire actuel",
"Current object": "Objet actuel", "Current object": "Objet actuel",
"Linkage with form fields": "Lien avec les champs de formulaire", "Linkage with form fields": "Lien avec les champs de formulaire",
"Allow add new, update and delete actions": "Autoriser les actions d'ajout, de mise à jour et de suppression" "Allow add new, update and delete actions": "Autoriser les actions d'ajout, de mise à jour et de suppression",
"Allow add new":"Autoriser les ajouts",
"Allow selection of existing records":"Permet de sélectionner des données existantes"
} }

View File

@ -666,5 +666,7 @@
"Search plugin": "プラグインを検索", "Search plugin": "プラグインを検索",
"Author": "著者", "Author": "著者",
"Plugin loading failed. Please check the server logs.": "プラグインのロードに失敗しました。サーバーログを確認してください。", "Plugin loading failed. Please check the server logs.": "プラグインのロードに失敗しました。サーバーログを確認してください。",
"Automatically drop objects that depend on the collection (such as views), and in turn all objects that depend on those objects": "テーブルに依存するオブジェクト、およびそれらに依存するオブジェクトを自動的に削除する" "Automatically drop objects that depend on the collection (such as views), and in turn all objects that depend on those objects": "テーブルに依存するオブジェクト、およびそれらに依存するオブジェクトを自動的に削除する",
"Allow add new":"新規作成を許可",
"Allow selection of existing records":"既存のデータの選択を許可"
} }

View File

@ -708,5 +708,7 @@
"Data template": "Modelo de dados", "Data template": "Modelo de dados",
"Not found": "Não encontrado", "Not found": "Não encontrado",
"Add": "Adicionar", "Add": "Adicionar",
"Automatically drop objects that depend on the collection (such as views), and in turn all objects that depend on those objects": "Excluir automaticamente objetos que dependem desta tabela, bem como objetos que dependem desses objetos" "Automatically drop objects that depend on the collection (such as views), and in turn all objects that depend on those objects": "Excluir automaticamente objetos que dependem desta tabela, bem como objetos que dependem desses objetos",
"Allow add new":"Permitir novas adições",
"Allow selection of existing records":"Permitir a selecção dos registos existentes"
} }

View File

@ -839,5 +839,7 @@
"Are you sure you want to perform the {{title}} action?": "你确定执行{{title}}操作吗?", "Are you sure you want to perform the {{title}} action?": "你确定执行{{title}}操作吗?",
"Sign in with another account": "登录其他账号", "Sign in with another account": "登录其他账号",
"Return to the main application": "返回主应用", "Return to the main application": "返回主应用",
"Permission denied": "没有权限" "Permission denied": "没有权限",
"Allow add new":"允许新增",
"Allow selection of existing records":"允许选择已有数据"
} }

View File

@ -597,7 +597,10 @@ test.describe('creation form block schema settings', () => {
await page.mouse.move(300, 0); await page.mouse.move(300, 0);
// 当新增一行时,应该显示默认值 // 当新增一行时,应该显示默认值
await page.getByRole('button', { name: 'plus' }).click(); await page
.getByTestId('drawer-Action.Container-general-Add record')
.getByRole('button', { name: 'Add new' })
.click();
await expect( await expect(
page page
.getByRole('cell', { name: 'block-item-CollectionField-users-form-users.nickname-Nickname' }) .getByRole('cell', { name: 'block-item-CollectionField-users-form-users.nickname-Nickname' })

View File

@ -21,7 +21,7 @@ import { useAssociationFieldContext, useFieldNames, useInsertSchema } from './ho
import schema from './schema'; import schema from './schema';
import { flatData, getLabelFormatValue, useLabelUiSchema } from './util'; import { flatData, getLabelFormatValue, useLabelUiSchema } from './util';
const useTableSelectorProps = () => { export const useTableSelectorProps = () => {
const field: any = useField(); const field: any = useField();
const { const {
multiple, multiple,

View File

@ -14,13 +14,18 @@ export const InternalSubTable = observer(
const field: any = useField(); const field: any = useField();
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const insert = useInsertSchema('SubTable'); const insert = useInsertSchema('SubTable');
const insertSelector = useInsertSchema('Selector');
const { options } = useAssociationFieldContext(); const { options } = useAssociationFieldContext();
const { actionName } = useACLActionParamsContext(); const { actionName } = useACLActionParamsContext();
useEffect(() => { useEffect(() => {
insert(schema.SubTable); insert(schema.SubTable);
field.required = fieldSchema['required']; field.required = fieldSchema['required'];
}, []); }, []);
useEffect(() => {
if (field.componentProps?.allowSelectExistingRecord) {
insertSelector(schema.Selector);
}
}, [field.componentProps?.allowSelectExistingRecord]);
const option = useSchemaOptionsContext(); const option = useSchemaOptionsContext();
const components = { const components = {
...option.components, ...option.components,

View File

@ -1,21 +1,44 @@
import { PlusOutlined } 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';
import { observer } from '@formily/react'; import { observer, RecursionField, useFieldSchema } from '@formily/react';
import { action } from '@formily/reactive'; import { action } from '@formily/reactive';
import { isArr } from '@formily/shared'; import { isArr } from '@formily/shared';
import { Button } from 'antd'; import { Button } from 'antd';
import React from 'react'; import { omit, unionBy, uniqBy } from 'lodash';
import React, { useState, useMemo, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { FormActiveFieldsProvider } from '../../../block-provider'; import { FormActiveFieldsProvider } from '../../../block-provider';
import { FlagProvider } from '../../../flag-provider'; import { FlagProvider } from '../../../flag-provider';
import { Table } from '../table-v2/Table'; import { Table } from '../table-v2/Table';
import { useAssociationFieldContext } from './hooks'; import { useAssociationFieldContext, useFieldNames } from './hooks';
import { ActionContextProvider } from '../action';
import {
FormProvider,
RecordPickerProvider,
SchemaComponentOptions,
useActionContext,
RecordPickerContext,
} from '../..';
import { getLabelFormatValue, useLabelUiSchema } from './util';
import { CollectionProvider } from '../../../collection-manager';
import { TableSelectorParamsProvider } from '../../../block-provider/TableSelectorProvider';
import { useCompile } from '../../hooks';
import { useTableSelectorProps } from './InternalPicker';
import { useCreateActionProps } from '../../../block-provider/hooks';
export const SubTable: any = observer( export const SubTable: any = observer(
(props: any) => { (props: any) => {
const { field } = useAssociationFieldContext<ArrayField>(); const { openSize } = props;
// useSubTableSpecialCase({ field }); const { field, options: collectionField } = useAssociationFieldContext<ArrayField>();
const { t } = useTranslation();
const [visibleSelector, setVisibleSelector] = useState(false);
const [selectedRows, setSelectedRows] = useState([]);
const fieldNames = useFieldNames(props);
const fieldSchema = useFieldSchema();
const compile = useCompile();
const labelUiSchema = useLabelUiSchema(collectionField, fieldNames?.label || 'label');
const move = (fromIndex: number, toIndex: number) => { const move = (fromIndex: number, toIndex: number) => {
if (toIndex === undefined) return; if (toIndex === undefined) return;
if (!isArr(field.value)) return; if (!isArr(field.value)) return;
@ -32,6 +55,55 @@ export const SubTable: any = observer(
}); });
}; };
field.move = move; field.move = move;
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 usePickActionProps = () => {
const { setVisible } = useActionContext();
const { selectedRows, options, collectionField } = useContext(RecordPickerContext);
return {
onClick() {
const selectData = unionBy(selectedRows, options, collectionField?.targetKey || 'id');
const data = field.value || [];
field.value = uniqBy(data.concat(selectData), collectionField?.targetKey || 'id');
field.onInput(field.value);
setVisible(false);
},
};
};
const getFilter = () => {
const targetKey = collectionField?.targetKey || 'id';
const list = options.map((option) => option[targetKey]).filter(Boolean);
const filter = list.length ? { $and: [{ [`${targetKey}.$ne`]: list }] } : {};
return filter;
};
return ( return (
<div <div
className={css` className={css`
@ -72,6 +144,9 @@ export const SubTable: any = observer(
.ant-formily-editable { .ant-formily-editable {
vertical-align: sub; vertical-align: sub;
} }
.ant-table-footer {
display: flex;
}
`} `}
bordered bordered
size={'small'} size={'small'}
@ -83,27 +158,80 @@ export const SubTable: any = observer(
rowSelection={{ type: 'none', hideSelectAll: true }} rowSelection={{ type: 'none', hideSelectAll: true }}
footer={() => footer={() =>
field.editable && ( field.editable && (
<Button <>
type={'text'} {field.componentProps?.allowAddnew !== false && (
block <Button
className={css` type={'text'}
display: block; block
`} className={css`
onClick={() => { display: block;
field.value = field.value || []; border-radius: 0px;
field.value.push({}); border-right: 1px solid rgba(0, 0, 0, 0.06);
field.onInput(field.value); `}
}} onClick={() => {
icon={<PlusOutlined />} field.value = field.value || [];
> field.value.push({});
{/* {t('Add new')} */} field.onInput(field.value);
</Button> }}
>
{t('Add new')}
</Button>
)}
{field.componentProps?.allowSelectExistingRecord && (
<Button
type={'text'}
block
className={css`
display: block;
border-radius: 0px;
`}
onClick={() => {
setVisibleSelector(true);
}}
>
{t('Select')}
</Button>
)}
</>
) )
} }
isSubTable={true} isSubTable={true}
/> />
</FormActiveFieldsProvider> </FormActiveFieldsProvider>
</FlagProvider> </FlagProvider>
<ActionContextProvider
value={{
openSize,
openMode: 'drawer',
visible: visibleSelector,
setVisible: setVisibleSelector,
}}
>
<RecordPickerProvider {...pickerProps}>
<CollectionProvider name={collectionField?.target}>
<FormProvider>
<TableSelectorParamsProvider params={{ filter: getFilter() }}>
<SchemaComponentOptions
scope={{
usePickActionProps,
useTableSelectorProps,
useCreateActionProps,
}}
>
<RecursionField
onlyRenderProperties
basePath={field.address}
schema={fieldSchema.parent}
filterProperties={(s) => {
return s['x-component'] === 'AssociationField.Selector';
}}
/>
</SchemaComponentOptions>
</TableSelectorParamsProvider>
</FormProvider>
</CollectionProvider>
</RecordPickerProvider>
</ActionContextProvider>
</div> </div>
); );
}, },

View File

@ -32,6 +32,72 @@ export const formItemSettings = new SchemaSettings({
name: 'FormItemSettings', name: 'FormItemSettings',
items: [ items: [
...(generalSettingsItems as any), ...(generalSettingsItems as any),
{
name: 'allowAddNewData',
type: 'switch',
useVisible() {
const readPretty = useIsFieldReadPretty();
const isAssociationField = useIsAssociationField();
const fieldMode = useFieldMode();
return !readPretty && isAssociationField && ['SubTable'].includes(fieldMode);
},
useComponentProps() {
const { t } = useTranslation();
const field = useField<Field>();
const fieldSchema = useFieldSchema();
const { dn, refresh } = useDesignable();
return {
title: t('Allow add new'),
checked: fieldSchema['x-component-props']?.allowAddnew !== (false as boolean),
onChange(value) {
const schema = {
['x-uid']: fieldSchema['x-uid'],
};
field.componentProps.allowAddnew = value;
fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
fieldSchema['x-component-props'].allowAddnew = value;
schema['x-component-props'] = fieldSchema['x-component-props'];
dn.emit('patch', {
schema,
});
refresh();
},
};
},
},
{
name: 'allowSelectExistingRecord',
type: 'switch',
useVisible() {
const readPretty = useIsFieldReadPretty();
const isAssociationField = useIsAssociationField();
const fieldMode = useFieldMode();
return !readPretty && isAssociationField && ['SubTable'].includes(fieldMode);
},
useComponentProps() {
const { t } = useTranslation();
const field = useField<Field>();
const fieldSchema = useFieldSchema();
const { dn, refresh } = useDesignable();
return {
title: t('Allow selection of existing records'),
checked: fieldSchema['x-component-props']?.allowSelectExistingRecord,
onChange(value) {
const schema = {
['x-uid']: fieldSchema['x-uid'],
};
field.componentProps.allowSelectExistingRecord = value;
fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
fieldSchema['x-component-props'].allowSelectExistingRecord = value;
schema['x-component-props'] = fieldSchema['x-component-props'];
dn.emit('patch', {
schema,
});
refresh();
},
};
},
},
{ {
name: 'quickUpload', name: 'quickUpload',
type: 'switch', type: 'switch',

View File

@ -266,9 +266,9 @@ test.describe('form item & create form', () => {
// 选择 Sub-form // 选择 Sub-form
await (async (page: Page, fieldName: string) => { await (async (page: Page, fieldName: string) => {
await page.getByLabel(`block-item-CollectionField-general-form-general.${fieldName}-${fieldName}`).hover(); await page.getByLabel(`block-item-CollectionField-general-form-general.${fieldName}-${fieldName}`).hover();
await page // await page
.getByLabel(`designer-schema-settings-CollectionField-FormItem.Designer-general-general.${fieldName}`) // .getByLabel(`designer-schema-settings-CollectionField-FormItem.Designer-general-general.${fieldName}`)
.hover(); // .hover();
})(page, 'manyToMany'); })(page, 'manyToMany');
await page.getByRole('menuitem', { name: 'Field component' }).click(); await page.getByRole('menuitem', { name: 'Field component' }).click();
await page.getByRole('option', { name: 'Sub-form', exact: true }).click(); await page.getByRole('option', { name: 'Sub-form', exact: true }).click();

View File

@ -419,7 +419,7 @@ test.describe('table column & sub-table in edit form', () => {
], ],
variableValue: ['Current user', 'Nickname'], variableValue: ['Current user', 'Nickname'],
expectVariableValue: async () => { expectVariableValue: async () => {
await page.getByRole('button', { name: 'plus' }).click(); await page.getByRole('button', { name: 'Add new' }).click();
await expect( await expect(
page page
.getByLabel('block-item-CollectionField-general-form-general.singleLineText-singleLineText') .getByLabel('block-item-CollectionField-general-form-general.singleLineText-singleLineText')