feat: support automatic data block refresh (#6591) (#6658)

* fix: update FilterContext to allow null parent value

* fix: refactor context usage from FilterContext to DataBlocksContext in FilterProvider and SchemaSettings

* feat: add highlight and unhighlight functionality for data blocks in SchemaSettings

* Revert "fix: refactor context usage from FilterContext to DataBlocksContext in FilterProvider and SchemaSettings"

This reverts commit a75c7002010785f1cfd2e78c4f5998d0194366bc.

* Revert "fix: update FilterContext to allow null parent value"

This reverts commit 6eb0b1989e20be8310f8dbce4e875e862123f2b3.

* feat: add AllDataBlocksProvider and integrate it into SchemaSettings and Page components

* feat: add BlocksSelector component and integrate data block refresh functionality in Action and SchemaSettings

* feat: optimize handleClick to use useMemo for better performance and refresh data blocks after onClick

* feat: add dialog visibility control in BlocksSelector for improved user experience

* fix: avoid error

* feat: add highlight and scroll tracking functionality for data blocks

* feat: add transition

* feat: add tootip

* fix: prevent closed dialog blocks from appearing in the BlocksSelector options

* fix: handle errors during block refresh to prevent crashes

* chore: fix build

* feat: add AllDataBlocksProvider to BlockTemplatePage and export from index

* feat: set width for AfterSuccess dialog to 700

* feat: wrap MobileRouter with AllDataBlocksProvider for improved data handling

* feat: export BlocksSelector component and integrate into AfterSuccess settings

* fix: ensure container visibility is managed correctly in highlightBlock and unhighlightBlock functions

* fix: remove unnecessary display property manipulation in highlightBlock and simplify unhighlightBlock logic

* chore: hide data refresh after sucess option from block template configure page

* fix: revert code format

---------

Co-authored-by: gchust <gchust@qq.com>
This commit is contained in:
Zeke Zhang 2025-04-14 18:34:21 +08:00 committed by GitHub
parent 23d7e09fa5
commit ba83b2b1be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 563 additions and 216 deletions

View File

@ -19,6 +19,7 @@ import { mergeFilter, useAssociatedFields } from './utils';
// @ts-ignore
import React, { createContext, useCallback, useEffect, useMemo, useRef } from 'react';
import { useAllDataBlocks } from '../schema-component/antd/page/AllDataBlocksProvider';
enum FILTER_OPERATOR {
AND = '$and',
@ -71,6 +72,10 @@ export interface DataBlock {
* manual: 只有当点击了筛选按钮
*/
dataLoadingMode?: 'auto' | 'manual';
/** 让整个区块悬浮起来 */
highlightBlock: () => void;
/** 取消悬浮 */
unhighlightBlock: () => void;
}
interface FilterContextValue {
@ -124,7 +129,7 @@ export const DataBlockCollector = ({
const field = useField();
const fieldSchema = useFieldSchema();
const associatedFields = useAssociatedFields();
const container = useRef(null);
const container = useRef<HTMLDivElement | null>(null);
const dataLoadingMode = useDataLoadingMode();
const shouldApplyFilter =
@ -172,6 +177,34 @@ export const DataBlockCollector = ({
field.data?.clearSelectedRowKeys?.();
}
},
highlightBlock() {
const dom = container.current;
if (!dom) return;
const designer = dom.querySelector('.ant-nb-schema-toolbar');
if (designer) {
designer.classList.remove(process.env.__E2E__ ? 'hidden-e2e' : 'hidden');
}
dom.style.boxShadow = '0 3px 12px rgba(0, 0, 0, 0.15)';
dom.style.transition = 'box-shadow 0.3s ease, transform 0.2s ease';
dom.scrollIntoView?.({
behavior: 'smooth',
block: 'start',
});
},
unhighlightBlock() {
const dom = container.current;
if (!dom) return;
const designer = dom.querySelector('.ant-nb-schema-toolbar');
if (designer) {
designer.classList.add(process.env.__E2E__ ? 'hidden-e2e' : 'hidden');
}
dom.style.boxShadow = 'none';
dom.style.transition = 'box-shadow 0.3s ease, transform 0.2s ease';
}
});
}, [
associatedFields,
@ -197,12 +230,14 @@ export const DataBlockCollector = ({
*/
export const useFilterBlock = () => {
const ctx = React.useContext(FilterContext);
const allDataBlocksCtx = useAllDataBlocks();
// 有可能存在页面没有提供 FilterBlockProvider 的情况,比如内部使用的数据表管理页面
const getDataBlocks = useCallback<() => DataBlock[]>(() => ctx?.getDataBlocks() || [], [ctx]);
const recordDataBlocks = useCallback(
(block: DataBlock) => {
allDataBlocksCtx.recordDataBlocks(block);
const existingBlock = ctx?.getDataBlocks().find((item) => item.uid === block.uid);
if (existingBlock) {
@ -218,6 +253,7 @@ export const useFilterBlock = () => {
const removeDataBlock = useCallback(
(uid: string) => {
allDataBlocksCtx.removeDataBlock(uid);
if (ctx?.getDataBlocks().every((item) => item.uid !== uid)) return;
ctx?.setDataBlocks((prev) => prev.filter((item) => item.uid !== uid));
},

View File

@ -0,0 +1,47 @@
let container: HTMLElement | null = null;
export const highlightBlock = (clonedBlockDom: HTMLElement, boxRect: DOMRect) => {
if (!container) {
container = document.createElement('div');
document.body.appendChild(container);
container.style.position = 'absolute';
container.style.transition = 'opacity 0.3s ease';
container.style.pointerEvents = 'none';
}
container.appendChild(clonedBlockDom);
container.style.opacity = '1';
container.style.width = `${boxRect.width}px`;
container.style.height = `${boxRect.height}px`;
container.style.top = `${boxRect.top}px`;
container.style.left = `${boxRect.left}px`;
container.style.zIndex = '2000';
}
export const unhighlightBlock = () => {
if (container) {
container.style.opacity = '0';
container.innerHTML = '';
}
}
export const startScrollEndTracking = (dom: HTMLElement & { _prevRect?: DOMRect; _timer?: any }, callback: () => void) => {
dom._timer = setInterval(() => {
const prevRect = dom._prevRect;
const currentRect = dom.getBoundingClientRect();
if (!prevRect || currentRect.top !== prevRect.top) {
dom._prevRect = currentRect;
} else {
clearInterval(dom._timer);
callback();
}
}, 100)
}
export const stopScrollEndTracking = (dom: HTMLElement & { _timer?: any }) => {
if (dom._timer) {
clearInterval(dom._timer);
dom._timer = null;
}
}

View File

@ -886,5 +886,8 @@
"Are you sure you want to hide this tab?": "Sind Sie sicher, dass Sie diesen Tab ausblenden möchten?",
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "Nach dem Ausblenden wird dieser Tab nicht mehr in der Tableiste angezeigt. Um ihn wieder anzuzeigen, müssen Sie zur Routenverwaltungsseite gehen, um ihn einzustellen.",
"No pages yet, please configure first": "Noch keine Seiten, bitte zuerst konfigurieren",
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Klicken Sie auf das \"UI-Editor\"-Symbol in der oberen rechten Ecke, um den UI-Editor-Modus zu betreten"
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Klicken Sie auf das \"UI-Editor\"-Symbol in der oberen rechten Ecke, um den UI-Editor-Modus zu betreten",
"Refresh data blocks": "Aktualisieren Sie die Datenblöcke",
"Select data blocks to refresh": "Wählen Sie die Datenblöcke aus, die aktualisiert werden sollen.",
"After successful submission, the selected data blocks will be automatically refreshed.": "Nach erfolgreicher Übermittlung werden die ausgewählten Datenblöcke automatisch aktualisiert."
}

View File

@ -890,5 +890,9 @@
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode",
"Deprecated": "Deprecated",
"The following old template features have been deprecated and will be removed in next version.": "The following old template features have been deprecated and will be removed in next version.",
"Full permissions": "Full permissions"
"Full permissions": "Full permissions",
"Refresh data blocks": "Refresh data blocks",
"Select data blocks to refresh": "Select data blocks to refresh",
"After successful submission, the selected data blocks will be automatically refreshed.": "After successful submission, the selected data blocks will be automatically refreshed."
}

View File

@ -807,5 +807,8 @@
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Haga clic en el icono \"Editor de UI\" en la esquina superior derecha para entrar en el modo de Editor de UI.",
"Deprecated": "Obsoleto",
"The following old template features have been deprecated and will be removed in next version.": "Las siguientes características de plantilla antigua han quedado obsoletas y se eliminarán en la próxima versión.",
"Full permissions": "Todos los derechos"
"Full permissions": "Todos los derechos",
"Refresh data blocks": "Actualizar bloques de datos",
"Select data blocks to refresh": "Actualizar bloques de datos",
"After successful submission, the selected data blocks will be automatically refreshed.": "Después de enviar correctamente, los bloques de datos seleccionados se actualizarán automáticamente."
}

View File

@ -827,5 +827,8 @@
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Cliquez sur l'icône \"Éditeur d'interface utilisateur\" dans le coin supérieur droit pour entrer en mode Éditeur d'interface utilisateur",
"Deprecated": "Déprécié",
"The following old template features have been deprecated and will be removed in next version.": "Les fonctionnalités des anciens modèles ont été dépréciées et seront supprimées dans la prochaine version.",
"Full permissions": "Tous les droits"
"Full permissions": "Tous les droits",
"Refresh data blocks": "Actualiser les blocs de données",
"Select data blocks to refresh": "Actualiser les blocs de données",
"After successful submission, the selected data blocks will be automatically refreshed.": "Après une soumission réussie, les blocs de données sélectionnés seront automatiquement actualisés."
}

View File

@ -1082,5 +1082,8 @@
"Are you sure you want to hide this tab?": "Sei sicuro di voler nascondere questa scheda?",
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "Dopo averla nascosta, questa scheda non apparirà più nella barra delle schede. Per mostrarla di nuovo, devi andare alla pagina di gestione dei percorsi per configurarlo.",
"No pages yet, please configure first": "Nessuna pagina ancora, si prega di configurare prima",
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Cliquez sur l'icône \"Éditeur d'interface utilisateur\" dans le coin supérieur droit pour entrer en mode Éditeur d'interface utilisateur"
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Cliquez sur l'icône \"Éditeur d'interface utilisateur\" dans le coin supérieur droit pour entrer en mode Éditeur d'interface utilisateur",
"Refresh data blocks": "Aggiorna blocchi di dati",
"Select data blocks to refresh": "Aggiorna blocchi di dati",
"After successful submission, the selected data blocks will be automatically refreshed.": "Dopo una soumission réussie, les blocs de données sélectionnés seront automatiquement actualisés."
}

View File

@ -1045,5 +1045,8 @@
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "ユーザーインターフェースエディターモードに入るには、右上隅の「UIエディタ」アイコンをクリックしてください",
"Deprecated": "非推奨",
"The following old template features have been deprecated and will be removed in next version.": "次の古いテンプレート機能は非推奨になり、次のバージョンで削除されます。",
"Full permissions": "すべての権限"
"Full permissions": "すべての権限",
"Refresh data blocks": "データブロックを更新",
"Select data blocks to refresh": "データブロックを選択して更新",
"After successful submission, the selected data blocks will be automatically refreshed.": "送信後、選択したデータブロックが自動的に更新されます。"
}

View File

@ -918,5 +918,8 @@
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "사용자 인터페이스 편집기 모드에 들어가려면 오른쪽 상단의 \"UI 편집기\" 아이콘을 클릭하십시오",
"Deprecated": "사용 중단됨",
"The following old template features have been deprecated and will be removed in next version.": "다음 오래된 템플릿 기능은 사용 중단되었으며 다음 버전에서 제거될 것입니다.",
"Full permissions": "모든 권한"
"Full permissions": "모든 권한",
"Refresh data blocks": "데이터 블록 새로 고침",
"Select data blocks to refresh": "데이터 블록을 선택하여 새로 고침",
"After successful submission, the selected data blocks will be automatically refreshed.": "전송 후, 선택한 데이터 블록이 자동으로 새로 고쳐집니다."
}

View File

@ -1054,5 +1054,8 @@
"Font Sizepx": "Lettergroottepx",
"Font Weight": "Letterdikte",
"Font Style": "Letterstijl",
"Italic": "Cursief"
"Italic": "Cursief",
"Refresh data blocks": "Vernieuw gegevensblokken",
"Select data blocks to refresh": "Selecteer gegevensblokken om te vernieuwen",
"After successful submission, the selected data blocks will be automatically refreshed.": "Na succesvolle indiening worden de geselecteerde gegevensblokken automatisch vernieuwd."
}

View File

@ -784,5 +784,8 @@
"The following old template features have been deprecated and will be removed in next version.": "As seguintes funcionalidades de modelo antigo foram descontinuadas e serão removidas na próxima versão.",
"Full permissions": "Todas as permissões",
"No pages yet, please configure first": "Ainda não há páginas, por favor configure primeiro",
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Cliquez sur l'icône \"Éditeur d'interface utilisateur\" dans le coin supérieur droit pour entrer en mode Éditeur d'interface utilisateur"
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Cliquez sur l'icône \"Éditeur d'interface utilisateur\" dans le coin supérieur droit pour entrer en mode Éditeur d'interface utilisateur",
"Refresh data blocks": "Atualizar blocos de dados",
"Select data blocks to refresh": "Selecionar blocos de dados para atualizar",
"After successful submission, the selected data blocks will be automatically refreshed.": "Após a atualização em massa bem sucedida."
}

View File

@ -613,5 +613,8 @@
"The following old template features have been deprecated and will be removed in next version.": "Следующие старые функции шаблонов устарели и будут удалены в следующей версии.",
"Full permissions": "Полные права",
"No pages yet, please configure first": "Пока нет страниц, пожалуйста, настройте сначала",
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Нажмите на значок \"Редактор пользовательского интерфейса\" в правом верхнем углу, чтобы войти в режим редактора пользовательского интерфейса"
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Нажмите на значок \"Редактор пользовательского интерфейса\" в правом верхнем углу, чтобы войти в режим редактора пользовательского интерфейса",
"Refresh data blocks": "Обновить блоки данных",
"Select data blocks to refresh": "Выберите блоки данных для обновления",
"After successful submission, the selected data blocks will be automatically refreshed.": "После успешной отправки выбранные блоки данных будут автоматически обновлены."
}

View File

@ -611,5 +611,8 @@
"The following old template features have been deprecated and will be removed in next version.": "Aşağıdaki eski şablon özellikleri kullanımdan kaldırıldı ve gelecek sürümlerde kaldırılacaktır.",
"Full permissions": "Tüm izinler",
"No pages yet, please configure first": "Henüz sayfa yok, lütfen önce yapılandırın",
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Kullanıcı arayüzü düzenleyici moduna girmek için sağ üst köşedeki \"Kullanıcı Arayüzü Düzenleyici\" simgesine tıklayın"
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Kullanıcı arayüzü düzenleyici moduna girmek için sağ üst köşedeki \"Kullanıcı Arayüzü Düzenleyici\" simgesine tıklayın",
"Refresh data blocks": "Yenile veri blokları",
"Select data blocks to refresh": "Veri bloklarını yenilemek için seçin",
"After successful submission, the selected data blocks will be automatically refreshed.": "Başarılı bir şekilde gönderildikten sonra, seçilen veri blokları otomatik olarak yenilenecektir."
}

View File

@ -827,5 +827,8 @@
"The following old template features have been deprecated and will be removed in next version.": "Наступні старі функції шаблонів були застарілі і будуть видалені в наступній версії.",
"Full permissions": "Повні права",
"No pages yet, please configure first": "Ще немає сторінок, будь ласка, спочатку налаштуйте",
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Натисніть на значок \"Редактор користувацького інтерфейсу\" в правому верхньому куті, щоб увійти в режим редактора користувацького інтерфейсу."
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Натисніть на значок \"Редактор користувацького інтерфейсу\" в правому верхньому куті, щоб увійти в режим редактора користувацького інтерфейсу.",
"Refresh data blocks": "Оновити дані блоків",
"Select data blocks to refresh": "Виберіть блоки даних для оновлення",
"After successful submission, the selected data blocks will be automatically refreshed.": "Після успішної подачі вибрані блоки даних будуть автоматично оновлені."
}

View File

@ -167,6 +167,8 @@
"Year": "年",
"QuarterYear": "季度",
"Select grouping field": "选择分组字段",
"Refresh data blocks": "刷新数据区块",
"Select data blocks to refresh": "选择要刷新的数据区块",
"Media": "多媒体",
"Markdown": "Markdown",
"Wysiwyg": "富文本",
@ -1099,5 +1101,6 @@
"Response record":"响应结果记录",
"Colon":"冒号",
"No pages yet, please configure first": "暂无页面,请先配置",
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "点击右上角的“界面配置”图标,进入界面配置模式"
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "点击右上角的“界面配置”图标,进入界面配置模式",
"After successful submission, the selected data blocks will be automatically refreshed.": "提交成功后,会自动刷新这里选中的数据区块。"
}

View File

@ -918,5 +918,8 @@
"The following old template features have been deprecated and will be removed in next version.": "以下舊的模板功能已棄用,將在下個版本移除。",
"Full permissions": "完全權限",
"No pages yet, please configure first": "尚未配置頁面,請先配置",
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "點擊右上角的 \"介面設定\" 圖示進入介面設定模式"
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "點擊右上角的 \"介面設定\" 圖示進入介面設定模式",
"Refresh data blocks": "刷新數據區塊",
"Select data blocks to refresh": "選擇要刷新的數據區塊",
"After successful submission, the selected data blocks will be automatically refreshed.": "提交成功後,選中的數據區塊將自動刷新。"
}

View File

@ -9,14 +9,16 @@
import { ISchema, useField, useFieldSchema } from '@formily/react';
import { isValid, uid } from '@formily/shared';
import { ModalProps } from 'antd';
import React, { useCallback } from 'react';
import { ModalProps, Select } from 'antd';
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useCompile, useDesignable } from '../..';
import { isInitializersSame, useApp } from '../../../application';
import { isInitializersSame, useApp, usePlugin } from '../../../application';
import { useGlobalVariable } from '../../../application/hooks/useGlobalVariable';
import { SchemaSettingOptions, SchemaSettings } from '../../../application/schema-settings';
import { useSchemaToolbar } from '../../../application/schema-toolbar';
import { useCollectionManager_deprecated, useCollection_deprecated } from '../../../collection-manager';
import { highlightBlock, startScrollEndTracking, stopScrollEndTracking, unhighlightBlock } from '../../../filter-provider/highlightBlock';
import { FlagProvider } from '../../../flag-provider';
import { SaveMode } from '../../../modules/actions/submit/createSubmitActionSettings';
import { useOpenModeContext } from '../../../modules/popup/OpenModeProvider';
@ -32,10 +34,10 @@ import {
SchemaSettingsSwitchItem,
} from '../../../schema-settings/SchemaSettings';
import { DefaultValueProvider } from '../../../schema-settings/hooks/useIsAllowToSetDefaultValue';
import { useAllDataBlocks } from '../page/AllDataBlocksProvider';
import { useLinkageAction } from './hooks';
import { requestSettingsSchema } from './utils';
import { useAfterSuccessOptions } from './hooks/useGetAfterSuccessVariablesOptions';
import { useGlobalVariable } from '../../../application/hooks/useGlobalVariable';
import { requestSettingsSchema } from './utils';
const MenuGroup = (props) => {
return props.children;
@ -294,27 +296,105 @@ const useVariableProps = (environmentVariables) => {
};
};
const hideDialog = (dialogClassName: string) => {
const dialogMask = document.querySelector<HTMLElement>(`.${dialogClassName} > .ant-modal-mask`);
const dialogWrap = document.querySelector<HTMLElement>(`.${dialogClassName} > .ant-modal-wrap`);
if (dialogMask) {
dialogMask.style.opacity = '0';
dialogMask.style.transition = 'opacity 0.5s ease';
}
if (dialogWrap) {
dialogWrap.style.opacity = '0';
dialogWrap.style.transition = 'opacity 0.5s ease';
}
}
const showDialog = (dialogClassName: string) => {
const dialogMask = document.querySelector<HTMLElement>(`.${dialogClassName} > .ant-modal-mask`);
const dialogWrap = document.querySelector<HTMLElement>(`.${dialogClassName} > .ant-modal-wrap`);
if (dialogMask) {
dialogMask.style.opacity = '1';
dialogMask.style.transition = 'opacity 0.5s ease';
}
if (dialogWrap) {
dialogWrap.style.opacity = '1';
dialogWrap.style.transition = 'opacity 0.5s ease';
}
}
export const BlocksSelector = (props) => {
const { getAllDataBlocks } = useAllDataBlocks();
const allDataBlocks = getAllDataBlocks();
const compile = useCompile();
const { t } = useTranslation();
// 转换 allDataBlocks 为 Select 选项
const options = useMemo(() => {
return allDataBlocks.map(block => {
// 防止列表中出现已关闭的弹窗中的区块
if (!block.dom?.isConnected) {
return null;
}
const title = `${compile(block.collection.title)} #${block.uid.slice(0, 4)}`;
return {
label: title,
value: block.uid,
onMouseEnter() {
block.highlightBlock();
hideDialog('dialog-after-successful-submission');
startScrollEndTracking(block.dom, () => {
highlightBlock(block.dom.cloneNode(true) as HTMLElement, block.dom.getBoundingClientRect());
});
},
onMouseLeave() {
block.unhighlightBlock();
showDialog('dialog-after-successful-submission');
stopScrollEndTracking(block.dom);
unhighlightBlock();
}
}
}).filter(Boolean);
}, [allDataBlocks, t]);
return (
<Select
value={props.value}
mode="multiple"
allowClear
placeholder={t('Select data blocks to refresh')}
options={options}
onChange={props.onChange}
/>
);
}
export function AfterSuccess() {
const { dn } = useDesignable();
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
const { onSuccess } = fieldSchema?.['x-action-settings'] || {};
const environmentVariables = useGlobalVariable('$env');
const templatePlugin: any = usePlugin('@nocobase/plugin-block-template');
const isInBlockTemplateConfigPage = templatePlugin?.isInBlockTemplateConfigPage?.();
return (
<SchemaSettingsModalItem
dialogRootClassName='dialog-after-successful-submission'
width={700}
title={t('After successful submission')}
initialValues={
onSuccess
? {
actionAfterSuccess: onSuccess?.redirecting ? 'redirect' : 'previous',
...onSuccess,
}
actionAfterSuccess: onSuccess?.redirecting ? 'redirect' : 'previous',
...onSuccess,
}
: {
manualClose: false,
redirecting: false,
successMessage: '{{t("Saved successfully")}}',
actionAfterSuccess: 'previous',
}
manualClose: false,
redirecting: false,
successMessage: '{{t("Saved successfully")}}',
actionAfterSuccess: 'previous',
}
}
schema={
{
@ -382,6 +462,18 @@ export function AfterSuccess() {
// eslint-disable-next-line react-hooks/rules-of-hooks
'x-use-component-props': () => useVariableProps(environmentVariables),
},
blocksToRefresh: {
type: 'array',
title: t('Refresh data blocks'),
'x-decorator': 'FormItem',
'x-use-decorator-props': () => {
return {
tooltip: t('After successful submission, the selected data blocks will be automatically refreshed.'),
};
},
'x-component': BlocksSelector,
'x-hidden': isInBlockTemplateConfigPage, // 模板配置页面暂不支持该配置
},
},
} as ISchema
}

View File

@ -52,10 +52,11 @@ import { NAMESPACE_UI_SCHEMA } from '../../../i18n/constant';
// 这个要放到最下面,否则会导致前端单测失败
import { useApp } from '../../../application';
import { useAllDataBlocks } from '../page/AllDataBlocksProvider';
const useA = () => {
return {
async run() {},
async run() { },
};
};
@ -101,6 +102,8 @@ export const Action: ComposedAction = withDynamicSchemaProps(
const { getAriaLabel } = useGetAriaLabelOfAction(title);
const parentRecordData = useCollectionParentRecordData();
const app = useApp();
const { getAllDataBlocks } = useAllDataBlocks();
useEffect(() => {
if (field.stateOfLinkageRules) {
setInitialActionState(field);
@ -131,6 +134,26 @@ export const Action: ComposedAction = withDynamicSchemaProps(
[onMouseEnter],
);
const handleClick = useMemo(() => {
return onClick && (async (e, callback) => {
await onClick?.(e, callback);
// 执行完 onClick 之后,刷新数据区块
const blocksToRefresh = fieldSchema['x-action-settings']?.onSuccess?.blocksToRefresh || []
if (blocksToRefresh.length > 0) {
getAllDataBlocks().forEach((block) => {
if (blocksToRefresh.includes(block.uid)) {
try {
block.service?.refresh();
} catch (error) {
console.error('Failed to refresh block:', block.uid, error);
}
}
});
}
});
}, [onClick, fieldSchema, getAllDataBlocks]);
return (
<InternalAction
containerRefKey={containerRefKey}
@ -144,7 +167,7 @@ export const Action: ComposedAction = withDynamicSchemaProps(
className={className}
type={props.type}
Designer={Designer}
onClick={onClick}
onClick={handleClick}
confirm={confirm}
confirmTitle={confirmTitle}
popover={popover}

View File

@ -0,0 +1,81 @@
import _ from "lodash";
import React, { useCallback } from "react";
import { DataBlock } from "../../../filter-provider/FilterProvider";
export const AllDataBlocksContext = React.createContext<{
getAllDataBlocks: () => DataBlock[];
setAllDataBlocks: (
value: DataBlock[] | ((prev: DataBlock[]) => DataBlock[])
) => void;
}>({
getAllDataBlocks: () => [],
setAllDataBlocks: () => { },
});
/**
*
* @param props
* @returns
*/
export const AllDataBlocksProvider: React.FC = (props) => {
const dataBlocksRef = React.useRef<DataBlock[]>([]);
const setAllDataBlocks = React.useCallback((value) => {
if (typeof value === "function") {
dataBlocksRef.current = value(dataBlocksRef.current);
} else {
dataBlocksRef.current = value;
}
}, []);
const getAllDataBlocks = React.useCallback(
() => dataBlocksRef.current,
[]
);
const value = React.useMemo(
() => ({ getAllDataBlocks, setAllDataBlocks }),
[getAllDataBlocks, setAllDataBlocks]
);
return <AllDataBlocksContext.Provider value={value}>{props.children}</AllDataBlocksContext.Provider>;
}
export const useAllDataBlocks = () => {
const ctx = React.useContext(AllDataBlocksContext);
const getAllDataBlocks = useCallback<() => DataBlock[]>(() => ctx?.getAllDataBlocks() || [], [ctx]);
const recordDataBlocks = useCallback(
(block: DataBlock) => {
const existingBlock = ctx?.getAllDataBlocks().find((item) => item.uid === block.uid);
if (existingBlock) {
// 这里的值有可能会变化,所以需要更新
Object.assign(existingBlock, block);
return;
}
ctx?.setAllDataBlocks((prev) => [...prev, block]);
},
[ctx],
);
const removeDataBlock = useCallback(
(uid: string) => {
if (ctx?.getAllDataBlocks().every((item) => item.uid !== uid)) return;
ctx?.setAllDataBlocks((prev) => prev.filter((item) => item.uid !== uid));
},
[ctx],
);
if (!ctx) {
return {
recordDataBlocks: _.noop,
removeDataBlock: _.noop,
getAllDataBlocks,
};
}
return {
recordDataBlocks,
removeDataBlock,
getAllDataBlocks,
};
};

View File

@ -13,6 +13,7 @@ import { css } from '@emotion/css';
import { FormLayout } from '@formily/antd-v5';
import { SchemaOptionsContext, useFieldSchema } from '@formily/react';
import { uid } from '@formily/shared';
import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client';
import { Button, Tabs } from 'antd';
import classNames from 'classnames';
import React, { FC, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
@ -48,10 +49,10 @@ import { useCompile, useDesignable } from '../../hooks';
import { useToken } from '../__builtins__';
import { ErrorFallback } from '../error-fallback';
import { useMenuDragEnd, useNocoBaseRoutes } from '../menu/Menu';
import { AllDataBlocksProvider } from './AllDataBlocksProvider';
import { useStyles } from './Page.style';
import { PageDesigner, PageTabDesigner } from './PageTabDesigner';
import { PopupRouteContextResetter } from './PopupRouteContextResetter';
import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client';
import { NAMESPACE_UI_SCHEMA } from '../../../i18n/constant';
interface PageProps {
@ -128,12 +129,14 @@ export const Page = React.memo((props: PageProps) => {
}
return (
<div className={`${componentCls} ${hashId} ${antTableCell}`} style={pageActive ? null : hiddenStyle}>
{/* Avoid passing values down to improve rendering performance */}
<CurrentTabUidContext.Provider value={''}>
<InternalPage currentTabUid={tabUidRef.current} className={props.className} />
</CurrentTabUidContext.Provider>
</div>
<AllDataBlocksProvider>
<div className={`${componentCls} ${hashId} ${antTableCell}`} style={pageActive ? null : hiddenStyle}>
{/* Avoid passing values down to improve rendering performance */}
<CurrentTabUidContext.Provider value={''}>
<InternalPage currentTabUid={tabUidRef.current} className={props.className} />
</CurrentTabUidContext.Provider>
</div>
</AllDataBlocksProvider>
);
});

View File

@ -14,3 +14,4 @@ export { PagePopups, useCurrentPopupContext } from './PagePopups';
export { getPopupPathFromParams, getStoredPopupContext, storePopupContext, withSearchParams } from './pagePopupUtils';
export * from './PageTab.Settings';
export { PopupSettingsProvider, usePopupSettings } from './PopupSettingsProvider';
export * from './AllDataBlocksProvider';

View File

@ -96,6 +96,7 @@ import { useRecord } from '../record-provider';
import { ActionContextProvider } from '../schema-component/antd/action/context';
import { SubFormProvider, useSubFormValue } from '../schema-component/antd/association-field/hooks';
import { FormDialog } from '../schema-component/antd/form-dialog';
import { AllDataBlocksContext } from '../schema-component/antd/page/AllDataBlocksProvider';
import { SchemaComponentContext } from '../schema-component/context';
import { FormProvider } from '../schema-component/core/FormProvider';
import { RemoteSchemaComponent } from '../schema-component/core/RemoteSchemaComponent';
@ -364,8 +365,8 @@ export const SchemaSettingsFormItemTemplate = function FormItemTemplate(props) {
required: true,
default: collection
? `${compile(collection?.title || collection?.name)}_${t(
componentTitle[componentName] || componentName,
)}`
componentTitle[componentName] || componentName,
)}`
: t(componentTitle[componentName] || componentName),
'x-decorator': 'FormItem',
'x-component': 'Input',
@ -566,7 +567,7 @@ export const SchemaSettingsRemove: FC<SchemaSettingsRemoveProps> = (props) => {
export interface SchemaSettingsSelectItemProps
extends Omit<SchemaSettingsItemProps, 'onChange' | 'onClick'>,
Omit<SelectWithTitleProps, 'title' | 'defaultValue'> {
Omit<SelectWithTitleProps, 'title' | 'defaultValue'> {
value?: SelectWithTitleProps['defaultValue'];
optionRender?: (option: any, info: { index: number }) => React.ReactNode;
}
@ -816,6 +817,7 @@ export interface SchemaSettingsModalItemProps {
noRecord?: boolean;
/** 自定义 Modal 上下文 */
ModalContextProvider?: React.FC;
dialogRootClassName?: string;
}
export const SchemaSettingsModalItem: FC<SchemaSettingsModalItemProps> = (props) => {
const {
@ -830,6 +832,7 @@ export const SchemaSettingsModalItem: FC<SchemaSettingsModalItemProps> = (props)
width = 'fit-content',
noRecord = false,
ModalContextProvider = (props) => <>{props.children}</>,
dialogRootClassName,
...others
} = props;
const options = useContext(SchemaOptionsContext);
@ -850,6 +853,7 @@ export const SchemaSettingsModalItem: FC<SchemaSettingsModalItemProps> = (props)
const { getOperators } = useOperators();
const locationSearch = useLocationSearch();
const variableOptions = useVariables();
const allDataBlocks = useContext(AllDataBlocksContext);
// 解决变量`当前对象`值在弹窗中丢失的问题
const { formValue: subFormValue, collection: subFormCollection, parent } = useSubFormValue();
@ -870,41 +874,42 @@ export const SchemaSettingsModalItem: FC<SchemaSettingsModalItemProps> = (props)
const values = asyncGetInitialValues ? await asyncGetInitialValues() : initialValues;
const schema = _.isFunction(props.schema) ? props.schema() : props.schema;
FormDialog(
{ title: schema.title || title, width },
{ title: schema.title || title, width, rootClassName: dialogRootClassName },
() => {
return (
<ModalContextProvider>
<CollectOperators defaultOperators={getOperators()}>
<VariablesContext.Provider value={variableOptions}>
<BlockContext.Provider value={blockOptions}>
<VariablePopupRecordProvider
recordData={popupRecordVariable?.value}
collection={popupRecordVariable?.collection}
parent={{
recordData: parentPopupRecordVariable?.value,
collection: parentPopupRecordVariable?.collection,
}}
>
<CollectionRecordProvider record={noRecord ? null : record}>
<CurrentRecordContextProvider {...currentRecordCtx}>
<FormBlockContext.Provider value={formCtx}>
<SubFormProvider value={{ value: subFormValue, collection: subFormCollection, parent }}>
<FormActiveFieldsProvider
name="form"
getActiveFieldsName={upLevelActiveFields?.getActiveFieldsName}
>
<LocationSearchContext.Provider value={locationSearch}>
<BlockRequestContext_deprecated.Provider value={ctx}>
<DataSourceApplicationProvider dataSourceManager={dm} dataSource={dataSourceKey}>
<AssociationOrCollectionProvider
allowNull
collection={collection?.name}
association={association}
>
<SchemaComponentOptions scope={options.scope} components={options.components}>
<FormLayout
layout={'vertical'}
className={css`
<AllDataBlocksContext.Provider value={allDataBlocks}>
<ModalContextProvider>
<CollectOperators defaultOperators={getOperators()}>
<VariablesContext.Provider value={variableOptions}>
<BlockContext.Provider value={blockOptions}>
<VariablePopupRecordProvider
recordData={popupRecordVariable?.value}
collection={popupRecordVariable?.collection}
parent={{
recordData: parentPopupRecordVariable?.value,
collection: parentPopupRecordVariable?.collection,
}}
>
<CollectionRecordProvider record={noRecord ? null : record}>
<CurrentRecordContextProvider {...currentRecordCtx}>
<FormBlockContext.Provider value={formCtx}>
<SubFormProvider value={{ value: subFormValue, collection: subFormCollection, parent }}>
<FormActiveFieldsProvider
name="form"
getActiveFieldsName={upLevelActiveFields?.getActiveFieldsName}
>
<LocationSearchContext.Provider value={locationSearch}>
<BlockRequestContext_deprecated.Provider value={ctx}>
<DataSourceApplicationProvider dataSourceManager={dm} dataSource={dataSourceKey}>
<AssociationOrCollectionProvider
allowNull
collection={collection?.name}
association={association}
>
<SchemaComponentOptions scope={options.scope} components={options.components}>
<FormLayout
layout={'vertical'}
className={css`
// screen > 576px
@media (min-width: 576px) {
min-width: 520px;
@ -915,34 +920,35 @@ export const SchemaSettingsModalItem: FC<SchemaSettingsModalItemProps> = (props)
min-width: 320px;
}
`}
>
<ApplicationContext.Provider value={app}>
<APIClientProvider apiClient={apiClient}>
<ConfigProvider locale={locale}>
<SchemaComponent
components={components}
scope={scope}
schema={schema}
/>
</ConfigProvider>
</APIClientProvider>
</ApplicationContext.Provider>
</FormLayout>
</SchemaComponentOptions>
</AssociationOrCollectionProvider>
</DataSourceApplicationProvider>
</BlockRequestContext_deprecated.Provider>
</LocationSearchContext.Provider>
</FormActiveFieldsProvider>
</SubFormProvider>
</FormBlockContext.Provider>
</CurrentRecordContextProvider>
</CollectionRecordProvider>
</VariablePopupRecordProvider>
</BlockContext.Provider>
</VariablesContext.Provider>
</CollectOperators>
</ModalContextProvider>
>
<ApplicationContext.Provider value={app}>
<APIClientProvider apiClient={apiClient}>
<ConfigProvider locale={locale}>
<SchemaComponent
components={components}
scope={scope}
schema={schema}
/>
</ConfigProvider>
</APIClientProvider>
</ApplicationContext.Provider>
</FormLayout>
</SchemaComponentOptions>
</AssociationOrCollectionProvider>
</DataSourceApplicationProvider>
</BlockRequestContext_deprecated.Provider>
</LocationSearchContext.Provider>
</FormActiveFieldsProvider>
</SubFormProvider>
</FormBlockContext.Provider>
</CurrentRecordContextProvider>
</CollectionRecordProvider>
</VariablePopupRecordProvider>
</BlockContext.Provider>
</VariablesContext.Provider>
</CollectOperators>
</ModalContextProvider>
</AllDataBlocksContext.Provider>
);
},
theme,
@ -978,13 +984,13 @@ export const SchemaSettingsDefaultSortingRules = function DefaultSortingRules(pr
const sort = defaultSort?.map((item: string) => {
return item.startsWith('-')
? {
field: item.substring(1),
direction: 'desc',
}
field: item.substring(1),
direction: 'desc',
}
: {
field: item,
direction: 'asc',
};
field: item,
direction: 'asc',
};
});
const sortFields = useSortFields(props.name || collection?.name);

View File

@ -52,24 +52,10 @@ export function SchemaSettingsConnectDataBlocks(props) {
const Content = dataBlocks.map((block) => {
const title = `${compile(block.collection.title)} #${block.uid.slice(0, 4)}`;
const onHover = () => {
const dom = block.dom;
const designer = dom.querySelector('.general-schema-designer') as HTMLElement;
if (designer) {
designer.style.display = 'block';
}
dom.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.2)';
dom.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
block.highlightBlock();
};
const onLeave = () => {
const dom = block.dom;
const designer = dom.querySelector('.general-schema-designer') as HTMLElement;
if (designer) {
designer.style.display = null;
}
dom.style.boxShadow = 'none';
block.unhighlightBlock();
};
if (isSameCollection(block.collection, collection)) {
return (

View File

@ -20,6 +20,8 @@ import {
RefreshDataBlockRequest,
useAfterSuccessOptions,
useGlobalVariable,
BlocksSelector,
usePlugin,
} from '@nocobase/client';
import { useTranslation } from 'react-i18next';
import React from 'react';
@ -61,13 +63,19 @@ const useVariableProps = (environmentVariables) => {
fieldNames,
};
};
function AfterSuccess() {
const { dn } = useDesignable();
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
const environmentVariables = useGlobalVariable('$env');
const templatePlugin: any = usePlugin('@nocobase/plugin-block-template');
const isInBlockTemplateConfigPage = templatePlugin?.isInBlockTemplateConfigPage?.();
return (
<SchemaSettingsModalItem
dialogRootClassName='dialog-after-successful-submission'
width={700}
title={t('After successful submission')}
initialValues={fieldSchema?.['x-action-settings']?.['onSuccess']}
schema={
@ -116,6 +124,18 @@ function AfterSuccess() {
// eslint-disable-next-line react-hooks/rules-of-hooks
'x-use-component-props': () => useVariableProps(environmentVariables),
},
blocksToRefresh: {
type: 'array',
title: t('Refresh data blocks'),
'x-decorator': 'FormItem',
'x-use-decorator-props': () => {
return {
tooltip: t('After successful submission, the selected data blocks will be automatically refreshed.'),
};
},
'x-component': BlocksSelector,
'x-hidden': isInBlockTemplateConfigPage, // 模板配置页面暂不支持该配置
},
},
} as ISchema
}

View File

@ -42,107 +42,113 @@ export const useCustomizeBulkUpdateActionProps = () => {
const localVariables = useLocalVariables();
return {
async onClick(e, callBack) {
const {
assignedValues: originalAssignedValues = {},
onSuccess,
updateMode,
} = actionSchema?.['x-action-settings'] ?? {};
actionField.data = field.data || {};
actionField.data.loading = true;
const selectedRecordKeys =
tableBlockContext.field?.data?.selectedRowKeys ?? expressionScope?.selectedRecordKeys ?? {};
return new Promise<void>(async (resolve) => {
const {
assignedValues: originalAssignedValues = {},
onSuccess,
updateMode,
} = actionSchema?.['x-action-settings'] ?? {};
actionField.data = field.data || {};
actionField.data.loading = true;
const selectedRecordKeys =
tableBlockContext.field?.data?.selectedRowKeys ?? expressionScope?.selectedRecordKeys ?? {};
const assignedValues = {};
const waitList = Object.keys(originalAssignedValues).map(async (key) => {
const value = originalAssignedValues[key];
const collectionField = getField(key);
const assignedValues = {};
const waitList = Object.keys(originalAssignedValues).map(async (key) => {
const value = originalAssignedValues[key];
const collectionField = getField(key);
if (process.env.NODE_ENV !== 'production') {
if (!collectionField) {
throw new Error(`useCustomizeBulkUpdateActionProps: field "${key}" not found in collection "${name}"`);
if (process.env.NODE_ENV !== 'production') {
if (!collectionField) {
throw new Error(`useCustomizeBulkUpdateActionProps: field "${key}" not found in collection "${name}"`);
}
}
}
if (isVariable(value)) {
const result = await variables?.parseVariable(value, localVariables).then(({ value }) => value);
if (result) {
assignedValues[key] = transformVariableValue(result, { targetCollectionField: collectionField });
if (isVariable(value)) {
const result = await variables?.parseVariable(value, localVariables).then(({ value }) => value);
if (result) {
assignedValues[key] = transformVariableValue(result, { targetCollectionField: collectionField });
}
} else if (value !== '') {
assignedValues[key] = value;
}
} else if (value !== '') {
assignedValues[key] = value;
}
});
await Promise.all(waitList);
});
await Promise.all(waitList);
modal.confirm({
title: t('Bulk update', { ns: 'client' }),
content:
updateMode === 'selected'
? t('Update selected data?', { ns: 'client' })
: t('Update all data?', { ns: 'client' }),
async onOk() {
const { filter } = service.params?.[0] ?? {};
const updateData: { filter?: any; values: any; forceUpdate: boolean } = {
values: { ...assignedValues },
filter,
forceUpdate: false,
};
if (updateMode === 'selected') {
if (!selectedRecordKeys?.length) {
message.error(t('Please select the records to be updated'));
modal.confirm({
title: t('Bulk update', { ns: 'client' }),
content:
updateMode === 'selected'
? t('Update selected data?', { ns: 'client' })
: t('Update all data?', { ns: 'client' }),
async onOk() {
const { filter } = service.params?.[0] ?? {};
const updateData: { filter?: any; values: any; forceUpdate: boolean } = {
values: { ...assignedValues },
filter,
forceUpdate: false,
};
if (updateMode === 'selected') {
if (!selectedRecordKeys?.length) {
message.error(t('Please select the records to be updated'));
actionField.data.loading = false;
return;
}
updateData.filter = { $and: [{ [rowKey || 'id']: { $in: selectedRecordKeys } }] };
}
if (!updateData.filter) {
updateData.forceUpdate = true;
}
try {
await resource.update(updateData);
} catch (error) {
/* empty */
} finally {
actionField.data.loading = false;
}
if (callBack) {
callBack?.();
}
// service?.refresh?.();
if (!(resource instanceof TableFieldResource)) {
__parent?.service?.refresh?.();
}
if (!onSuccess?.successMessage) {
return;
}
updateData.filter = { $and: [{ [rowKey || 'id']: { $in: selectedRecordKeys } }] };
}
if (!updateData.filter) {
updateData.forceUpdate = true;
}
try {
await resource.update(updateData);
} catch (error) {
/* empty */
} finally {
actionField.data.loading = false;
}
if (callBack) {
callBack?.();
}
// service?.refresh?.();
if (!(resource instanceof TableFieldResource)) {
__parent?.service?.refresh?.();
}
if (!onSuccess?.successMessage) {
return;
}
if (onSuccess?.manualClose) {
modal.success({
title: compile(onSuccess?.successMessage),
onOk: async () => {
if (onSuccess?.redirecting && onSuccess?.redirectTo) {
if (isURL(onSuccess.redirectTo)) {
window.location.href = onSuccess.redirectTo;
} else {
navigate(onSuccess.redirectTo);
if (onSuccess?.manualClose) {
modal.success({
title: compile(onSuccess?.successMessage),
onOk: async () => {
if (onSuccess?.redirecting && onSuccess?.redirectTo) {
if (isURL(onSuccess.redirectTo)) {
window.location.href = onSuccess.redirectTo;
} else {
navigate(onSuccess.redirectTo);
}
}
},
});
} else {
message.success(compile(onSuccess?.successMessage));
if (onSuccess?.redirecting && onSuccess?.redirectTo) {
if (isURL(onSuccess.redirectTo)) {
window.location.href = onSuccess.redirectTo;
} else {
navigate(onSuccess.redirectTo);
}
},
});
} else {
message.success(compile(onSuccess?.successMessage));
if (onSuccess?.redirecting && onSuccess?.redirectTo) {
if (isURL(onSuccess.redirectTo)) {
window.location.href = onSuccess.redirectTo;
} else {
navigate(onSuccess.redirectTo);
}
}
}
},
async onCancel() {
actionField.data.loading = false;
},
resolve();
},
async onCancel() {
actionField.data.loading = false;
resolve();
},
});
});
},
};
};

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useRequest, RemoteSchemaComponent } from '@nocobase/client';
import { useRequest, RemoteSchemaComponent, AllDataBlocksProvider } from '@nocobase/client';
import React from 'react';
import { useT } from '../locale';
import { useParams } from 'react-router';
@ -31,7 +31,7 @@ export const BlockTemplatePage = () => {
const schemaUid = data?.data?.uid;
return (
<div>
<AllDataBlocksProvider>
<div
style={{
marginTop: -token.marginXXL,
@ -68,6 +68,6 @@ export const BlockTemplatePage = () => {
<RemoteSchemaComponent uid={schemaUid} />
</BlockTemplateInfoContext.Provider>
</div>
</div>
</AllDataBlocksProvider>
);
};

View File

@ -9,6 +9,7 @@
import {
AdminProvider,
AllDataBlocksProvider,
AntdAppProvider,
AssociationFieldMode,
AssociationFieldModeProvider,
@ -126,7 +127,9 @@ export const Mobile = () => {
<AssociationFieldModeProvider modeToComponent={modeToComponent}>
{/* the z-index of all popups and subpages will be based on this value */}
<zIndexContext.Provider value={100}>
<MobileRouter />
<AllDataBlocksProvider>
<MobileRouter />
</AllDataBlocksProvider>
</zIndexContext.Provider>
</AssociationFieldModeProvider>
</ResetSchemaOptionsProvider>