feat: new block template plugin (#5920)

* feat: new block template plugin

* fix: build error

* fix: support nested template in blocks

* fix: remove undefined x-component properties from schema during template processing [skip ci]

* feat: reset action for template block

* fix: skip dnd error[skip ci]

* fix: dupliate template action[skip ci]

* fix: plugin not shown in test env

* fix: reset action not been shown

* fix: no permission error

* fix: loading error in production env

* fix: cross env dev error

* fix: blocks not shown in production env

* fix: insert template failing for the first time

* chore: merge

* fix: association record option not working correctly

* fix: setting error

* fix: setting

* fix: linkage error

* fix: association settings error

* fix: association record error

* feat: support edit form tpl

* fix: support more blocks

* fix: avoid nested template

* chore: re org codes

* chore: refactor[skip ci]

* chore: required comment for loading template [skip ci]

* refactor: simplify schema handling and add axios interceptors for template blocks[skip ci]

* fix: i18n [skip ci]

* feat: support showing template title in block

* fix: revert to template will lost the template title

* fix: delete operation removing blocks not working

* fix: template label

* fix: i18n error

* fix: form type should be shown only when current record

* feat: show template in the add new popup

* fix: form type switch should not be shown in add new block popup

* fix: missing i18n

* fix: associate fields should be shown only having current record

* fix: switching form not working in cn

* fix: incorrect form associate setting

* fix: uniq action issue

* fix: uniq fields/actions[skip ci]

* fix: style issue

* fix: error in configure actions

* fix: bulk destroy when no records selected

* fix: only show revert to template config in the block level

* fix: table refresh pagination incorrect after deletion

* fix: be able to input name of template

* fix: bulk destroy

* fix: reset setting will sync with template

* fix: block template table style

* fix: missing translatation

* fix: cache issue

* fix: blocks not shown in the popup after modifing the template

* feat: add search for template initializers

* fix: some blocks are missing template related setting

* fix: hide save as template in template configure view

* chore: revert incorrect commit

* fix: batchpatch error

* fix: mobile support

* fix: build error

* fix: limit one template one block

* feat: show revert to template for fields and actions

* fix: build error

* fix: revert to template not refreshing the ui for actions

* fix: revert to template not working for form

* fix: duplicate revert to setting setting item

* chore: rename reset to revert

* feat: disallow delete template's blocks in page

* feat: add colorTemplateBgSettingsHover for template block hover state

* fix: build error

* chore: hide convert to block template setting item for page

* fix: data template should be hidden in edit form

* fix: fields switch should be disabled

* chore: rename var [skip ci]

* fix: should not be able remove field in block

* fix: after revert settings, is able to remove block in template

* fix: revert settings

* fix: nested template block error

* refactor: cache

* fix: template key has not been validated in client side

* fix: only show template that has been configured

* feat: show template name in edit form

* chore: update package.json

* fix: duplicated fields after dnd in form

* fix: duplicated fields shown in form

* chore: hide old block template menu

* feat: support mobile block template [skip ci]

* fix: filter for type options [skip ci]

* fix: incorrect create new  button style [skip ci]

* feat: add mobile block support

* fix: can't restrict one template one block [skip ci]

* fix: template title not synced after editing template

* fix: keep block deletion should transform the template block to normal block

* fix: insert template fails for the first time

* fix: destroy error

* fix: deploy failure [skip ci]

* fix: destory error in subapp with sub domain

* fix: destroy error

* fix: popup action should be hidden in create new form [skip ci]

* fix: possible crash when converting template to block [skip ci]

* fix: some properties have not been revert in real time

* fix: fitler action condition error

* fix: useDataBlockResource error

* fix: revert settings not refresh filter action form [skip ci]

* fix: new template properties clear

* fix: custom request action can't be shown

* fix: template tab should not be removable

* fix: duplicated delete action in table column

* fix: field link popup not shown in block template page

* fix: page configure link not working correctly

* chore: revert useContextVaraible [skip ci]

* fix(popup): fix configured page not taking effect

* fix: add blocks  button shown in block settings

* fix: only loading 20 templates

* fix: add block icon has been shown [skip ci]

* fix: association not shown in block template configure page

* fix: edit association form error [skip ci]

* fix: console error [skip ci]

* fix: only current field has been refreshed after revert to template [skip ci]

* chore: remove incorrect commit file [skip ci]

* fix: some action delete action still be shown for template block [skip ci]

* fix: template block style[skip ci]

* fix: keep position when rever setting[skip ci]

* fix: revert to template error when already deleted from block [skip ci]

* fix: revert still works even the template has been deleted

* fix: popup not shown as template block [skip ci]

* fix: bulk destory can't keep blocks[skip ci]

* fix: after dnd some unique blocks may be duplicated in the client [skip ci]

* fix: duplicate fields error in form

* fix: revert setting incorrect [skip ci]

* fix: duplicated sub form

* refactor: simplify by moving template loading to backend [skip  ci]

* fix: failing to add template block [skip ci]

* fix: assign fields values in bulk update action [skip ci]

* fix: can't add fields in nester popup subform [skip ci]

* fix: sub-table sub-form not merged correctly [skip ci]

* fix: subtable not highlight unique fields correctly [skip ci]

* fix: block title will not be synced correctly [skip ci]

* fix: can't add block into template [skip ci]

* fix: revert setting not refreshed [skip ci]

* fix: block template title not correct in some cases [skip ci]

* fix: field link's popup merge[skip ci]

* fix: cannot read properties of null in some popup [skip ci]

* fix: position of ui components have not been saved after dnd [skip ci]

* fix: dnd position not saved correctly in some cases [skip ci]

* fix: dnd undefined type error [skip ci]

* fix: can't swtich form type

* fix: some form type switch error [skip ci]

* fix: hide chart block from templates [skip ci]

* feat: support hide some blocks from template [skip ci]

* fix: react error for revert submit button of edit form [skip ci]

* fix: hide workflow and approvar block from template

* fix: hide connect data block from template [skip ci]

* fix: error [skip ci]

* fix: associate record options have not been shown for create form [skip ci]

* fix: creat form popup will be shown after refresh if switch form type [skip ci]

* fix: associate record settings error [skip ci]

* fix: mobile template setting page can not open popup [skip ci]

* fix: mobile content overflow [skip ci]

* chore: update templates menu style [skip ci]

* fix: unique disassociate btn [skip ci]

* feat: save collection and component info while update template

* fix: clear template context info after remove block

* chore: update template block entry point

* fix: incorrect association field template block [skip ci]

* fix: template title only shown after refresh

* fix: tooltip can't be revert correctly after moving entry to submenu [skip ci]

* fix: incorrect behavior after adding collection submenu entries [skip ci]

* fix: edit form support

* fix: support current details [skip ci]

* fix: edit form btn not correct [skip ci]

* fix: create form action params [skip ci]

* fix: hide template menu entry in block template configure page [skip ci]

* fix: incorrect association [skip ci]

* fix: nested template [skip ci]

* fix: can't insert template in mobile [skip ci]

* fix: association title not correct for details block [skip ci]

* chore: remove incorrect submodules commit [skip ci]

* fix: create form is using post method [skip ci]

* fix: association hasone and belongsto details block error [skip ci]

* fix: edit form not able load data [skip ci]

* chore: hide tab bar in mobile template configure page [skip ci]

* fix: don't show no accessible pages for template configure page [skip ci]

* fix: not able to see template list on the first time opening popup [skip ci]

* fix: able to remove related approvals template block [skip ci]

* fix: data not loading for details hasone relationship [skip ci]

* fix: dnd position may not be saved [skip ci]

* fix: multi-step-form is able to delete template step[skip ci]

* fix: hide rever setting in multi step form step name [skip ci]

* fix: extrol wrap when adding blocks from template [skip ci]

* fix: nested schema patch [skip ci]

* fix: x-acl-action not correct[skip ci]

* fix: diassociate action should be unique [skip ci]

* fix: disassociate action not unique [skip ci]

* fix: mobile popup by url not working [skip ci]

* chore: code clean

* chore: update delete setting position [skip ci]

* chore: keep revert btn position consistant in all places [skip ci]

* chore: hide template from workflow setting page [skip ci]

* chore: update setting menu icon [skip ci]

* chore: rename provider name to avoid duplicated with core [skip ci]

* chore: move block template menu to an upper level [skip ci]

* fix: hide other block templates menu [skip ci]

* fix: hide other blocks when creating template

* fix: duplicate revert to template option

* fix: mail manager template block not shown in popup

* fix: main block not showing in popup

* chore: mark old template features as deprecated

* chore: hide deprecated templates in block template configure page

* fix: read x-virtual from null

* fix: ci test error

* fix: skip old templates e2e test cases

* fix: skip old templates e2e test cases

---------

Co-authored-by: Zeke Zhang <958414905@qq.com>
This commit is contained in:
gchust 2025-02-20 22:23:07 +08:00 committed by GitHub
parent 4e3a567c2a
commit ae6b801132
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
125 changed files with 5442 additions and 259 deletions

View File

@ -46,6 +46,7 @@ export const CSSVariableProvider = ({ children }) => {
document.body.style.setProperty('--colorBgScrollBarActive', colorBgScrollBarActive);
document.body.style.setProperty('--colorSettings', token.colorSettings || defaultTheme.token.colorSettings);
document.body.style.setProperty('--colorBgSettingsHover', token.colorBgSettingsHover);
document.body.style.setProperty('--colorTemplateBgSettingsHover', token.colorTemplateBgSettingsHover);
document.body.style.setProperty('--colorBorderSettingsHover', token.colorBorderSettingsHover);
// 设置登录页面的背景色
@ -58,6 +59,7 @@ export const CSSVariableProvider = ({ children }) => {
token.colorBgContainer,
token.colorBgLayout,
token.colorBgSettingsHover,
token.colorTemplateBgSettingsHover,
token.colorBorderSettingsHover,
token.colorInfoBg,
token.colorInfoBorder,

View File

@ -24,6 +24,7 @@ const defaultTheme: ThemeConfig = {
// UI 配置组件
colorSettings: '#F18B62',
colorBgSettingsHover: 'rgba(241, 139, 98, 0.06)',
colorTemplateBgSettingsHover: 'rgba(98, 200, 241, 0.06)', // 默认为colorBgSettingsHover的互补色
colorBorderSettingsHover: 'rgba(241, 139, 98, 0.3)',
// 动画相关

View File

@ -30,6 +30,8 @@ export interface CustomToken extends AliasToken {
colorSettings: string;
/** 鼠标悬浮时显示的背景色 */
colorBgSettingsHover: string;
/** 鼠标悬浮模板区块时显示的背景色 */
colorTemplateBgSettingsHover: string;
/** 鼠标悬浮时显示的边框色 */
colorBorderSettingsHover: string;

View File

@ -164,6 +164,7 @@
"Chart type": "Chart type",
"Chart config": "Chart config",
"Templates": "Templates",
"Template": "Template",
"Select template": "Select template",
"Action logs": "Action logs",
"Create template": "Create template",
@ -882,5 +883,7 @@
"Are you sure you want to hide this menu?": "Are you sure you want to hide this menu?",
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.",
"If selected, the page will display Tab pages.": "If selected, the page will display Tab pages.",
"If selected, the route will be displayed in the menu.": "If selected, the route will be displayed in the menu."
"If selected, the route will be displayed in the menu.": "If selected, the route will be displayed in the menu.",
"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."
}

View File

@ -148,6 +148,7 @@
"Chart type": "Tipo del gráfico",
"Chart config": "Configuración del gráfico",
"Templates": "Plantillas",
"Template": "Plantilla",
"Select template": "Seleccione plantilla",
"Action logs": "Acción logs",
"Create template": "Crear plantilla",
@ -799,5 +800,7 @@
"Are you sure you want to hide this menu?": "¿Estás seguro de que quieres ocultar este menú?",
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "Después de ocultar, este menú ya no aparecerá en la barra de menú. Para mostrarlo de nuevo, debe ir a la página de administración de rutas para configurarlo.",
"If selected, the page will display Tab pages.": "Si se selecciona, la página mostrará páginas de pestañas.",
"If selected, the route will be displayed in the menu.": "Si se selecciona, la ruta se mostrará en el menú."
"If selected, the route will be displayed in the menu.": "Si se selecciona, la ruta se mostrará en el menú.",
"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."
}

View File

@ -159,6 +159,7 @@
"Chart type": "Type de graphique",
"Chart config": "Configuration du graphique",
"Templates": "Modèles",
"Template": "Modèle",
"Select template": "Sélectionner un modèle",
"Action logs": "Logs d'action",
"Create template": "Créer un modèle",
@ -819,5 +820,7 @@
"Are you sure you want to hide this menu?": "Êtes-vous sûr de vouloir masquer ce menu ?",
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "Après avoir masqué, ce menu ne sera plus affiché dans la barre de menu. Pour le réafficher, vous devez aller à la page de gestion des routes pour le configurer.",
"If selected, the page will display Tab pages.": "Si sélectionné, la page affichera des onglets.",
"If selected, the route will be displayed in the menu.": "Si sélectionné, la route sera affichée dans le menu."
"If selected, the route will be displayed in the menu.": "Si sélectionné, la route sera affichée dans le menu.",
"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."
}

View File

@ -145,6 +145,7 @@
"Chart type": "チャートタイプ",
"Chart config": "チャート設定",
"Templates": "テンプレート",
"Template": "テンプレート",
"Select template": "テンプレートを選択してください",
"Action logs": "操作履歴",
"Create template": "テンプレートを作成",
@ -1037,5 +1038,7 @@
"Are you sure you want to hide this menu?": "このメニューを非表示にしますか?",
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "非表示にすると、このメニューはメニューバーに表示されなくなります。再表示するには、ルート管理ページで設定する必要があります。",
"If selected, the page will display Tab pages.": "選択されている場合、ページはタブページを表示します。",
"If selected, the route will be displayed in the menu.": "選択されている場合、ルートはメニューに表示されます。"
"If selected, the route will be displayed in the menu.": "選択されている場合、ルートはメニューに表示されます。",
"Deprecated": "非推奨",
"The following old template features have been deprecated and will be removed in next version.": "次の古いテンプレート機能は非推奨になり、次のバージョンで削除されます。"
}

View File

@ -183,6 +183,7 @@
"Chart type": "차트 유형",
"Chart config": "차트 구성",
"Templates": "템플릿",
"Template": "템플릿",
"Select template": "템플릿 선택",
"Action logs": "작업 로그",
"Create template": "템플릿 생성",
@ -910,5 +911,7 @@
"Are you sure you want to hide this menu?": "이 메뉴를 숨기시겠습니까?",
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "숨기면 이 메뉴는 메뉴 바에 더 이상 표시되지 않습니다. 다시 표시하려면 라우트 관리 페이지에서 설정해야 합니다.",
"If selected, the page will display Tab pages.": "선택되면 페이지는 탭 페이지를 표시합니다.",
"If selected, the route will be displayed in the menu.": "선택되면 라우트는 메뉴에 표시됩니다."
"If selected, the route will be displayed in the menu.": "선택되면 라우트는 메뉴에 표시됩니다.",
"Deprecated": "사용 중단됨",
"The following old template features have been deprecated and will be removed in next version.": "다음 오래된 템플릿 기능은 사용 중단되었으며 다음 버전에서 제거될 것입니다."
}

View File

@ -112,6 +112,7 @@
"Chart type": "Tipo de gráfico",
"Chart config": "Configuração do gráfico",
"Templates": "Modelos",
"Template": "Modelo",
"Select template": "Selecione um modelo",
"Action logs": "Registros de ação",
"Create template": "Criar modelo",
@ -776,5 +777,7 @@
"Are you sure you want to hide this menu?": "Tem certeza de que deseja ocultar este menu?",
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "Depois de ocultar, este menu não aparecerá mais na barra de menus. Para mostrar novamente, você precisa ir à página de gerenciamento de rotas para configurá-lo.",
"If selected, the page will display Tab pages.": "Se selecionado, a página exibirá páginas de abas.",
"If selected, the route will be displayed in the menu.": "Se selecionado, a rota será exibida no menu."
"If selected, the route will be displayed in the menu.": "Se selecionado, a rota será exibida no menu.",
"Deprecated": "Descontinuado",
"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."
}

View File

@ -102,6 +102,7 @@
"Chart type": "Тип диаграммы",
"Chart config": "Конфиг. диаграммы",
"Templates": "Шаблоны",
"Template": "Шаблон",
"Select template": "Выбрать шаблон",
"Action logs": "Журналы действий",
"Create template": "Создать шаблон",
@ -605,5 +606,7 @@
"Are you sure you want to hide this menu?": "Вы уверены, что хотите скрыть это меню?",
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "После скрытия этого меню он больше не будет отображаться в меню. Чтобы снова отобразить его, вам нужно будет перейти на страницу управления маршрутами и настроить его.",
"If selected, the page will display Tab pages.": "Если выбран, страница будет отображать страницы с вкладками.",
"If selected, the route will be displayed in the menu.": "Если выбран, маршрут будет отображаться в меню."
"If selected, the route will be displayed in the menu.": "Если выбран, маршрут будет отображаться в меню.",
"Deprecated": "Устаревший",
"The following old template features have been deprecated and will be removed in next version.": "Следующие старые функции шаблонов устарели и будут удалены в следующей версии."
}

View File

@ -102,6 +102,7 @@
"Chart type": "Grafik türü",
"Chart config": "Grafik yapılandırması",
"Templates": "Şablonlar",
"Template": "Şablon",
"Select template": "Şablon seç",
"Action logs": "Eylem günlükleri",
"Create template": "Şablon oluştur",
@ -603,5 +604,7 @@
"Are you sure you want to hide this menu?": "Bu menüyü gizlemek istediğinizden emin misiniz?",
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "Gizlendikten sonra, bu menü artık menü çubuğunda görünmeyecektir. Tekrar görüntülemek için, yönlendirme yönetimi sayfasına gidip onu yapılandırmanız gerekecektir.",
"If selected, the page will display Tab pages.": "Seçildiğinde, sayfa Tab sayfalarını görüntüleyecektir.",
"If selected, the route will be displayed in the menu.": "Seçildiğinde, yol menüde görüntülenecektir."
"If selected, the route will be displayed in the menu.": "Seçildiğinde, yol menüde görüntülenecektir.",
"Deprecated": "Kullanımdan kaldırıldı",
"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."
}

View File

@ -159,6 +159,7 @@
"Chart type": "Тип діаграми",
"Chart config": "Налаштування діаграми",
"Templates": "Шаблони",
"Template": "Шаблон",
"Select template": "Вибрати шаблон",
"Action logs": "Журнал дій",
"Create template": "Створити шаблон",
@ -819,5 +820,7 @@
"Are you sure you want to hide this menu?": "Ви впевнені, що хочете приховати це меню?",
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "Після приховування цього меню він більше не з'явиться в меню. Щоб знову показати його, вам потрібно перейти на сторінку керування маршрутами і налаштувати його.",
"If selected, the page will display Tab pages.": "Якщо вибрано, сторінка відобразить сторінки з вкладками.",
"If selected, the route will be displayed in the menu.": "Якщо вибрано, маршрут буде відображений в меню."
"If selected, the route will be displayed in the menu.": "Якщо вибрано, маршрут буде відображений в меню.",
"Deprecated": "Застаріло",
"The following old template features have been deprecated and will be removed in next version.": "Наступні старі функції шаблонів були застарілі і будуть видалені в наступній версії."
}

View File

@ -183,6 +183,7 @@
"Chart type": "图表类型",
"Chart config": "图表配置",
"Templates": "模板",
"Template": "模板",
"Select template": "选择模板",
"Action logs": "操作日志",
"Create template": "创建模板",
@ -1078,5 +1079,7 @@
"Are you sure you want to hide this menu?": "你确定要隐藏这个菜单吗?",
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "隐藏后,这个菜单将不再出现在菜单栏中。要再次显示它,你需要到路由管理页面进行设置。",
"If selected, the page will display Tab pages.": "如果选中,该页面将显示标签页。",
"If selected, the route will be displayed in the menu.": "如果选中,该路由将显示在菜单中。"
"If selected, the route will be displayed in the menu.": "如果选中,该路由将显示在菜单中。",
"Deprecated": "已弃用",
"The following old templates have been deprecated and will be removed in next version.": "以下旧的模板功能已弃用,将在下个版本移除。"
}

View File

@ -183,6 +183,7 @@
"Chart type": "圖表型別",
"Chart config": "圖表設定",
"Templates": "模板",
"Template": "模板",
"Select template": "選擇模板",
"Action logs": "動作日誌",
"Create template": "建立模板",
@ -910,6 +911,8 @@
"Are you sure you want to hide this menu?": "你確定要隱藏這個菜單嗎?",
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "隱藏後,這個菜單將不再出現在菜單欄中。要再次顯示它,你需要到路由管理頁面進行設置。",
"If selected, the page will display Tab pages.": "如果選中,該頁面將顯示標籤頁。",
"If selected, the route will be displayed in the menu.": "如果選中,該路由將顯示在菜單中。"
"If selected, the route will be displayed in the menu.": "如果選中,該路由將顯示在菜單中。",
"Deprecated": "已棄用",
"The following old template features have been deprecated and will be removed in next version.": "以下舊的模板功能已棄用,將在下個版本移除。"
}

View File

@ -15,7 +15,7 @@ import {
} from './templates';
test.describe('Submit: should refresh data after submit', () => {
test('submit in reference template block', async ({ page, mockPage, clearBlockTemplates, mockRecord }) => {
test.skip('submit in reference template block', async ({ page, mockPage, clearBlockTemplates, mockRecord }) => {
const nocoPage = await mockPage(submitInReferenceTemplateBlock).waitForInit();
await mockRecord('collection', { nickname: 'abc' });
await nocoPage.goto();

View File

@ -33,7 +33,7 @@ test.describe('multi data details block schema settings', () => {
'Linkage rules',
'Set the data scope',
'Set default sorting rules',
'Save as template',
// 'Save as template',
'Delete',
],
});

View File

@ -26,7 +26,7 @@ test.describe('where single data details block can be added', () => {
});
// https://nocobase.height.app/T-3848/description
test('popup opened by clicking on the button for the relationship field', async ({
test.skip('popup opened by clicking on the button for the relationship field', async ({
page,
mockPage,
mockRecord,

View File

@ -24,7 +24,7 @@ test.describe('single details block schema settings', () => {
await page.getByLabel('block-item-CardItem-general-form').hover();
await page.getByLabel('designer-schema-settings-CardItem-FormV2.ReadPrettyDesigner-general').hover();
},
supportedOptions: ['Edit block title', 'Linkage rules', 'Save as block template', 'Delete'],
supportedOptions: ['Edit block title', 'Linkage rules', 'Delete'],
});
});
});

View File

@ -51,7 +51,8 @@ test.describe('creation form block schema settings', () => {
await runExpect();
});
test('Save as block template & convert reference to duplicate', async ({ page, mockPage }) => {
// deprecated
test.skip('Save as block template & convert reference to duplicate', async ({ page, mockPage }) => {
await mockPage(oneTableBlockWithActionsAndFormBlocks).goto();
await page.getByRole('button', { name: 'Add new' }).click();
@ -115,7 +116,7 @@ test.describe('creation form block schema settings', () => {
await expect(page.getByLabel('block-item-CardItem-general-form')).not.toBeVisible();
});
test('save as block Template', async ({ page, mockPage }) => {
test.skip('save as block Template', async ({ page, mockPage }) => {
await mockPage(oneEmptyForm).goto();
// 先保存为模板 ------------------------------------------------------------------------
@ -270,7 +271,7 @@ test.describe('creation form block schema settings', () => {
});
});
test('save block template & using block template', async ({ page, mockPage, clearBlockTemplates }) => {
test.skip('save block template & using block template', async ({ page, mockPage, clearBlockTemplates }) => {
// 确保测试结束后已保存的模板会被清空
await clearBlockTemplates();
const nocoPage = await mockPage({

View File

@ -236,7 +236,7 @@ test.describe('linkage rules', () => {
});
// https://nocobase.height.app/T-3806
test('after save as block template', async ({ page, mockPage }) => {
test.skip('after save as block template', async ({ page, mockPage }) => {
await mockPage(T3806).goto();
// 1. 一开始联动规则应该正常

View File

@ -26,7 +26,7 @@ test.describe('where edit form block can be added', () => {
});
// https://nocobase.height.app/T-3848/description
test('popup opened by clicking on the button for the relationship field', async ({
test.skip('popup opened by clicking on the button for the relationship field', async ({
page,
mockPage,
mockRecord,

View File

@ -84,7 +84,7 @@ test.describe('edit form block schema settings', () => {
await runExpect();
});
test('Convert reference to duplicate & Save as block template', async ({ page, mockPage, mockRecord }) => {
test.skip('Convert reference to duplicate & Save as block template', async ({ page, mockPage, mockRecord }) => {
const nocoPage = await mockPage(oneTableBlockWithActionsAndFormBlocks).waitForInit();
await mockRecord('general');
await nocoPage.goto();

View File

@ -12,9 +12,8 @@ import { useDetailsParentRecord } from '../../details-single/hooks/useDetailsDec
import { useHiddenForInherit } from './useHiddenForInherit';
export function useEditFormBlockDecoratorProps(props) {
const params = useFormBlockParams();
const params = useFormBlockParams(props);
let parentRecord;
const { hidden } = useHiddenForInherit(props);
// association 的值是固定不变的,所以这里可以使用 hooks
@ -31,6 +30,6 @@ export function useEditFormBlockDecoratorProps(props) {
};
}
function useFormBlockParams() {
return useParamsFromRecord();
function useFormBlockParams(props) {
return useParamsFromRecord(props);
}

View File

@ -25,7 +25,7 @@ test.describe('grid card block schema settings', () => {
'Set the data scope',
'Set default sorting rules',
'Records per page',
'Save as template',
// 'Save as template',
'Delete',
],
});

View File

@ -24,7 +24,7 @@ test.describe('list block schema settings', () => {
'Set the data scope',
'Set default sorting rules',
'Records per page',
'Save as template',
// 'Save as template',
'Delete',
],
});

View File

@ -10,7 +10,7 @@
import { expect, test } from '@nocobase/test/e2e';
import { ordinaryBlockTemplatesCannotBeUsedToCreateAssociationBlocksAndViceVersa } from './templatesOfBug';
test.describe('block template', () => {
test.skip('block template', () => {
test('Ordinary block templates cannot be used to create association blocks, and vice versa', async ({
page,
mockPage,

View File

@ -34,7 +34,7 @@ test.describe('table block schema settings', () => {
'Set the data scope',
'Records per page',
'Connect data blocks',
'Save as template',
// 'Save as template',
'Delete',
],
});

View File

@ -9,8 +9,8 @@
import { expect, test } from '@nocobase/test/e2e';
test.describe('save as template', () => {
test('save as template, then delete it', async ({ page, mockPage, clearBlockTemplates }) => {
test.skip('save as template', () => {
test.skip('save as template, then delete it', async ({ page, mockPage, clearBlockTemplates }) => {
// 1. 创建一个区块,然后保存为模板
await mockPage().goto();
await page.getByLabel('schema-initializer-Grid-page:').hover();

View File

@ -25,7 +25,7 @@ test.describe('tree table block schema settings', () => {
'Set default sorting rules',
'Records per page',
'Connect data blocks',
'Save as template',
// 'Save as template',
'Delete',
],
});

View File

@ -25,7 +25,7 @@ test.describe('collapse schema settings', () => {
await page.getByLabel('block-item-CardItem-general-filter-collapse').hover();
await page.getByLabel('designer-schema-settings-CardItem-AssociationFilter.BlockDesigner-general').hover();
},
supportedOptions: ['Edit block title', 'Save as template', 'Connect data blocks', 'Delete'],
supportedOptions: ['Edit block title', 'Connect data blocks', 'Delete'],
});
});

View File

@ -28,7 +28,7 @@ test.describe('filter block schema settings', () => {
},
supportedOptions: [
'Edit block title',
'Save as block template',
// 'Save as block template',
'Linkage rules',
'Connect data blocks',
'Delete',
@ -37,7 +37,7 @@ test.describe('filter block schema settings', () => {
});
test.describe('connect data blocks', () => {
test('connecting two blocks of the same collection', async ({
test.skip('connecting two blocks of the same collection', async ({
page,
mockPage,
mockRecords,

View File

@ -35,12 +35,14 @@ export class PMPlugin extends Plugin {
// Component: ACLPane,
// aclSnippet: 'pm.acl.roles',
// });
this.app.pluginSettingsManager.add('ui-schema-storage', {
title: '{{t("Block templates")}}',
icon: 'LayoutOutlined',
Component: BlockTemplatesPane,
aclSnippet: 'pm.ui-schema-storage.block-templates',
});
// Replaced by plugin-block-template
// this.app.pluginSettingsManager.add('ui-schema-storage', {
// title: '{{t("Block templates")}}',
// icon: 'LayoutOutlined',
// Component: BlockTemplatesPane,
// aclSnippet: 'pm.ui-schema-storage.block-templates',
// });
this.app.pluginSettingsManager.add('system-settings', {
icon: 'SettingOutlined',
title: '{{t("System settings")}}',

View File

@ -167,6 +167,13 @@ export function AssignedFieldValues() {
'x-component': 'Grid',
'x-initializer': 'assignFieldValuesForm:configureFields',
};
if (fieldSchema['x-template-uid']) {
initialSchema['x-template-root-ref'] = {
'x-template-uid': fieldSchema['x-template-uid'],
'x-path': 'x-action-settings.schemaUid',
};
}
const tips = {
'customize:update': t(
'After clicking the custom button, the following fields of the current record will be saved according to the following form.',

View File

@ -34,6 +34,9 @@ const useStyles = genStyleHook('nb-action', (token) => {
background: 'var(--colorBgSettingsHover)',
border: '0',
pointerEvents: 'none',
'&.nb-in-template': {
background: 'var(--colorTemplateBgSettingsHover)',
},
'> .general-schema-designer-icons': {
position: 'absolute',
right: '2px',

View File

@ -13,7 +13,14 @@ import _ from 'lodash';
import React, { FC, Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDesignable, usePopupSettings } from '../../';
import { WithoutTableFieldResource } from '../../../block-provider';
import { CollectionRecordProvider, useCollectionManager, useCollectionRecordData } from '../../../data-source';
import {
CollectionRecordProvider,
DataBlockProvider,
useAssociationName,
useCollection,
useCollectionManager,
useCollectionRecordData,
} from '../../../data-source';
import { NocoBaseRecursionField } from '../../../formily/NocoBaseRecursionField';
import { useOpenModeContext } from '../../../modules/popup/OpenModeProvider';
import { VariablePopupRecordProvider } from '../../../modules/variable/variablesProvider/VariablePopupRecordProvider';
@ -278,12 +285,14 @@ export const ReadPrettyInternalViewer: React.FC<ReadPrettyInternalViewerProps> =
const field = useField();
const [visible, setVisible] = useState(false);
const { options: collectionField } = useAssociationFieldContext();
const associationName = useAssociationName();
const { visibleWithURL, setVisibleWithURL } = usePopupUtils();
const [btnHover, setBtnHover] = useState(!!visibleWithURL);
const { defaultOpenMode } = useOpenModeContext();
const parentRecordData = useCollectionRecordData();
const [recordData, setRecordData] = useState(null);
const { isPopupVisibleControlledByURL } = usePopupSettings();
const collection = useCollection();
const onClickItem = useCallback((props: { recordData: any }) => {
setRecordData(props.recordData);
@ -329,6 +338,15 @@ export const ReadPrettyInternalViewer: React.FC<ReadPrettyInternalViewerProps> =
}
return (
<DataBlockProvider
dataSource={collection.dataSource}
collection={collection.name}
association={associationName}
sourceId={recordData?.[collection.getPrimaryKey()]}
record={recordData}
parentRecord={parentRecordData}
action="get"
>
<CollectionRecordProvider isNew={false} record={recordData} parentRecord={parentRecordData}>
{/* The recordData here is only provided when the popup is opened, not the current row record */}
<VariablePopupRecordProvider>
@ -337,6 +355,7 @@ export const ReadPrettyInternalViewer: React.FC<ReadPrettyInternalViewerProps> =
</WithoutTableFieldResource.Provider>
</VariablePopupRecordProvider>
</CollectionRecordProvider>
</DataBlockProvider>
);
};

View File

@ -255,7 +255,7 @@ const ToManyNester = observer(
return (
<React.Fragment key={index}>
<div style={{ textAlign: 'right' }}>
{!field.readPretty && allowed && (
{!field.readPretty && allowed && (!fieldSchema['x-template-uid'] || index > 0) && (
<Tooltip key={'remove'} title={t('Remove')}>
<CloseOutlined
style={{ zIndex: 1000, color: '#a8a3a3' }}

View File

@ -45,16 +45,16 @@ describe('CollectionSelect', () => {
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-11aiz3o ant-app"
class="css-dev-only-do-not-override-1gopndx ant-app"
style="height: 100%;"
>
<div
aria-label="block-item-demo title"
class="nb-block-item nb-form-item css-1elzyjx ant-nb-block-item css-dev-only-do-not-override-11aiz3o"
class="nb-block-item nb-form-item css-1elzyjx ant-nb-block-item css-dev-only-do-not-override-1gopndx"
role="button"
>
<div
class="css-1yh5po ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-11aiz3o"
class="css-1yh5po ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-1gopndx"
>
<div
class="ant-formily-item-label"
@ -84,7 +84,7 @@ describe('CollectionSelect', () => {
class="ant-formily-item-control-content-component"
>
<div
class="ant-select css-dev-only-do-not-override-11aiz3o ant-select-focused ant-select-single ant-select-show-arrow ant-select-show-search"
class="ant-select css-dev-only-do-not-override-1gopndx ant-select-focused ant-select-single ant-select-show-arrow ant-select-show-search"
data-testid="select-collection"
role="button"
>
@ -182,16 +182,16 @@ describe('CollectionSelect', () => {
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-11aiz3o ant-app"
class="css-dev-only-do-not-override-1gopndx ant-app"
style="height: 100%;"
>
<div
aria-label="block-item-demo title"
class="nb-block-item nb-form-item css-1elzyjx ant-nb-block-item css-dev-only-do-not-override-11aiz3o"
class="nb-block-item nb-form-item css-1elzyjx ant-nb-block-item css-dev-only-do-not-override-1gopndx"
role="button"
>
<div
class="css-1yh5po ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-11aiz3o"
class="css-1yh5po ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-1gopndx"
>
<div
class="ant-formily-item-label"
@ -222,7 +222,7 @@ describe('CollectionSelect', () => {
>
<div>
<span
class="ant-tag css-dev-only-do-not-override-11aiz3o"
class="ant-tag css-dev-only-do-not-override-1gopndx"
>
Users
</span>

View File

@ -26,7 +26,7 @@ describe('ColorPicker', () => {
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-11aiz3o ant-app"
class="css-dev-only-do-not-override-1gopndx ant-app"
style="height: 100%;"
>
<div
@ -35,7 +35,7 @@ describe('ColorPicker', () => {
style="display: inline-block;"
>
<div
class="ant-color-picker-trigger css-dev-only-do-not-override-11aiz3o"
class="ant-color-picker-trigger css-dev-only-do-not-override-1gopndx"
>
<div
class="ant-color-picker-color-block"
@ -90,7 +90,7 @@ describe('ColorPicker', () => {
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-11aiz3o ant-app"
class="css-dev-only-do-not-override-1gopndx ant-app"
style="height: 100%;"
>
<div
@ -99,7 +99,7 @@ describe('ColorPicker', () => {
role="button"
>
<div
class="ant-color-picker-trigger ant-color-picker-sm css-dev-only-do-not-override-11aiz3o ant-color-picker-trigger-disabled"
class="ant-color-picker-trigger ant-color-picker-sm css-dev-only-do-not-override-1gopndx ant-color-picker-trigger-disabled"
>
<div
class="ant-color-picker-color-block"

View File

@ -215,10 +215,10 @@ describe('form.settings', () => {
title: 'Form data templates',
type: 'modal',
},
{
title: 'Save as block template',
type: 'modal',
},
// {
// title: 'Save as block template',
// type: 'modal',
// },
{
title: 'Delete',
type: 'delete',

View File

@ -27,13 +27,16 @@ import { SchemaSettingsDataScope } from '../../../schema-settings/SchemaSettings
import { SchemaSettingsTemplate } from '../../../schema-settings/SchemaSettingsTemplate';
import { useSchemaTemplate } from '../../../schema-templates';
import { useBlockTemplateContext } from '../../../schema-templates/BlockTemplateProvider';
import { useDesignable } from '../../hooks';
import { useCompile, useDesignable } from '../../hooks';
import { removeNullCondition } from '../filter';
import { useApp } from '../../../application';
/**
* @deprecated - 使 SchemaSettings
*/
export const ListDesigner = () => {
const app = useApp();
const compile = useCompile();
const { name, title } = useCollection_deprecated();
const template = useSchemaTemplate();
const { t } = useTranslation();
@ -57,8 +60,15 @@ export const ListDesigner = () => {
};
});
const { componentNamePrefix } = useBlockTemplateContext();
const RevertSetting =
app.schemaSettingsManager.getAll()['blockSettings:list']?.get('template-revertSettingItem')?.['Component'] || null;
let designerTitle = title;
if (fieldSchema['x-template-uid']) {
designerTitle = `${compile(title)} [${t('Template')}: ${compile(fieldSchema['x-template-title'])}]`;
}
return (
<GeneralSchemaDesigner template={template} title={title || name}>
<GeneralSchemaDesigner template={template} title={designerTitle || name}>
<SchemaSettingsBlockTitleItem />
<SchemaSettingsDataScope
collectionName={name}
@ -188,11 +198,15 @@ export const ListDesigner = () => {
});
}}
/>
{!fieldSchema['x-template-uid'] ? (
<>
{!RevertSetting && (
<SchemaSettingsTemplate
componentName={`${componentNamePrefix}List`}
collectionName={name}
resourceName={defaultResource}
/>
)}
<SchemaSettingsDivider />
<SchemaSettingsRemove
removeParentsIfNoChildren
@ -200,6 +214,10 @@ export const ListDesigner = () => {
'x-component': 'Grid',
}}
/>
</>
) : (
<RevertSetting />
)}
</GeneralSchemaDesigner>
);
};

View File

@ -40,8 +40,15 @@ export const usePopupSettings = () => {
const isOldMobileMode = pathname?.includes('/mobile/') || hash?.includes('/mobile/');
const isNewMobileMode = pathname?.includes('/m/');
const isPCMode = pathname?.includes('/admin/');
const isMobileTemplateSettingsPage = pathname?.includes('/m/block-templates/');
return (isPCMode || isNewMobileMode) && !isOldMobileMode && enableURL && !isInSettingsPage;
return (
(isPCMode || isNewMobileMode) &&
!isOldMobileMode &&
enableURL &&
!isInSettingsPage &&
!isMobileTemplateSettingsPage
);
}, [enableURL, isInSettingsPage]);
return {

View File

@ -21,12 +21,12 @@ describe('Pagination', () => {
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-11aiz3o ant-app"
class="css-dev-only-do-not-override-1gopndx ant-app"
style="height: 100%;"
>
<div>
<ul
class="ant-pagination css-dev-only-do-not-override-11aiz3o"
class="ant-pagination css-dev-only-do-not-override-1gopndx"
>
<li
aria-disabled="true"
@ -131,7 +131,7 @@ describe('Pagination', () => {
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-11aiz3o ant-app"
class="css-dev-only-do-not-override-1gopndx ant-app"
style="height: 100%;"
/>
</div>

View File

@ -62,8 +62,13 @@ export const TabsDesigner = () => {
});
}}
/>
{/* if it is created by template, do not show remove button */}
{fieldSchema['x-template-uid'] ? null : (
<>
<SchemaSettingsDivider />
<SchemaSettingsRemove />
</>
)}
</GeneralSchemaDesigner>
);
};

View File

@ -47,7 +47,7 @@ export const Tabs: any = React.memo((props: TabsProps) => {
});
return result;
}, [fieldSchema.mapProperties((s, key) => key).join()]);
}, [fieldSchema]);
const tabBarExtraContent = useMemo(
() => ({
@ -102,6 +102,9 @@ const designerCss = css`
left: 0;
right: 0;
pointer-events: none;
&.nb-in-template {
background: var(--colorTemplateBgSettingsHover);
}
> .general-schema-designer-icons {
position: absolute;
right: 2px;

View File

@ -20,11 +20,11 @@ describe('UnixTimestamp', () => {
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-11aiz3o ant-app"
class="css-dev-only-do-not-override-1gopndx ant-app"
style="height: 100%;"
>
<div
class="ant-picker css-dev-only-do-not-override-11aiz3o"
class="ant-picker css-dev-only-do-not-override-1gopndx"
>
<div
class="ant-picker-input"
@ -77,7 +77,7 @@ describe('UnixTimestamp', () => {
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-11aiz3o ant-app"
class="css-dev-only-do-not-override-1gopndx ant-app"
style="height: 100%;"
>
<div

View File

@ -0,0 +1,29 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { Space, Tooltip } from 'antd';
export const DeprecatedTemplateTitle = () => {
const { t } = useTranslation();
return (
<Space>
{t('Deprecated')}
<Tooltip
title={t('The following old template features have been deprecated and will be removed in next versions.')}
>
<QuestionCircleOutlined />
</Tooltip>
</Space>
);
};
export const DeprecatedTemplateTitleElement = <DeprecatedTemplateTitle />;

View File

@ -0,0 +1,37 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
// hooks/useTemplateBlockNotifier.ts
import { useFieldSchema, useForm } from '@formily/react';
import { useMemo } from 'react';
export const useTemplateBlockNotifier = () => {
const form = useForm();
const fieldSchema = useFieldSchema();
// only when TemplateGridDecorator is used, the blockAdded event will be notified
const hasTemplateGridDecorator = useMemo(() => {
let current = fieldSchema;
while (current) {
if (current['x-decorator'] === 'TemplateGridDecorator') {
return true;
}
current = current.parent;
}
return false;
}, [fieldSchema]);
const notify = (info: { collection: string; dataSource: string; componentType: string; menuName: string }) => {
if (hasTemplateGridDecorator) {
form.notify('blockAdded', info);
}
};
return notify;
};

View File

@ -149,6 +149,7 @@ export {
useRecordCollectionDataSourceItems,
useRemoveGridFormItem,
useTableColumnInitializerFields,
registerInitializerMenusGenerator,
} from './utils';
export class SchemaInitializerPlugin extends Plugin {

View File

@ -22,6 +22,7 @@ import { Collection, CollectionFieldOptions } from '../../data-source/collection
import { useCompile } from '../../schema-component';
import { useSchemaTemplateManager } from '../../schema-templates';
import { useCollectionDataSourceItems } from '../utils';
import { useTemplateBlockNotifier } from '../hooks/useTemplateBlockNotifier';
const MENU_ITEM_HEIGHT = 32;
const STEP = 15;
@ -332,8 +333,16 @@ export const DataBlockInitializer: FC<DataBlockInitializerProps> = (props) => {
const { insert, setVisible } = useSchemaInitializer();
const compile = useCompile();
const { getTemplateSchemaByMode } = useSchemaTemplateManager();
const templateBlockAddedNotifier = useTemplateBlockNotifier();
const onClick = useCallback(
async (options) => {
templateBlockAddedNotifier({
collection: options.item.name,
dataSource: options.item.dataSource,
componentType: componentType,
menuName: name,
});
const { item, fromOthersInPopup } = options;
if (propsOnClick) {
@ -343,6 +352,8 @@ export const DataBlockInitializer: FC<DataBlockInitializerProps> = (props) => {
if (item.template) {
const s = await getTemplateSchemaByMode(item);
templateWrap ? insert(templateWrap(s, { item, fromOthersInPopup })) : insert(s);
} else if (item.schemaInsertor) {
await item.schemaInsertor(insert, { item, name, fromOthersInPopup });
} else {
if (onCreateBlockSchema) {
onCreateBlockSchema({ item, fromOthersInPopup });
@ -351,7 +362,17 @@ export const DataBlockInitializer: FC<DataBlockInitializerProps> = (props) => {
setVisible(false);
},
[getTemplateSchemaByMode, insert, setVisible, onCreateBlockSchema, propsOnClick, templateWrap],
[
getTemplateSchemaByMode,
insert,
setVisible,
onCreateBlockSchema,
propsOnClick,
templateWrap,
templateBlockAddedNotifier,
name,
componentType,
],
);
const items =
itemsFromProps ||

View File

@ -15,8 +15,12 @@ import { SchemaInitializerSwitch, useSchemaInitializer } from '../../application
import { useCurrentSchema } from '../utils';
export const InitializerWithSwitch = (props) => {
const { type, schema, item, remove: passInRemove, disabled } = props;
const { exists, remove } = useCurrentSchema(
const { type, schema, item, remove: passInRemove, disabled: propsDisabled } = props;
const {
exists,
remove,
schema: currentSchema,
} = useCurrentSchema(
schema?.[type] || item?.schema?.[type],
type,
item.find,
@ -25,6 +29,8 @@ export const InitializerWithSwitch = (props) => {
);
const { insert } = useSchemaInitializer();
const update = useUpdate();
const isInTemplate = !!currentSchema?.['x-template-uid'];
const disabled = propsDisabled || isInTemplate;
return (
<SchemaInitializerSwitch
checked={exists}

View File

@ -36,6 +36,7 @@ import { isAssocField } from '../filter-provider/utils';
import { useActionContext, useCompile, useDesignable } from '../schema-component';
import { useSchemaTemplateManager } from '../schema-templates';
import { useBlockTemplateContext } from '../schema-templates/BlockTemplateProvider';
import { DeprecatedTemplateTitleElement } from './components/DeprecatedTemplateTitle';
export const itemsMerge = (items1) => {
return items1;
@ -838,21 +839,24 @@ export const useRecordCollectionDataSourceItems = (
.filter((template) => {
return ['FormItem', 'ReadPrettyFormItem'].includes(componentName) || template.resourceName === resourceName;
});
if (!templates.length) {
const extralCollectionMenuItems = Array.from(initializerMenusGenerators.values())
.map((generator) => generator({ collection, componentName }))
.filter(Boolean)
.flat();
if ((!templates.length && !extralCollectionMenuItems.length) || isInTemplateSettingPage()) {
return [];
}
const index = 0;
return [
{
key: `${collectionName || componentName}_table_blank`,
type: 'item',
name: collection.name,
title: t('Blank block'),
item,
},
const deprecatedTemplatesMenuItems = [];
if (templates.length) {
deprecatedTemplatesMenuItems.push(
{
type: 'divider',
},
{
type: 'itemGroup',
title: DeprecatedTemplateTitleElement,
children: [
{
key: `${collectionName || componentName}_table_subMenu_${index}_copy`,
type: 'subMenu',
@ -891,6 +895,20 @@ export const useRecordCollectionDataSourceItems = (
};
}),
},
],
},
);
}
return [
{
key: `${collectionName || componentName}_table_blank`,
type: 'item',
name: collection.name,
title: t('Blank block'),
item,
},
...extralCollectionMenuItems,
...deprecatedTemplatesMenuItems,
];
};
@ -937,6 +955,7 @@ export const useCollectionDataSourceItems = ({
filterCollections: filter,
showAssociationFields,
componentNamePrefix,
name,
});
const association = useAssociationName();
@ -1515,7 +1534,13 @@ const getChildren = ({
return componentName && template.componentName === componentName;
});
if (!templates.length) {
const extralCollectionMenuItems = Array.from(initializerMenusGenerators.values())
.map((generator) => {
return generator({ item, index, componentName, association });
})
.filter(Boolean)
.flat();
if ((!templates.length && !extralCollectionMenuItems.length) || isInTemplateSettingPage()) {
return {
type: 'item',
name: item.name,
@ -1523,22 +1548,16 @@ const getChildren = ({
dataSource,
};
}
return {
key: `${componentName}_table_subMenu_${index}`,
type: 'subMenu',
name: `${item.name}_${index}`,
title,
dataSource,
children: [
{
type: 'item',
name: item.name,
dataSource,
title: t('Blank block'),
},
const deprecatedTemplatesMenuItems = [];
if (templates.length) {
deprecatedTemplatesMenuItems.push(
{
type: 'divider',
},
{
type: 'itemGroup',
title: DeprecatedTemplateTitleElement,
children: [
{
key: `${componentName}_table_subMenu_${index}_copy`,
type: 'subMenu',
@ -1586,6 +1605,25 @@ const getChildren = ({
}),
},
],
},
);
}
return {
key: `${componentName}_table_subMenu_${index}`,
type: 'subMenu',
name: `${item.name}_${index}`,
title,
dataSource,
children: [
{
type: 'item',
name: item.name,
dataSource,
title: t('Blank block'),
},
...extralCollectionMenuItems,
...deprecatedTemplatesMenuItems,
],
};
});
};
@ -1712,11 +1750,13 @@ function useAssociationFields({
filterCollections,
showAssociationFields,
componentNamePrefix,
name,
}: {
componentName: string;
filterCollections: (options: { collection?: Collection; associationField?: CollectionFieldOptions }) => boolean;
componentNamePrefix: string;
showAssociationFields?: boolean;
name: string;
}) {
const fieldSchema = useFieldSchema();
const { getCollectionFields } = useCollectionManager_deprecated();
@ -1764,7 +1804,12 @@ function useAssociationFields({
return template.componentName === componentName;
});
if (!templates.length) {
const keyPrefix = `associationFiled_table_subMenu`;
const extralCollectionMenuItems = Array.from(initializerMenusGenerators.values())
.map((generator) => generator({ collection, index, field, componentName, keyPrefix, name }))
.filter(Boolean)
.flat();
if ((!templates.length && !extralCollectionMenuItems.length) || isInTemplateSettingPage()) {
return {
type: 'item',
name: `${field.collectionName}.${field.name}`,
@ -1774,24 +1819,16 @@ function useAssociationFields({
associationField: field,
};
}
return {
key: `associationFiled_${componentName}_table_subMenu_${index}`,
type: 'subMenu',
name: `${field.target}_${index}`,
title,
dataSource,
children: [
{
type: 'item',
name: `${field.collectionName}.${field.name}`,
collectionName: field.target,
dataSource,
title: t('Blank block'),
associationField: field,
},
const deprecatedTemplatesMenuItems = [];
if (templates.length) {
deprecatedTemplatesMenuItems.push(
{
type: 'divider',
},
{
type: 'itemGroup',
title: DeprecatedTemplateTitleElement,
children: [
{
key: `associationFiled_${componentName}_table_subMenu_${index}_copy`,
type: 'subMenu',
@ -1843,6 +1880,27 @@ function useAssociationFields({
}),
},
],
},
);
}
return {
key: `associationFiled_${componentName}_table_subMenu_${index}`,
type: 'subMenu',
name: `${field.target}_${index}`,
title,
dataSource,
children: [
{
type: 'item',
name: `${field.collectionName}.${field.name}`,
collectionName: field.target,
dataSource,
title: t('Blank block'),
associationField: field,
},
...extralCollectionMenuItems,
...deprecatedTemplatesMenuItems,
],
};
});
}, [
@ -1859,3 +1917,17 @@ function useAssociationFields({
componentNamePrefix,
]);
}
const isInTemplateSettingPage = () => window.location.pathname.includes('/block-templates/');
const initializerMenusGenerators = new Map<
string,
(options: any) => SchemaInitializerItemType | SchemaInitializerItemType[]
>();
export function registerInitializerMenusGenerator(
key: string,
generator: (options: any) => SchemaInitializerItemType | SchemaInitializerItemType[],
) {
initializerMenusGenerators.set(key, generator);
}

View File

@ -140,7 +140,13 @@ export const GeneralSchemaDesigner: FC<GeneralSchemaDesignerProps> = (props: any
return (
<SchemaToolbarProvider {...contextValue}>
<div className={classNames('general-schema-designer', overrideAntdCSS)}>
<div
className={classNames(
'general-schema-designer',
overrideAntdCSS,
fieldSchema['x-template-uid'] ? 'nb-in-template' : '',
)}
>
{title && (
<div className={classNames('general-schema-designer-title', titleCss)}>
<Space size={2}>
@ -241,6 +247,7 @@ const InternalSchemaToolbar: FC<SchemaToolbarProps> = React.memo((props) => {
const dataSourceContext = useDataSource();
const dataSource = dataSources?.length > 1 && dataSourceContext;
const refreshFieldSchema = useRefreshFieldSchema();
const templateTitleLabel = useRef(t('Reference template'));
const refresh = useCallback(() => {
refreshFieldSchema({ refreshParentSchema: true });
@ -249,8 +256,14 @@ const InternalSchemaToolbar: FC<SchemaToolbarProps> = React.memo((props) => {
const titleArr = useMemo(() => {
if (!title) return undefined;
if (typeof title === 'string') return [compile(title)];
if (Array.isArray(title)) return compile(title);
}, [title]);
if (Array.isArray(title)) {
if (title.length === 1 && fieldSchema['x-template-title']) {
templateTitleLabel.current = t('Template');
return compile([title[0], fieldSchema['x-template-title']]);
}
return compile(title);
}
}, [title, fieldSchema]);
const { render: schemaSettingsRender, exists: schemaSettingsExists } = useSchemaSettingsRender(
settings || fieldSchema?.['x-settings'],
@ -367,7 +380,7 @@ const InternalSchemaToolbar: FC<SchemaToolbarProps> = React.memo((props) => {
</span>
{titleArr[1] && (
<span className={'toolbar-title-tag'}>
{`${t('Reference template')}: ${`${titleArr[1]}` || t('Untitled')}`}
{`${templateTitleLabel.current}: ${`${titleArr[1]}` || t('Untitled')}`}
</span>
)}
</Space>

View File

@ -23,7 +23,9 @@ export const useStyles = genStyleHook('nb-schema-toolbar', (token) => {
border: '2px solid var(--colorBorderSettingsHover)',
background: 'var(--colorBgSettingsHover)',
pointerEvents: 'none',
'&.nb-in-template': {
background: 'var(--colorTemplateBgSettingsHover)',
},
'&.hidden': {
// Visually hide the element while keeping it in document flow to prevent reflow/repaint
transform: 'scale(0)',

View File

@ -27,8 +27,8 @@ export const CustomRequestActionACLDecorator = (props) => {
cacheKey: listByCurrentRoleUrl,
},
);
if (!isRoot && !data?.data?.includes(fieldSchema?.['x-uid'])) {
const requestId = fieldSchema?.['x-custom-request-id'] || fieldSchema?.['x-uid'];
if (!isRoot && !data?.data?.includes(requestId)) {
return null;
}

View File

@ -56,6 +56,9 @@ export function CustomRequestSettingsItem() {
onSubmit={async (config) => {
const { ...requestSettings } = config;
fieldSchema['x-response-type'] = requestSettings.responseType;
const isSelfRequest =
!fieldSchema['x-custom-request-id'] || fieldSchema['x-custom-request-id'] === fieldSchema['x-uid'];
await customRequestsResource.updateOrCreate({
values: {
key: fieldSchema['x-uid'],
@ -67,14 +70,19 @@ export function CustomRequestSettingsItem() {
},
filterKeys: ['key'],
});
dn.emit('patch', {
schema: {
const schema = {
'x-response-type': requestSettings.responseType,
'x-uid': fieldSchema['x-uid'],
},
};
if (!isSelfRequest && fieldSchema['x-custom-request-id']) {
schema['x-custom-request-id'] = fieldSchema['x-uid'];
fieldSchema['x-custom-request-id'] = fieldSchema['x-uid'];
}
await dn.emit('patch', {
schema,
});
refresh();
dn.refresh();
refresh();
}}
/>
</>
@ -87,6 +95,7 @@ export function CustomRequestACL() {
const customRequestsResource = useCustomRequestsResource();
const { message } = App.useApp();
const { data, refresh } = useGetCustomRequest();
const { dn } = useDesignable();
const { refresh: refreshRoleCustomKeys } = useRequest<{ data: string[] }>(
{
url: listByCurrentRoleUrl,
@ -107,6 +116,19 @@ export function CustomRequestACL() {
}}
beforeOpen={() => !data && refresh()}
onSubmit={async ({ roles }) => {
const isSelfRequest =
!fieldSchema['x-custom-request-id'] || fieldSchema['x-custom-request-id'] === fieldSchema['x-uid'];
if (!isSelfRequest) {
fieldSchema['x-custom-request-id'] = fieldSchema['x-uid'];
await dn.emit('patch', {
schema: {
'x-uid': fieldSchema['x-uid'],
'x-custom-request-id': fieldSchema['x-uid'],
},
});
}
await customRequestsResource.updateOrCreate({
values: {
key: fieldSchema['x-uid'],
@ -116,6 +138,7 @@ export function CustomRequestACL() {
});
refresh();
refreshRoleCustomKeys();
dn.refresh();
return message.success(t('Saved successfully'));
}}
/>

View File

@ -52,8 +52,9 @@ export const useCustomizeRequestActionProps = () => {
actionField.data ??= {};
actionField.data.loading = true;
try {
const requestId = fieldSchema['x-custom-request-id'] || fieldSchema['x-uid'];
const res = await apiClient.request({
url: `/customRequests:send/${fieldSchema['x-uid']}`,
url: `/customRequests:send/${requestId}`,
method: 'POST',
data: {
currentRecord: {

View File

@ -12,7 +12,8 @@ import { useRequest } from '@nocobase/client';
export const useGetCustomRequest = () => {
const fieldSchema = useFieldSchema();
const url = `customRequests:get/${fieldSchema['x-uid']}`;
const requestId = fieldSchema['x-custom-request-id'] || fieldSchema['x-uid'];
const url = `customRequests:get/${requestId}`;
return useRequest<{ data: { options: any; title: string; roles: any[] } }>(
{
url,

View File

@ -60,6 +60,10 @@ export const actionDesignerCss = css`
left: 0;
right: 0;
pointer-events: none;
'&.nb-in-template': {
background: 'var(--colorTemplateBgSettingsHover)';
}
,
> .general-schema-designer-icons {
position: absolute;
right: 2px;

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1 @@
# @nocobase/plugin-block-template

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -0,0 +1,23 @@
{
"name": "@nocobase/plugin-block-template",
"displayName": "Block: template",
"displayName.zh-CN": "区块:模板",
"description": "Create and manage block templates for reuse on pages.",
"description.zh-CN": "创建和管理区块模板,用于在页面中重复使用。",
"version": "1.6.0-alpha.14",
"license": "AGPL-3.0",
"main": "dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/block-template",
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/block-template",
"dependencies": {},
"peerDependencies": {
"@nocobase/client": "1.x",
"@nocobase/server": "1.x",
"@nocobase/test": "1.x",
"@nocobase/plugin-ui-schema-storage": "1.x"
},
"keywords": [
"Block",
"Template"
]
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

@ -0,0 +1,249 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
// CSS modules
type CSSModuleClasses = { readonly [key: string]: string };
declare module '*.module.css' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.scss' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.sass' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.less' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.styl' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.stylus' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.pcss' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.sss' {
const classes: CSSModuleClasses;
export default classes;
}
// CSS
declare module '*.css' { }
declare module '*.scss' { }
declare module '*.sass' { }
declare module '*.less' { }
declare module '*.styl' { }
declare module '*.stylus' { }
declare module '*.pcss' { }
declare module '*.sss' { }
// Built-in asset types
// see `src/node/constants.ts`
// images
declare module '*.apng' {
const src: string;
export default src;
}
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.jfif' {
const src: string;
export default src;
}
declare module '*.pjpeg' {
const src: string;
export default src;
}
declare module '*.pjp' {
const src: string;
export default src;
}
declare module '*.gif' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
declare module '*.ico' {
const src: string;
export default src;
}
declare module '*.webp' {
const src: string;
export default src;
}
declare module '*.avif' {
const src: string;
export default src;
}
// media
declare module '*.mp4' {
const src: string;
export default src;
}
declare module '*.webm' {
const src: string;
export default src;
}
declare module '*.ogg' {
const src: string;
export default src;
}
declare module '*.mp3' {
const src: string;
export default src;
}
declare module '*.wav' {
const src: string;
export default src;
}
declare module '*.flac' {
const src: string;
export default src;
}
declare module '*.aac' {
const src: string;
export default src;
}
declare module '*.opus' {
const src: string;
export default src;
}
declare module '*.mov' {
const src: string;
export default src;
}
declare module '*.m4a' {
const src: string;
export default src;
}
declare module '*.vtt' {
const src: string;
export default src;
}
// fonts
declare module '*.woff' {
const src: string;
export default src;
}
declare module '*.woff2' {
const src: string;
export default src;
}
declare module '*.eot' {
const src: string;
export default src;
}
declare module '*.ttf' {
const src: string;
export default src;
}
declare module '*.otf' {
const src: string;
export default src;
}
// other
declare module '*.webmanifest' {
const src: string;
export default src;
}
declare module '*.pdf' {
const src: string;
export default src;
}
declare module '*.txt' {
const src: string;
export default src;
}
// wasm?init
declare module '*.wasm?init' {
const initWasm: (options?: WebAssembly.Imports) => Promise<WebAssembly.Instance>;
export default initWasm;
}
// web worker
declare module '*?worker' {
const workerConstructor: {
new(options?: { name?: string }): Worker;
};
export default workerConstructor;
}
declare module '*?worker&inline' {
const workerConstructor: {
new(options?: { name?: string }): Worker;
};
export default workerConstructor;
}
declare module '*?worker&url' {
const src: string;
export default src;
}
declare module '*?sharedworker' {
const sharedWorkerConstructor: {
new(options?: { name?: string }): SharedWorker;
};
export default sharedWorkerConstructor;
}
declare module '*?sharedworker&inline' {
const sharedWorkerConstructor: {
new(options?: { name?: string }): SharedWorker;
};
export default sharedWorkerConstructor;
}
declare module '*?sharedworker&url' {
const src: string;
export default src;
}
declare module '*?raw' {
const src: string;
export default src;
}
declare module '*?url' {
const src: string;
export default src;
}
declare module '*?inline' {
const src: string;
export default src;
}

View File

@ -0,0 +1,60 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { tStr } from '../locale';
export const blockTemplatesCollection = {
name: 'blockTemplates',
filterTargetKey: 'key',
fields: [
{
type: 'string',
name: 'title',
interface: 'input',
uiSchema: {
type: 'string',
title: "{{t('Title')}}",
required: true,
'x-component': 'Input',
},
},
{
type: 'string',
name: 'key',
interface: 'input',
uiSchema: {
type: 'string',
title: "{{t('Name')}}",
required: true,
'x-component': 'Input',
},
},
{
type: 'string',
name: 'type',
interface: 'radioGroup',
uiSchema: {
type: 'string',
title: tStr('Type'),
'x-component': 'Radio.Group',
enum: '{{ typeOptions }}',
},
},
{
type: 'string',
name: 'description',
interface: 'textarea',
uiSchema: {
type: 'string',
title: "{{t('Description')}}",
'x-component': 'Input.TextArea',
},
},
],
};

View File

@ -0,0 +1,38 @@
import { ActionContextProvider, SchemaComponent, FormBlockProvider } from '@nocobase/client';
import React, { createContext, useState } from 'react';
import { useT } from '../locale';
import { Button } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { createActionSchema } from '../schemas/createActionSchema';
import { createForm } from '@formily/core';
import { uid } from '@nocobase/utils/client';
export const NewTemplateFormContext = createContext(null);
export const AddNewTemplate = () => {
const [visible, setVisible] = useState(false);
const [form, setForm] = useState(null);
const t = useT();
const handleClick = () => {
setForm(
createForm({
initialValues: {
key: `t_${uid()}`,
type: 'Desktop',
},
}),
);
setVisible(true);
};
return (
<ActionContextProvider value={{ visible, setVisible }}>
<Button icon={<PlusOutlined />} type={'primary'} onClick={handleClick}>
{t('Add new')}
</Button>
<NewTemplateFormContext.Provider value={form}>
<SchemaComponent schema={createActionSchema} />
</NewTemplateFormContext.Provider>
</ActionContextProvider>
);
};

View File

@ -0,0 +1,15 @@
import { createContext, useContext } from 'react';
interface BlockTemplateInfo {
uid?: string;
key?: string;
title?: string;
description?: string;
configured?: boolean;
}
export const BlockTemplateInfoContext = createContext<BlockTemplateInfo>({});
export const useBlockTemplateInfo = () => {
return useContext(BlockTemplateInfoContext);
};

View File

@ -0,0 +1,54 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import {
ExtendCollectionsProvider,
SchemaComponent,
SchemaComponentContext,
useSchemaComponentContext,
} from '@nocobase/client';
import React from 'react';
import { blockTemplatesCollection } from '../collections/blockTemplates';
import { blockTemplatesSchema } from '../schemas/blockTemplates';
import {
useDuplicateAction,
useCreateActionProps,
useEditActionProps,
useEditFormProps,
useDeleteAction,
useBulkDestroyAction,
} from '../hooks';
import { useT } from '../locale';
export const BlockTemplateList = () => {
const scCtx = useSchemaComponentContext();
const t = useT();
const typeOptions = [
{ label: t('Desktop'), value: 'Desktop' },
{ label: t('Mobile'), value: 'Mobile' },
];
return (
<ExtendCollectionsProvider collections={[blockTemplatesCollection]}>
<SchemaComponentContext.Provider value={{ ...scCtx, designable: false }}>
<SchemaComponent
schema={blockTemplatesSchema}
scope={{
useCreateActionProps,
useEditActionProps,
useEditFormProps,
useDeleteAction,
useDuplicateAction,
useBulkDestroyAction,
typeOptions,
}}
/>
</SchemaComponentContext.Provider>
</ExtendCollectionsProvider>
);
};

View File

@ -0,0 +1,218 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import {
useRequest,
useAPIClient,
usePlugin,
registerInitializerMenusGenerator,
useResource,
ISchema,
SchemaInitializerItemType,
} from '@nocobase/client';
import React, { createContext, useContext, useEffect } from 'react';
import PluginBlockTemplateClient from '..';
import PluginMobileClient from '@nocobase/plugin-mobile/client';
import { useT } from '../locale';
import { findBlockRootSchema } from '../utils/schema';
import { convertTemplateToBlock, correctIdReferences } from '../initializers/TemplateBlockInitializer';
import { useMemoizedFn } from 'ahooks';
import { useLocation } from 'react-router-dom';
interface BlockTemplateContextProps {
loading: boolean;
templates: any[];
handleTemplateClick: (item: any, options?: any, insert?: any) => Promise<void>;
}
const BlockTemplateMenusContext = createContext<BlockTemplateContextProps>({
loading: false,
templates: [],
handleTemplateClick: async () => {},
});
export const useBlockTemplateMenus = () => {
return useContext(BlockTemplateMenusContext);
};
export const BlockTemplateMenusProvider = ({ children }) => {
const api = useAPIClient();
const plugin = usePlugin(PluginBlockTemplateClient);
const mobilePlugin = usePlugin(PluginMobileClient);
const blockTemplatesResource = useResource('blockTemplates');
const t = useT();
const isMobile = window.location.pathname.startsWith(mobilePlugin.mobileBasename);
const location = useLocation();
const previousPathRef = React.useRef(location.pathname);
const { data, loading, refresh } = useRequest<{
data: {
key: string;
title: string;
componentType?: string;
menuName?: string;
collection?: string;
dataSource?: string;
}[];
}>(
{
url: 'blockTemplates:list',
method: 'get',
params: {
filter: {
configured: true,
type: isMobile ? 'Mobile' : { $ne: 'Mobile' },
},
paginate: false,
},
},
{
cacheKey: 'blockTemplates',
},
);
useEffect(() => {
const isLeavingTemplatesPage =
previousPathRef.current.includes('/settings/block-templates') &&
!location.pathname.includes('/settings/block-templates');
if (isLeavingTemplatesPage) {
refresh();
}
previousPathRef.current = location.pathname;
}, [location.pathname, refresh]);
const handleTemplateClick = useMemoizedFn(async ({ item }, options?: any, insert?: any) => {
const { uid } = item;
const { data } = await api.request({
url: `uiSchemas:getProperties/${uid}`,
});
const template = data?.data;
const schemas = convertTemplateToBlock(template, item.key, options);
plugin.setTemplateCache(findBlockRootSchema(template['properties']?.['blocks']));
correctIdReferences(schemas);
for (const schema of schemas) {
insert?.(schema);
}
// server hook only support root node, so we do the link from client
const links = [];
const fillLink = (schema: ISchema) => {
if (schema['x-template-root-uid']) {
links.push({
templateKey: item.key,
templateBlockUid: schema['x-template-root-uid'],
blockUid: schema['x-uid'],
});
}
if (schema.properties) {
for (const key in schema.properties) {
fillLink(schema.properties[key]);
}
}
};
for (const schema of schemas) {
fillLink(schema);
}
blockTemplatesResource.link({
values: links,
});
});
useEffect(() => {
data?.data?.forEach((item) => {
plugin.templateInfos.set(item.key, item);
});
}, [data?.data, plugin.templateInfos]);
useEffect(() => {
const generator = ({ collection, association, item, index, field, componentName, dataSource, keyPrefix, name }) => {
let collectionName = collection?.name || item?.options?.name;
const dataSourceName = dataSource || item?.options?.dataSource || collection?.dataSource;
const isInWorkflowPage = window.location.pathname.includes('/admin/workflow');
if (componentName?.startsWith('mobile-')) {
componentName = componentName.replace('mobile-', '');
}
if (plugin.isInBlockTemplateConfigPage() || isInWorkflowPage) {
// hide menu in template config page
return null;
}
if (field) {
// association field
collectionName = field?.target;
}
const isDetails = name === 'details' || componentName === 'ReadPrettyFormItem';
const children = data?.data
?.filter(
(d) =>
(d.componentType === componentName ||
name === d['menuName'] ||
(isDetails && d['menuName'] === 'details')) &&
d.collection === collectionName &&
d.dataSource === dataSourceName,
)
.map((m) => {
return {
type: 'item',
name: m.key,
item: m,
title: m.title,
schemaInsertor: (insert, { item, fromOthersInPopup, name }) => {
const options = { dataSourceName };
if (association && (name === 'editForm' || name === 'currentRecord')) {
options['association'] = association;
}
if (field) {
options['association'] = `${collection?.name}.${field.name}`;
options['associationType'] = field.type;
} else {
options['collectionName'] = collectionName;
}
options['currentRecord'] = name === 'currentRecord' && isDetails;
if (name === 'editForm') {
options['currentRecord'] = true;
}
return handleTemplateClick(item, options, insert);
},
} as SchemaInitializerItemType;
});
if (!children?.length) {
return null;
}
return [
{
type: 'divider',
},
{
type: 'itemGroup',
title: t('Block template'),
children,
},
] as SchemaInitializerItemType[];
};
registerInitializerMenusGenerator('block_template', generator);
}, [data?.data, plugin.isInBlockTemplateConfigPage, handleTemplateClick, t, plugin]);
return (
<BlockTemplateMenusContext.Provider
value={{
loading,
templates: data?.data || [],
handleTemplateClick,
}}
>
{children}
</BlockTemplateMenusContext.Provider>
);
};
BlockTemplateMenusProvider.displayName = 'BlockTemplateMenusProvider';

View File

@ -0,0 +1,49 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useEffect } from 'react';
import { MobilePage } from '@nocobase/plugin-mobile/client';
import { useParams } from 'react-router-dom';
import { useRequest } from '@nocobase/client';
import { Spin } from 'antd';
import { BlockTemplateInfoContext } from './BlockTemplateInfoContext';
export const BlockTemplateMobilePage = () => {
const { key } = useParams<{ key: string }>();
const { data, loading } = useRequest<any>({
url: `blockTemplates:get/${key}`,
});
useEffect(() => {
// hide tab bar
const tabBar = document.querySelector('.ant-nb-mobile-tab-bar');
const tabBarDisplay = (tabBar as HTMLElement)?.style?.display;
if (tabBar) {
(tabBar as HTMLElement).style.display = 'none';
}
// show tab bar
return () => {
const tabBar = document.querySelector('.ant-nb-mobile-tab-bar');
if (tabBar) {
(tabBar as HTMLElement).style.display = tabBarDisplay;
}
};
}, []);
if (loading) {
return <Spin />;
}
return (
<BlockTemplateInfoContext.Provider value={data?.data}>
<MobilePage />
</BlockTemplateInfoContext.Provider>
);
};

View File

@ -0,0 +1,63 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useRequest, RemoteSchemaComponent } from '@nocobase/client';
import React from 'react';
import { useT } from '../locale';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { Breadcrumb, Spin, theme } from 'antd';
import { BlockTemplateInfoContext } from './BlockTemplateInfoContext';
export const BlockTemplatePage = () => {
const params = useParams();
const { token } = theme.useToken();
const t = useT();
const { data, loading } = useRequest<any>({
url: `blockTemplates:get/${params.key}`,
});
const { title } = data?.data || {};
if (loading) {
return <Spin />;
}
const schemaUid = data?.data?.uid;
return (
<div>
<div
style={{
margin: -token.margin,
padding: token.paddingSM,
background: token.colorBgContainer,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Breadcrumb
items={[
{
title: <Link to={`/admin/settings/block-templates`}>{t('Block template')}</Link>,
},
{
title: title,
},
]}
/>
</div>
<div style={{ marginTop: token.marginXL, position: 'relative', zIndex: 0 /** create a new z-index context */ }}>
<BlockTemplateInfoContext.Provider value={data?.data}>
<RemoteSchemaComponent uid={schemaUid} />
</BlockTemplateInfoContext.Provider>
</div>
</div>
);
};

View File

@ -0,0 +1,24 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useCollectionRecordData, useFilterByTk } from '@nocobase/client';
import React from 'react';
import { Link } from 'react-router-dom';
import { useT } from '../locale';
export const ConfigureLink = () => {
const value = useFilterByTk();
const recordData = useCollectionRecordData();
const t = useT();
let to = `/admin/settings/block-templates/${value}`;
if (recordData.type === 'Mobile') {
to = `/m/block-templates/${recordData.key}/${recordData.uid}`;
}
return <Link to={to}>{t('Configure')}</Link>;
};

View File

@ -0,0 +1,31 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { SchemaSettingsDivider, SchemaSettingsItem } from '@nocobase/client';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { Tooltip, Space } from 'antd';
import React from 'react';
import { useT } from '../locale';
export const DisabledDeleteItem = () => {
const t = useT();
return (
<>
<SchemaSettingsDivider />
<SchemaSettingsItem disabled={true} title={t('Delete')}>
<Space>
{t('Delete')}
<Tooltip title={t('This is part of a template, deletion is not allowed')}>
<QuestionCircleOutlined />
</Tooltip>
</Space>
</SchemaSettingsItem>
</>
);
};

View File

@ -0,0 +1,218 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import {
SchemaSettingsItem,
useAPIClient,
useDesignable,
useFormBlockProps,
usePlugin,
SchemaSettingsDivider,
} from '@nocobase/client';
import { useFieldSchema, useForm, useField } from '@formily/react';
import { App } from 'antd';
import React from 'react';
import _ from 'lodash';
import { blockKeepProps, convertTplBlock, formSchemaPatch } from '../initializers/TemplateBlockInitializer';
import { Schema } from '@formily/json-schema';
import { useT } from '../locale';
import PluginBlockTemplateClient from '..';
import { addToolbarClass, syncExtraTemplateInfo } from '../utils/template';
import { uid } from '@nocobase/utils/client';
const findInsertPosition = (parentSchema, uid) => {
const postion = {
insertPosition: 'beforeBegin',
insertTarget: null,
};
const properties = Object.values(parentSchema.properties || {}).sort((a, b) => {
return (a as any)['x-index'] - (b as any)['x-index'];
});
for (let i = 0; i < properties.length; i++) {
const property = properties[i];
if ((property as any)['x-uid'] === uid) {
postion.insertPosition = 'beforeBegin';
if (i === properties.length - 1) {
postion.insertPosition = 'beforeEnd';
postion.insertTarget = parentSchema['x-uid'];
} else {
postion.insertPosition = 'beforeBegin';
postion.insertTarget = (properties[i + 1] as any)['x-uid'];
}
}
}
return postion;
};
const findParentRootTemplateSchema = (fieldSchema) => {
if (!fieldSchema) {
return null;
}
if (fieldSchema['x-template-root-uid']) {
return fieldSchema;
} else {
return findParentRootTemplateSchema(fieldSchema.parent);
}
};
export const RevertSetting = () => {
const { refresh, remove } = useDesignable();
const plugin = usePlugin(PluginBlockTemplateClient);
const t = useT();
const api = useAPIClient();
const form = useForm();
const field = useField();
// const { runAsync } = useDataBlockRequest();
const { form: blockForm } = useFormBlockProps();
const fieldSchema = useFieldSchema();
const { modal, message } = App.useApp();
return (
<>
<SchemaSettingsDivider />
<SchemaSettingsItem
title={t('Revert to template')}
onClick={() => {
modal.confirm({
title: t('Revert to template'),
content: t('Are you sure you want to revert all changes from the template?'),
...confirm,
async onOk() {
const templateSchemaId = _.get(fieldSchema, 'x-template-uid');
const res = await api.request({
url: `/uiSchemas:getJsonSchema/${templateSchemaId}`,
});
const templateSchema = res.data?.data;
if (!templateSchema?.['x-uid']) {
// this means the template has already been deleted
remove(null, {
removeParentsIfNoChildren: true,
breakRemoveOn: {
'x-component': 'Grid',
},
});
refresh({ refreshParentSchema: true });
form.reset();
form.clearFormGraph();
blockForm?.clearFormGraph();
message.success(t('Reset successfully'), 0.2);
return;
}
const rootSchema = findParentRootTemplateSchema(fieldSchema);
const isRoot = rootSchema === fieldSchema;
if (isRoot) {
plugin.setTemplateCache(templateSchema);
} else {
// patch the edit form button schema, keep same as the form
if (fieldSchema['x-settings']?.includes('updateSubmit')) {
templateSchema['x-settings'] = 'actionSettings:updateSubmit';
templateSchema['x-use-component-props'] = 'useUpdateActionProps';
}
}
// patch filter block
// remove this when multiple blocks template supported
if (fieldSchema['x-filter-targets']) {
templateSchema['x-filter-targets'] = fieldSchema['x-filter-targets'];
}
const newSchema = convertTplBlock(
templateSchema,
false,
isRoot,
rootSchema?.['x-uid'],
rootSchema?.['x-block-template-key'],
);
newSchema['x-index'] = fieldSchema['x-index'];
for (const p of blockKeepProps) {
if (_.hasIn(fieldSchema, p)) {
_.set(newSchema, p, _.get(fieldSchema, p));
}
}
if (
fieldSchema['x-decorator'] === 'FormBlockProvider' &&
fieldSchema['x-use-decorator-props'] === 'useEditFormBlockDecoratorProps'
) {
formSchemaPatch(newSchema, {
collectionName: fieldSchema['x-decorator-props']['collection'],
dataSourceName: fieldSchema['x-decorator-props']['dataSource'],
association: fieldSchema['x-decorator-props']['association'],
currentRecord: true,
});
}
// remove old schema
const position = findInsertPosition(fieldSchema.parent, fieldSchema['x-uid']);
await api.request({
url: `/uiSchemas:remove/${fieldSchema['x-uid']}`,
});
// insertAdjacent
const schema = new Schema(newSchema);
schema.name = fieldSchema.name;
await api.request({
url: `/uiSchemas:insertAdjacent/${position.insertTarget}?position=${position.insertPosition}`,
method: 'post',
data: {
schema,
},
});
// this is a hack to make the schema component refresh to the new schema
fieldSchema.toJSON = () => {
let ret;
if (schema['x-template-root-uid'] || fieldSchema.parent?.['x-template-root-uid']) {
ret = schema.toJSON();
} else {
const mergedSchema = _.merge(templateSchema, schema.toJSON());
ret = mergedSchema;
}
addToolbarClass(ret);
syncExtraTemplateInfo(ret, plugin.templateInfos, plugin.savedSchemaUids);
return ret;
};
refresh({ refreshParentSchema: true });
// set componentProps, otherwise some components props will not be refreshed
field['componentProps'] = {
...templateSchema['x-component-props'],
key: uid(),
};
if (field.parent?.['componentProps']) {
field.parent['componentProps'] = {
...field.parent['componentProps'],
key: uid(),
};
}
// set decoratorProps, otherwise title will not be refreshed
field['decoratorProps'] = {
...field['decoratorProps'],
...templateSchema['x-decorator-props'],
key: uid(),
};
if (field.parent?.['decoratorProps']) {
field.parent['decoratorProps'] = {
...field.parent['decoratorProps'],
key: uid(),
};
}
form.reset();
blockForm?.reset();
form.clearFormGraph('*', false);
blockForm?.clearFormGraph('*', false);
message.success(t('Reset successfully'), 0.2);
},
});
}}
>
{t('Revert to template')}
</SchemaSettingsItem>
</>
);
};

View File

@ -0,0 +1,163 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useCallback, useContext, useEffect, useRef } from 'react';
import { observer, useFieldSchema, useField, useFormEffects, useForm } from '@formily/react';
import { onFieldReact } from '@formily/core';
import { useUpdate } from 'ahooks';
import { SchemaComponentOnChangeContext, useAPIClient } from '@nocobase/client';
import { useBlockTemplateInfo } from './BlockTemplateInfoContext';
import { Schema } from '@nocobase/utils';
import { findBlockRootSchema } from '../utils/schema';
export const TemplateGridDecorator = observer((props: any) => {
const fieldSchema = useFieldSchema();
const field = useField();
const update = useUpdate();
const preInitializerDisplay = useRef('block');
const preBlockSchemaUid = useRef(findBlockRootSchema(fieldSchema)?.['x-uid']);
const api = useAPIClient();
const template = useBlockTemplateInfo();
const form = useForm();
const { onChange: onChangeFromContext } = useContext(SchemaComponentOnChangeContext);
useEffect(() => {
const dispose = form.subscribe(({ type, payload }) => {
if (type === 'blockAdded') {
api.resource('blockTemplates').update({
filter: {
key: template.key,
},
values: {
collection: payload.collection,
dataSource: payload.dataSource,
componentType: payload.componentType,
menuName: payload.menuName,
},
});
}
});
return () => {
form.unsubscribe(dispose);
};
}, [form]);
fieldSchema['x-initializer-props'] = {
style: {
display: preInitializerDisplay.current,
},
};
// used to hide add blocks button in toolbar, for the moment, only need to support one layer
const updateInitializer = useCallback(
(display: string) => {
let updatedLayer = 0;
const updater = (s: Schema) => {
if (s['x-toolbar'] || s['x-settings']) {
s['x-toolbar-props'] = {
...s['x-toolbar-props'],
initializer: display === 'block' ? undefined : false,
};
updatedLayer++;
}
if (updatedLayer > 0) {
return;
}
if (s.properties) {
Object.keys(s.properties).forEach((key) => {
updater(s.properties[key]);
});
}
};
updater(fieldSchema);
},
[fieldSchema],
);
const updateInitializerDisplay = useCallback(
(configured: boolean) => {
const initializerDisplay = !configured ? 'block' : 'none';
if (initializerDisplay !== preInitializerDisplay.current) {
preInitializerDisplay.current = initializerDisplay;
field.decoratorProps.style = {
display: initializerDisplay,
};
updateInitializer(initializerDisplay);
field.form.clearFormGraph(); // refresh form graph
update();
}
},
[update, field, updateInitializer],
);
const updateTemplateConfigured = useCallback(
(configured: boolean) => {
if (template && template.configured !== configured) {
const values = { configured };
if (!configured) {
values['dataSource'] = null;
values['collection'] = null;
values['componentType'] = null;
values['menuName'] = null;
}
api.resource('blockTemplates').update({
filter: {
key: template.key,
},
values,
});
template.configured = configured;
if (!configured && preBlockSchemaUid.current) {
// this means the block has already been deleted
// let's call the remove API, otherwise the block will still be cached in server!!!
// maybe better way is to update cache in server clearXUidPathCache?
api.request({
url: `/uiSchemas:remove/${preBlockSchemaUid.current}`,
});
} else {
preBlockSchemaUid.current = findBlockRootSchema(fieldSchema)?.['x-uid'];
}
}
},
[api, template, fieldSchema],
);
const syncTemplateInfo = useCallback(() => {
const configured = Object.keys(fieldSchema['properties'] || {}).length > 0;
updateInitializerDisplay(configured);
updateTemplateConfigured(configured);
}, [fieldSchema, updateInitializerDisplay, updateTemplateConfigured]);
const onChange = useCallback(
// schema will not be passed here, is it a core bug?
() => {
syncTemplateInfo();
onChangeFromContext?.(fieldSchema);
},
[fieldSchema, syncTemplateInfo, onChangeFromContext],
);
useFormEffects(() => {
onFieldReact('blocks.*.*', () => {
syncTemplateInfo();
});
});
useEffect(() => {
syncTemplateInfo();
}, [syncTemplateInfo]);
return (
<SchemaComponentOnChangeContext.Provider value={{ onChange }}>
{props.children}
</SchemaComponentOnChangeContext.Provider>
);
});

View File

@ -0,0 +1,18 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export * from './AddNewTemplate';
export * from './BlockTemplateList';
export * from './BlockTemplatePage';
export * from './BlockTemplateMobilePage';
export * from './ConfigureLink';
export * from './RevertSetting';
export * from './BlockTemplateInfoContext';
export * from './TemplateGridDecorator';
export * from './DisabledDeleteItem';

View File

@ -0,0 +1,12 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export const BlockName = 'Template';
export const BlockNameLowercase = BlockName.toLowerCase();
export const NAMESPACE = 'block-template';

View File

@ -0,0 +1,15 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export * from './useDuplicateAction';
export * from './useCreateActionProps';
export * from './useEditActionProps';
export * from './useDeleteAction';
export * from './useEditFormProps';
export * from './useBulkDestroyAction';

View File

@ -0,0 +1,66 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useAPIClient, usePlugin, useDataBlockRequest, useActionContext } from '@nocobase/client';
import { useField } from '@formily/react';
import { App } from 'antd';
import { useT } from '../locale';
import { useForm } from '@formily/react';
import { useTableBlockProps } from '@nocobase/client';
export const useBulkDestroyAction = () => {
const apiClient = useAPIClient();
const { setVisible } = useActionContext();
const { data, refresh, run } = useDataBlockRequest();
const field = useField();
const t = useT();
const form = useForm();
const { message } = App.useApp();
const { onRowSelectionChange } = useTableBlockProps();
return {
async run() {
const selectedRowKeys = field.data?.selectedRowKeys;
if (!selectedRowKeys?.length) {
message.error(t('Please select the records you want to delete'));
return;
}
await apiClient.request({
method: 'POST',
url: '/blockTemplates:destroy',
params: {
filterByTk: selectedRowKeys,
removeSchema: !form.values.keepBlocks,
},
});
// Reset selection
onRowSelectionChange([], []);
form.reset();
// Calculate pagination after deletion
const currentPage = data?.['meta']?.page || 1;
const pageSize = data?.['meta']?.pageSize || 20;
const totalCount = data?.['meta']?.count || 0;
const remainingItems = totalCount - selectedRowKeys.length;
const lastPage = Math.max(Math.ceil(remainingItems / pageSize), 1);
// Update data with appropriate page
if (currentPage > lastPage) {
run({ page: lastPage, pageSize });
} else {
refresh();
}
setVisible(false);
message.success(t('Deleted successfully'));
},
};
};

View File

@ -0,0 +1,89 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useForm } from '@formily/react';
import { useActionContext, useAPIClient, useDataBlockRequest, useDataBlockResource } from '@nocobase/client';
import { uid } from '@nocobase/utils/client';
import { App as AntdApp } from 'antd';
import { useT } from '../locale';
export const useCreateActionProps = () => {
const { setVisible } = useActionContext();
const { message } = AntdApp.useApp();
const form = useForm();
const resource = useDataBlockResource();
const api = useAPIClient();
const { refresh } = useDataBlockRequest();
const t = useT();
return {
type: 'primary',
async onClick() {
await form.submit();
const values = form.values;
const key = values.key;
const schemaUid = uid();
const isMobile = values['type'] === 'Mobile';
const schema = {
type: 'void',
name: key,
'x-uid': `template-${schemaUid}`,
_isJSONSchemaObject: true,
properties: {
template: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'div',
...(isMobile
? {
'x-component-props': {
style: {
padding: '10px',
maxHeight: '100%',
overflow: 'scroll',
},
},
}
: {}),
properties: {
blocks: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-decorator': 'TemplateGridDecorator',
'x-component': 'Grid',
'x-initializer': isMobile ? 'mobile:addBlock' : 'page:addBlock',
'x-uid': uid(),
'x-async': false,
'x-index': 1,
properties: {},
},
},
'x-uid': schemaUid,
'x-async': true,
'x-index': 1,
},
},
};
await resource.create({
values: {
...values,
uid: schemaUid,
},
});
await api.resource('uiSchemas').insert({ values: schema });
form.reset();
refresh();
message.success(t('Saved successfully'));
setVisible(false);
form.values.key = `t_${uid()}`;
},
};
};

View File

@ -0,0 +1,59 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import {
useCollection,
useCollectionRecordData,
useDataBlockResource,
usePlugin,
useDataBlockRequest,
} from '@nocobase/client';
import { App as AntdApp } from 'antd';
import { useForm } from '@formily/react';
import { useT } from '../locale';
export function useDeleteAction() {
const { message } = AntdApp.useApp();
const record = useCollectionRecordData();
const resource = useDataBlockResource();
const { data, refresh, run } = useDataBlockRequest();
const collection = useCollection();
const t = useT();
const form = useForm();
return {
async run() {
if (!collection) {
throw new Error('collection does not exist');
}
await form.submit();
const keepBlocks = form.values.keepBlocks;
await resource.destroy({
filterByTk: record[collection.filterTargetKey],
removeSchema: !keepBlocks,
});
// Calculate pagination after deletion
const currentPage = data?.['meta']?.page || 1;
const pageSize = data?.['meta']?.pageSize || 20;
const totalCount = data?.['meta']?.count || 0;
const remainingItems = totalCount - 1;
const lastPage = Math.max(Math.ceil(remainingItems / pageSize), 1);
// Update data with appropriate page
if (currentPage > lastPage) {
run({ page: lastPage, pageSize });
} else {
refresh();
}
message.success(t('Deleted successfully'));
},
};
}

View File

@ -0,0 +1,95 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import {
useCollection,
useCollectionRecordData,
useBlockRequestContext,
useDataBlockResource,
useAPIClient,
useActionContext,
} from '@nocobase/client';
import { useForm } from '@formily/react';
import { App as AntdApp } from 'antd';
import _ from 'lodash';
import { useState } from 'react';
import { uid } from '@nocobase/utils/client';
const duplicateSchema = (schema) => {
if (!schema) {
return null;
}
if (schema['x-component'] === 'CustomRequestAction') {
schema['x-custom-request-id'] = schema['x-custom-request-id'] || schema['x-uid'];
}
schema['x-uid'] = uid();
if (schema.properties) {
for (const key of Object.keys(schema.properties)) {
duplicateSchema(schema.properties[key]);
}
}
return schema;
};
export function useDuplicateAction() {
const { message } = AntdApp.useApp();
const api = useAPIClient();
const record = useCollectionRecordData();
const resource = useDataBlockResource();
const { service } = useBlockRequestContext();
const collection = useCollection();
const form = useForm();
const { setVisible } = useActionContext();
const [loading, setLoading] = useState(false);
return {
async run() {
if (loading) {
return;
}
await form.submit();
setLoading(true);
const values = form.values;
if (!collection) {
throw new Error('collection does not exist');
}
const schemaUid = record.uid;
const { data: schema } = await api.request({
url: `uiSchemas:getJsonSchema/${schemaUid}`,
});
const duplicatedSchema = duplicateSchema(_.cloneDeep(schema?.data));
const newSchemaUid = duplicatedSchema['x-uid'];
const newKey = `t_${uid()}`;
await api.resource('uiSchemas').insert({
values: {
type: 'void',
name: newKey,
'x-uid': `template-${newSchemaUid}`,
_isJSONSchemaObject: true,
properties: {
template: duplicatedSchema,
},
},
});
await resource.create({
values: {
...record,
title: `${values.title}`,
key: newKey,
uid: newSchemaUid,
},
});
await service.refresh();
setVisible(false);
setLoading(false);
await form.reset();
message.success('Duplicated!');
},
};
}

View File

@ -0,0 +1,47 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useForm } from '@formily/react';
import {
useActionContext,
useCollection,
useDataBlockRequest,
useDataBlockResource,
usePlugin,
} from '@nocobase/client';
import { App as AntdApp } from 'antd';
import { useT } from '../locale';
import { PluginBlockTemplateClient } from '..';
export const useEditActionProps = () => {
const { setVisible } = useActionContext();
const { message } = AntdApp.useApp();
const form = useForm();
const resource = useDataBlockResource();
const collection = useCollection();
const plugin = usePlugin(PluginBlockTemplateClient);
const t = useT();
const { refresh } = useDataBlockRequest();
return {
type: 'primary',
async onClick() {
await form.submit();
const values = form.values;
await resource.update({
values,
filterByTk: values[collection.filterTargetKey],
});
refresh();
message.success(t('Saved successfully'));
plugin.templateInfos.set(values[collection.filterTargetKey], values);
setVisible(false);
},
};
};

View File

@ -0,0 +1,27 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { createForm } from '@formily/core';
import { useCollectionRecordData } from '@nocobase/client';
import { useMemo } from 'react';
export const useEditFormProps = () => {
const recordData = useCollectionRecordData();
const form = useMemo(
() =>
createForm({
initialValues: recordData,
}),
[recordData],
);
return {
form,
};
};

View File

@ -0,0 +1,23 @@
import { useFieldSchema } from '@formily/react';
import _ from 'lodash';
/**
* A custom hook that determines if the current field schema is part of a template block
*
* @param includeRoot - When true, returns true for both root and child template elements.
* When false, only returns true for child template elements.
* Defaults to true.
* @returns boolean - Returns true if the field is part of a template block, considering the includeRoot parameter
*
* @example
* // Check if component is in any template block (including root)
* const isInTemplate = useIsInTemplate();
*
* // Check if component is in template block but not the root
* const isInTemplateNotRoot = useIsInTemplate(false);
*/
export const useIsInTemplate = (includeRoot = true) => {
const fieldSchema = useFieldSchema();
const templateBlock = _.get(fieldSchema, 'x-template-uid');
return !!templateBlock && (includeRoot || !_.get(fieldSchema, 'x-template-root-uid'));
};

View File

@ -0,0 +1,173 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Plugin } from '@nocobase/client';
import { templateBlockInitializerItem } from './initializers';
import { NAMESPACE } from './constants';
import { BlockTemplateList, BlockTemplatePage } from './components';
import { ISchema, Schema } from '@formily/json-schema';
import * as _ from 'lodash';
import { revertSettingItem } from './settings/revertSetting';
import { getFullSchema } from './utils/template';
import { registerTemplateBlockInterceptors } from './utils/interceptors';
import { TemplateGridDecorator } from './components/TemplateGridDecorator';
import PluginMobileClient from '@nocobase/plugin-mobile/client';
import { BlockTemplateMobilePage } from './components/BlockTemplateMobilePage';
import {
hideBlocksFromTemplate,
hideConnectDataBlocksFromTemplate,
hideConvertToBlockSettingItem,
hideDeleteSettingItem,
} from './utils/setting';
import { BlockTemplateMenusProvider } from './components/BlockTemplateMenusProvider';
import { disabledDeleteSettingItem } from './settings/disabledDeleteSetting';
export class PluginBlockTemplateClient extends Plugin {
templateInfos = new Map();
templateschemacache = {};
pageBlocks = {};
savedSchemaUids = new Set<string>();
injectInitializers = [
'page:addBlock',
'popup:common:addBlock',
'popup:addNew:addBlock',
'mobile:addBlock',
'mobile:popup:common:addBlock',
];
async afterAdd() {
// await this.app.pm.add()
}
async beforeLoad() {}
async load() {
Schema.registerPatches((s: ISchema) => {
if (s['x-template-infos']) {
for (const key in s['x-template-infos']) {
const templateInfo = s['x-template-infos'][key];
this.templateInfos.set(key, templateInfo);
}
delete s['x-template-infos'];
}
if (s['x-template-schemas']) {
for (const key in s['x-template-schemas']) {
const templateSchema = s['x-template-schemas'][key];
this.templateschemacache[key] = templateSchema;
}
delete s['x-template-schemas'];
}
// add version check here is to avoid modifying the template schema before insertAdjacent api
// otherwise, the template root schema will be a full copy of the original schema
if (s['x-template-root-uid'] && (s['version'] || s['x-template-root-ref'])) {
const sc = getFullSchema(s, this.templateschemacache, this.templateInfos, this.savedSchemaUids);
this.pageBlocks[sc['x-uid']] = sc;
return sc;
}
return s;
});
// Register axios interceptors for template block operations
registerTemplateBlockInterceptors(this.app.apiClient, this.pageBlocks, this.savedSchemaUids);
this.app.addComponents({ TemplateGridDecorator });
this.app.addProviders([BlockTemplateMenusProvider]);
for (const initializer of this.injectInitializers) {
this.app.schemaInitializerManager.addItem(initializer, 'otherBlocks.templates', templateBlockInitializerItem);
}
this.#afterAllPluginsLoaded();
this.app.pluginSettingsManager.add('block-templates', {
title: `{{t("Block templates", { ns: "${NAMESPACE}" })}}`,
icon: 'ProfileOutlined',
Component: BlockTemplateList,
});
this.app.pluginSettingsManager.add(`block-templates/:key`, {
title: false,
pluginKey: 'block-templates',
isTopLevel: false,
Component: BlockTemplatePage,
});
// add mobile router
this.app.pluginManager.get<PluginMobileClient>('mobile')?.mobileRouter?.add('mobile.schema.blockTemplate', {
path: `/block-templates/:key/:pageSchemaUid`,
Component: BlockTemplateMobilePage,
});
}
isInBlockTemplateConfigPage() {
const mobilePath = this.app.pluginManager.get<PluginMobileClient>('mobile')?.mobileBasename + '/block-templates';
const desktopPath = 'admin/settings/block-templates';
return window.location.pathname.includes(desktopPath) || window.location.pathname.includes(mobilePath);
}
setTemplateCache = (schema?: ISchema) => {
if (!schema) {
return;
}
this.templateschemacache[schema['x-uid']] = schema;
};
clearTemplateCache = (templateRootUid: string) => {
delete this.templateschemacache[templateRootUid];
};
#afterAllPluginsLoaded = () => {
// Check if this.app.loading is true every 1s
// If true, wait 1s and check again
// If false, stop checking and add template settings
const interval = setInterval(() => {
if (!this.app.loading) {
clearInterval(interval);
hideBlocksFromTemplate(this.injectInitializers, this.app);
// add template settings
const schemaSettings = this.app.schemaSettingsManager.getAll();
for (const key in schemaSettings) {
const schemaSetting = this.app.schemaSettingsManager.get(key);
// if not filter out fieldSettings:component:, we will show two revert setting item
if (schemaSetting && !key.startsWith('fieldSettings:component:')) {
for (let i = 0; i < schemaSetting.items.length; i++) {
// hide convert to block setting item
hideConvertToBlockSettingItem(
schemaSetting.items[i],
schemaSetting.items[i - 1],
schemaSetting.items[i + 1],
);
// hide connect data blocks setting item from template configure page
hideConnectDataBlocksFromTemplate(schemaSetting.items[i]);
// hide delete setting item
hideDeleteSettingItem(schemaSetting.items[i], schemaSetting.items[i - 1]);
}
const deleteItemIndex = schemaSetting.items.findIndex((item, index) => {
const nextItem = schemaSetting.items[index + 1];
return item['type'] === 'divider' && (nextItem?.name === 'delete' || nextItem?.name === 'remove');
});
if (
deleteItemIndex !== -1 &&
!schemaSetting.items.find((item) => item.name === 'template-revertSettingItem')
) {
schemaSetting.items.splice(deleteItemIndex, 0, revertSettingItem);
} else {
schemaSetting.add('template-revertSettingItem', revertSettingItem);
}
schemaSetting.add('template-disabledDeleteItem', disabledDeleteSettingItem);
}
}
}
}, 1000);
};
}
export default PluginBlockTemplateClient;

View File

@ -0,0 +1,467 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { SchemaInitializerItem, usePlugin, ISchema, useSchemaInitializer } from '@nocobase/client';
import React, { useState, useRef, useEffect } from 'react';
import { CopyOutlined, LoadingOutlined } from '@ant-design/icons';
import { Input, Divider, Empty } from 'antd';
import * as _ from 'lodash';
import { uid } from '@nocobase/utils/client';
import PluginBlockTemplateClient from '..';
import { useT } from '../locale';
import { useBlockTemplateMenus } from '../components/BlockTemplateMenusProvider';
import { useMemoizedFn } from 'ahooks';
import { findBlockRootSchema } from '../utils/schema';
export function convertTplBlock(
tpl,
virtual = false,
isRoot = true,
newRootId?: string,
templateKey?: string,
options?: any,
) {
if (!newRootId) {
newRootId = uid();
}
// 如果是Grid, Grid.Row, Grid.Col, 则复制一份
if (tpl['x-component'] === 'Grid' || tpl['x-component'] === 'Grid.Row' || tpl['x-component'] === 'Grid.Col') {
const newSchema = _.cloneDeep({
...tpl,
'x-uid': uid(), // 生成一个新的uid
properties: {},
});
if (virtual) {
newSchema['x-virtual'] = true;
}
if (newSchema['x-decorator'] === 'TemplateGridDecorator') {
delete newSchema['x-decorator'];
}
for (const key in tpl.properties) {
const t = convertTplBlock(tpl.properties[key], virtual, isRoot, newRootId, templateKey, options);
if (isRoot) {
newRootId = uid(); // 多个区块支持每个Grid.Row都要生成一个新的uid
}
if (t) {
newSchema.properties[key] = t;
}
}
return newSchema;
} else {
const newSchema = {
// ...tpl,
// 'x-component': rootComponent === tpl ? 'XTemplate' : tpl['x-component'],
// 'x-decorator': tpl['x-decorator'],
// type: rootComponent ? 'void' : tpl.type,
// name: tpl.name,
'x-uid': `${newRootId}-${tpl['x-uid']}`,
'x-template-uid': tpl['x-uid'],
properties: {},
};
if (virtual) {
newSchema['x-virtual'] = true;
}
if (tpl['x-settings']) {
newSchema['x-settings'] = tpl['x-settings'];
}
if (isRoot) {
newSchema['x-template-root-uid'] = tpl['x-uid'];
newSchema['x-uid'] = newRootId;
newSchema['x-template-version'] = '1.0';
}
blockKeepProps.forEach((prop) => {
if (_.hasIn(tpl, prop)) {
_.set(newSchema, prop, _.get(tpl, prop));
}
});
if (templateKey) {
newSchema['x-block-template-key'] = templateKey;
}
// custom request action will saved in other schema
if (tpl['x-component'] === 'CustomRequestAction') {
newSchema['x-custom-request-id'] = tpl['x-custom-request-id'] || tpl['x-uid'];
}
// association field will saved in other schema
if (tpl['x-component'] === 'Action' && _.get(tpl, 'x-action-settings.schemaUid')) {
newSchema['x-action-settings'] = {
schemaUid: '',
};
}
if (!tpl['x-component']) {
newSchema['x-no-component'] = true;
}
// filter should be in tpl
if (_.get(tpl, 'x-filter-targets')) {
newSchema['x-filter-targets'] = tpl['x-filter-targets'];
}
for (const key in tpl.properties) {
newSchema.properties[key] = convertTplBlock(tpl.properties[key], virtual, false, newRootId, templateKey);
}
if (isRoot && options) {
schemaPatch(newSchema, options);
}
return newSchema;
}
}
export const blockKeepProps = [
'x-decorator',
'x-decorator-props.collection',
'x-decorator-props.association',
'x-decorator-props.dataSource',
'x-decorator-props.action',
'x-decorator-props.params',
'x-acl-action',
'x-settings',
'x-use-decorator-props',
'x-is-current',
];
export function formSchemaPatch(currentSchema: ISchema, options?: any) {
const { collectionName, dataSourceName, association, currentRecord } = options;
if (currentRecord) {
currentSchema['x-decorator-props'] = {
action: 'get',
collection: collectionName,
association: association,
dataSource: dataSourceName,
};
currentSchema['x-data-templates'] = {
display: false,
};
currentSchema['x-acl-action'] = `${association || collectionName}:update`;
currentSchema['x-settings'] = 'blockSettings:editForm';
currentSchema['x-use-decorator-props'] = 'useEditFormBlockDecoratorProps';
currentSchema['x-is-current'] = true;
const comKey = Object.keys(currentSchema.properties)[0];
if (comKey) {
const actionKey = Object.keys(currentSchema['properties'][comKey]['properties']).find((key) => {
return key !== 'grid';
});
if (actionKey) {
_.set(currentSchema, `properties.${comKey}.x-use-component-props`, 'useEditFormBlockProps');
_.set(currentSchema, `properties.${comKey}.properties.${actionKey}.x-initializer`, 'editForm:configureActions');
const actionBarSchema = _.get(currentSchema, `properties.${comKey}.properties.${actionKey}.properties`, {});
for (const key in actionBarSchema) {
if (actionBarSchema[key]['x-settings']?.includes('createSubmit')) {
actionBarSchema[key]['x-settings'] = 'actionSettings:updateSubmit';
if (actionBarSchema[key]['x-use-component-props'] !== 'useStepsFormSubmitActionProps') {
actionBarSchema[key]['x-use-component-props'] = 'useUpdateActionProps';
}
}
}
}
}
} else {
currentSchema['x-decorator-props'] = {
collection: collectionName,
association: association,
dataSource: dataSourceName,
};
}
}
function detailsSchemaPatch(currentSchema: ISchema, options?: any) {
const { collectionName, dataSourceName, association, currentRecord, associationType } = options;
currentSchema['x-decorator-props'] = {
action: 'list',
collection: association ? null : collectionName,
association: association,
dataSource: dataSourceName,
readPretty: true,
params: {
pageSize: 1,
},
};
currentSchema['x-acl-action'] = `${association || collectionName}:view`; //currentSchema['x-acl-action'].replace(':get', ':view');
currentSchema['x-settings'] = 'blockSettings:detailsWithPagination';
currentSchema['x-use-decorator-props'] = 'useDetailsWithPaginationDecoratorProps';
if (currentRecord || associationType === 'hasOne' || associationType === 'belongsTo') {
currentSchema['x-acl-action'] = `${association || collectionName}:get`;
currentSchema['x-decorator-props']['action'] = 'get';
currentSchema['x-settings'] = 'blockSettings:details';
currentSchema['x-use-decorator-props'] = 'useDetailsDecoratorProps';
}
if (currentRecord) {
currentSchema['x-is-current'] = true;
}
}
function nestedSchemaPatch(currentSchema: ISchema) {
// Handle child blocks recursively
if (currentSchema.properties && currentSchema['x-decorator-props']?.association) {
const processChildBlock = (schema: ISchema, parentAssociation?: string) => {
const decoratorName = schema['x-decorator'];
// If this is a DetailsBlockProvider or FormBlockProvider
if (decoratorName === 'DetailsBlockProvider' || decoratorName === 'FormBlockProvider') {
if (!schema['x-decorator-props']?.association && parentAssociation) {
const settings = schema['x-settings'];
if (settings === 'blockSettings:editForm' || settings === 'blockSettings:details') {
schema['x-decorator-props'].association = parentAssociation;
schema['x-is-current'] = true;
schema['x-acl-action'] = `${parentAssociation}:${schema['x-acl-action']?.split(':')[1]}`;
}
}
}
const decoratorProps = schema['x-decorator-props'];
if (decoratorProps && decoratorProps.collection && !decoratorProps.association) {
// the association is not set in parent datablock provider, so we don't need to process down
return;
}
// Get the current block's association for its children
const currentAssociation = decoratorProps?.association || parentAssociation;
// Process children recursively
if (schema.properties) {
Object.values(schema.properties).forEach((childSchema) => {
processChildBlock(childSchema, currentAssociation);
});
}
};
// Start processing from the root's immediate children
Object.values(currentSchema.properties).forEach((childSchema) => {
processChildBlock(childSchema, currentSchema['x-decorator-props']?.association);
});
}
}
function schemaPatch(currentSchema: ISchema, options?: any) {
const { collectionName, dataSourceName, association } = options;
const decoratorName = currentSchema['x-decorator'];
if (decoratorName === 'DetailsBlockProvider') {
detailsSchemaPatch(currentSchema, options);
} else if (decoratorName === 'FormBlockProvider') {
formSchemaPatch(currentSchema, options);
} else if (decoratorName) {
currentSchema['x-decorator-props'] = {
action: 'list',
collection: collectionName,
association: association,
dataSource: dataSourceName,
};
}
nestedSchemaPatch(currentSchema);
return currentSchema;
}
function getSchemaUidMaps(schema, idMap = {}) {
if (schema['x-template-uid']) {
idMap[schema['x-template-uid']] = schema['x-uid'];
}
if (schema.properties) {
for (const key in schema.properties) {
getSchemaUidMaps(schema.properties[key], idMap);
}
}
return idMap;
}
function correctIdReference(schema, idMaps) {
const skipReplaceKeys = ['x-uid', 'x-template-uid', 'x-template-root-uid', 'x-custom-request-id'];
for (const key in schema) {
if (!skipReplaceKeys.includes(key)) {
if (schema[key] && typeof schema[key] === 'string') {
schema[key] = idMaps[schema[key]] || schema[key];
}
if (schema[key] && typeof schema[key] === 'object') {
correctIdReference(schema[key], idMaps);
}
}
}
}
export function correctIdReferences(schemas) {
const idMaps = {};
for (const schema of schemas) {
_.merge(idMaps, getSchemaUidMaps(schema));
}
for (const schema of schemas) {
correctIdReference(schema, idMaps);
}
}
export function convertTemplateToBlock(data, templateKey?: string, options?: any) {
// debugger;
let tpls = data?.properties; // Grid开始的区块
tpls = _.get(Object.values(tpls), '0.properties'); // Grid.Row开始的区块
const schemas = [];
// 遍历 tpl的所有属性每一个属性其实是一个区块
for (const key in tpls) {
const tpl = tpls[key];
const schema = convertTplBlock(tpl, false, true, undefined, templateKey, options);
if (schema) {
schemas.push(findBlockRootSchema(schema));
}
}
return schemas;
}
const SearchInput = ({ value: outValue, onChange }) => {
const [value, setValue] = useState<string>(outValue);
const inputRef = useRef<any>('');
const compositionRef = useRef<boolean>(false);
const t = useT();
useEffect(() => {
setValue(outValue);
}, [outValue]);
useEffect(() => {
const focusInput = () => {
if (inputRef.current) {
inputRef.current.focus();
}
};
const observer = new IntersectionObserver((entries) => {
if (entries.some((v) => v.isIntersecting)) {
focusInput();
}
});
if (inputRef.current?.input) {
observer.observe(inputRef.current.input);
}
return () => {
observer.disconnect();
};
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!compositionRef.current) {
onChange(e.target.value);
setValue(e.target.value);
}
};
const handleComposition = (e: React.CompositionEvent<HTMLInputElement> | any) => {
if (e.type === 'compositionend') {
compositionRef.current = false;
handleChange(e);
} else {
compositionRef.current = true;
}
};
return (
<div onClick={(e) => e.stopPropagation()}>
<Input
ref={inputRef}
allowClear
style={{ padding: '4px 8px', boxShadow: 'none', borderRadius: 0 }}
bordered={false}
placeholder={t('Search and select template')}
value={value}
onClick={(e) => {
e.stopPropagation();
}}
onChange={handleChange}
onCompositionStart={handleComposition}
onCompositionEnd={handleComposition}
onCompositionUpdate={handleComposition}
/>
<Divider style={{ margin: 0 }} />
</div>
);
};
export const TemplateBlockInitializer = () => {
const { insert } = useSchemaInitializer();
const plugin = usePlugin(PluginBlockTemplateClient);
const [searchValue, setSearchValue] = useState('');
const t = useT();
const { templates, handleTemplateClick, loading } = useBlockTemplateMenus();
const filteredData = templates
?.filter((item) => !item.dataSource)
.filter((item) => !searchValue || item.title.toLowerCase().includes(searchValue.toLowerCase()));
const menuItems = [
{
key: 'search',
label: (
<SearchInput
value={searchValue}
onChange={(val: string) => {
setSearchValue(val);
}}
/>
),
onClick: (e) => {
e.domEvent.stopPropagation();
},
},
...(filteredData?.length
? filteredData.map((item) => ({
label: item.title,
...item,
}))
: [
{
key: 'empty',
style: {
height: 150,
},
label: (
<div onClick={(e) => e.stopPropagation()}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('No data')} />
</div>
),
},
]),
];
useEffect(() => {
templates?.forEach((item) => {
plugin.templateInfos.set(item.key, item);
});
}, [templates, plugin.templateInfos]);
const onClick = useMemoizedFn((item) => {
handleTemplateClick(item, {}, insert);
});
if (loading) {
return (
<div>
<LoadingOutlined /> {t('Templates')}
</div>
);
}
return (
<SchemaInitializerItem
closeInitializerMenuWhenClick={true}
title={'{{t("Block template")}}'}
icon={<CopyOutlined style={{ marginRight: 0 }} />}
items={menuItems}
name={'templates'}
onClick={onClick}
/>
);
};

View File

@ -0,0 +1,11 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export * from './TemplateBlockInitializer';
export * from './templateBlockInitializerItem';

View File

@ -0,0 +1,21 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { SchemaInitializerItemTypeWithoutName, usePlugin } from '@nocobase/client';
import { TemplateBlockInitializer } from './TemplateBlockInitializer';
export const templateBlockInitializerItem: SchemaInitializerItemTypeWithoutName = {
name: 'templates',
Component: TemplateBlockInitializer,
title: '{{t("Block template")}}',
icon: 'TableOutlined',
// // sort: -1,
wrap: (t) => t,
useVisible: () => false,
};

View File

@ -0,0 +1,21 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
// @ts-ignore
import pkg from '../../package.json';
import { useApp } from '@nocobase/client';
export function useT() {
const app = useApp();
return (str: string) => app.i18n.t(str, { ns: [pkg.name, 'client'] });
}
export function tStr(key: string) {
return `{{t(${JSON.stringify(key)}, { ns: ['${pkg.name}', 'client'], nsMode: 'fallback' })}}`;
}

View File

@ -0,0 +1,280 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { ISchema } from '@nocobase/client';
import { uid } from '@nocobase/utils/client';
import { blockTemplatesCollection } from '../collections/blockTemplates';
import { createActionSchema } from './createActionSchema';
import { ConfigureLink } from '../components/ConfigureLink';
import { editActionSchema } from './editActionSchema';
import { NAMESPACE } from '../constants';
import { tStr } from '../locale';
import { bulkDestroySchema } from './bulkDestroySchema';
import { AddNewTemplate } from '../components/AddNewTemplate';
export const blockTemplatesSchema: ISchema = {
type: 'void',
name: uid(),
'x-component': 'CardItem',
'x-decorator': 'TableBlockProvider',
'x-decorator-props': {
collection: blockTemplatesCollection.name,
action: 'list',
params: {
sort: '-createdAt',
},
showIndex: true,
dragSort: false,
rowKey: 'key',
},
properties: {
actions: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 20,
},
},
properties: {
filter: {
type: 'void',
title: '{{ t("Filter") }}',
default: {
$and: [{ title: { $includes: '' } }],
},
'x-action': 'filter',
'x-component': 'Filter.Action',
'x-use-component-props': 'useFilterActionProps',
'x-component-props': {
icon: 'FilterOutlined',
},
'x-align': 'left',
},
refresh: {
type: 'void',
title: '{{ t("Refresh") }}',
'x-component': 'Action',
'x-use-component-props': 'useRefreshActionProps',
'x-component-props': {
icon: 'ReloadOutlined',
},
},
bulkDestroySchema,
addNew: {
type: 'void',
'x-component': AddNewTemplate,
},
},
},
table: {
type: 'array',
'x-component': 'TableV2',
'x-use-component-props': 'useTableBlockProps',
'x-component-props': {
rowKey: blockTemplatesCollection.filterTargetKey,
rowSelection: {
type: 'checkbox',
},
},
properties: {
title: {
type: 'void',
title: '{{ t("Title") }}',
'x-component': 'TableV2.Column',
'x-component-props': {
width: 150,
},
properties: {
title: {
type: 'string',
'x-component': 'CollectionField',
'x-read-pretty': true,
'x-component-props': {
ellipsis: true,
},
},
},
},
key: {
type: 'void',
title: '{{ t("Name") }}',
'x-component': 'TableV2.Column',
'x-component-props': {
width: 80,
},
properties: {
key: {
type: 'string',
'x-component': 'CollectionField',
'x-read-pretty': true,
'x-component-props': {
ellipsis: true,
},
},
},
},
type: {
type: 'void',
title: tStr('Type'),
'x-component': 'TableV2.Column',
'x-component-props': {
width: 80,
},
properties: {
type: {
type: 'string',
'x-component': 'CollectionField',
'x-read-pretty': true,
'x-component-props': {
ellipsis: true,
},
},
},
},
description: {
type: 'void',
title: '{{ t("Description") }}',
'x-component': 'TableV2.Column',
properties: {
description: {
type: 'string',
'x-component': 'CollectionField',
'x-read-pretty': true,
'x-component-props': {
ellipsis: true,
},
},
},
},
actions: {
type: 'void',
title: '{{ t("Actions") }}',
'x-component': 'TableV2.Column',
properties: {
actions: {
type: 'void',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
properties: {
configure: {
type: 'void',
title: tStr('Configure'),
'x-component': ConfigureLink,
},
editActionSchema,
duplicate: {
type: 'void',
title: `{{t("Duplicate", { ns: "${NAMESPACE}" })}}`,
'x-component': 'Action.Link',
'x-component-props': {
openSize: 'small',
},
properties: {
modal: {
type: 'void',
title: `{{t("Duplicate to new template", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormV2',
'x-component': 'Action.Modal',
properties: {
title: {
type: 'string',
title: '{{t("Title")}}',
'x-decorator': 'FormItem',
'x-component': 'Input',
required: true,
},
footer: {
type: 'void',
'x-component': 'Action.Modal.Footer',
properties: {
submit: {
type: 'void',
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ useDuplicateAction }}',
},
},
cancel: {
type: 'void',
title: '{{t("Cancel")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
},
},
},
},
},
},
delete: {
type: 'void',
title: tStr('Delete'),
'x-component': 'Action.Link',
'x-component-props': {
openSize: 'small',
},
properties: {
modal: {
type: 'void',
title: `{{t("Delete", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormV2',
'x-component': 'Action.Modal',
'x-component-props': {
openSize: 'small',
},
properties: {
keepBlocks: {
type: 'boolean',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
default: true,
'x-content': tStr('Keep the created blocks?'),
},
footer: {
type: 'void',
'x-component': 'Action.Modal.Footer',
properties: {
submit: {
type: 'void',
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ useDeleteAction }}',
},
},
cancel: {
type: 'void',
title: '{{t("Cancel")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
};

View File

@ -0,0 +1,67 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useMemo } from 'react';
import { tStr } from '../locale';
import { createForm } from '@formily/core';
export const bulkDestroySchema = {
name: 'bulkDestroySchema',
type: 'void',
'x-component': 'Action',
'x-component-props': {
openSize: 'small',
icon: 'DeleteOutlined',
},
title: '{{t("Delete")}}',
properties: {
modal: {
type: 'void',
'x-component': 'Action.Modal',
'x-decorator': 'FormV2',
'x-use-decorator-props': function useCreateFormProps() {
const form = useMemo(() => createForm(), []);
return { form };
},
title: '{{t("Delete")}}',
properties: {
keepBlocks: {
type: 'boolean',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
'x-content': tStr('Keep the created blocks?'),
default: true,
},
footer: {
type: 'void',
'x-component': 'Action.Modal.Footer',
properties: {
cancel: {
type: 'void',
title: '{{t("Cancel")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
submit: {
type: 'void',
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ useBulkDestroyAction }}',
},
},
},
},
},
},
},
};

View File

@ -0,0 +1,77 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { NAMESPACE } from '../constants';
import { useContext } from 'react';
import { NewTemplateFormContext } from '../components/AddNewTemplate';
export const createActionSchema = {
type: 'object',
'x-component': 'Action',
title: `{{t("Add new", { ns: "${NAMESPACE}" })}}`,
'x-align': 'right',
'x-component-props': {
type: 'primary',
icon: 'PlusOutlined',
openMode: 'drawer',
},
properties: {
drawer: {
type: 'void',
'x-component': 'Action.Drawer',
title: `{{t("Add new", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormV2',
'x-use-decorator-props': function useCreateFormProps() {
const form = useContext(NewTemplateFormContext);
return { form };
},
properties: {
form: {
type: 'void',
properties: {
title: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'CollectionField',
},
key: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'CollectionField',
'x-validator': 'uid',
description:
"{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
},
type: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'CollectionField',
},
description: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'CollectionField',
},
},
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
submit: {
title: 'Submit',
'x-component': 'Action',
'x-use-component-props': 'useCreateActionProps',
},
},
},
},
},
},
};

View File

@ -0,0 +1,69 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { tStr } from '../locale';
export const editActionSchema = {
type: 'void',
title: tStr('Edit'),
'x-component': 'Action.Link',
'x-component-props': {
openMode: 'drawer',
icon: 'EditOutlined',
},
properties: {
drawer: {
type: 'void',
title: tStr('Edit'),
'x-component': 'Action.Drawer',
'x-decorator': 'FormV2',
'x-use-decorator-props': 'useEditFormProps',
properties: {
form: {
type: 'void',
properties: {
title: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'CollectionField',
},
key: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'CollectionField',
'x-disabled': true,
},
type: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'CollectionField',
'x-disabled': true,
},
description: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'CollectionField',
},
},
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
submit: {
title: tStr('Submit'),
'x-component': 'Action',
'x-use-component-props': 'useEditActionProps',
},
},
},
},
},
},
};

View File

@ -0,0 +1,13 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export * from './blockTemplates';
export * from './createActionSchema';
export * from './editActionSchema';
export * from './bulkDestroySchema';

View File

@ -0,0 +1,19 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useIsInTemplate } from '../hooks/useIsInTemplate';
import { DisabledDeleteItem } from '../components/DisabledDeleteItem';
import { tStr } from '../locale';
export const disabledDeleteSettingItem = {
name: 'template-disabledDeleteItem',
title: tStr('Delete'),
Component: DisabledDeleteItem,
useVisible: () => useIsInTemplate(false),
};

View File

@ -0,0 +1,26 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useFieldSchema } from '@formily/react';
import _ from 'lodash';
import { RevertSetting } from '../components/RevertSetting';
import { tStr } from '../locale';
import { useIsInTemplate } from '../hooks/useIsInTemplate';
export const revertSettingItem = {
name: 'template-revertSettingItem',
title: tStr('Revert to template'),
Component: RevertSetting,
useVisible: () => {
const fieldSchema = useFieldSchema();
const isInTemplate = useIsInTemplate();
// in steps form, the schema is not the one saved in server side, so we need to hide the revert setting item
return isInTemplate && fieldSchema['x-settings'] !== 'settings:stepsFormStepTitleSettings';
},
};

View File

@ -0,0 +1,128 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { AxiosRequestConfig } from 'axios';
import {
findSchemaCache,
findFirstVirtualSchema,
convertToCreateSchema,
collectSchemaFirstVirtualUids,
findSchemaByUid,
} from './template';
import { ISchema } from '@nocobase/client';
import _ from 'lodash';
/**
* Register template block related interceptors for axios
* Handles schema removal and patching operations for template blocks
* @param apiClient The API client instance
* @param pageBlocks The template blocks cache
*/
export function registerTemplateBlockInterceptors(
apiClient: any,
pageBlocks: Record<string, any>,
savedSchemaUids: Set<string>,
) {
const setToTrueSchema = (uid: string) => {
const cacheSchema = findSchemaCache(pageBlocks, uid);
const deleteVirtual = (schema: ISchema) => {
if (schema?.['x-virtual']) {
savedSchemaUids.add(schema['x-uid']);
}
if (schema?.properties) {
for (const key in schema.properties) {
deleteVirtual(schema.properties[key]);
}
}
};
const schema = findSchemaByUid(cacheSchema, uid);
deleteVirtual(schema);
};
apiClient.axios.interceptors.request.use(async (config: AxiosRequestConfig) => {
// Handle schema patching
if (config.url?.includes('uiSchemas:patch') || config.url?.includes('uiSchemas:initializeActionContext')) {
const xUid = config.data?.['x-uid'];
const currentPageSchema = findSchemaCache(pageBlocks, xUid);
const currentSchema = findSchemaByUid(currentPageSchema, xUid);
const uids = collectSchemaFirstVirtualUids(currentSchema);
for (const uid of uids) {
const virtualSchema = findFirstVirtualSchema(currentPageSchema, uid);
if (virtualSchema) {
const newSchema = convertToCreateSchema(virtualSchema.schema);
await apiClient.request({
url: `/blockTemplates:saveSchema/${virtualSchema.insertTarget}?position=${virtualSchema.insertPosition}`,
method: 'post',
data: {
schema: newSchema,
},
});
setToTrueSchema(virtualSchema.schema['x-uid']);
}
}
}
// Handle schema batch patch
if (config.url?.includes('uiSchemas:batchPatch')) {
const schemas = config.data;
for (const schema of schemas) {
const currentSchema = findSchemaCache(pageBlocks, schema['x-uid']);
const virtualSchema = findFirstVirtualSchema(currentSchema, schema['x-uid']);
if (virtualSchema) {
await apiClient.request({
url: `/blockTemplates:saveSchema/${virtualSchema.insertTarget}?position=${virtualSchema.insertPosition}`,
method: 'post',
data: {
schema: convertToCreateSchema(virtualSchema.schema),
},
});
setToTrueSchema(virtualSchema.schema['x-uid']);
}
}
}
if (config.url?.includes('uiSchemas:insertAdjacent')) {
const uidWithQuery = config.url.split('/').pop();
const wrap = config.data?.wrap;
const schema = config.data?.schema;
const uid = uidWithQuery?.split('?')[0];
const schemaId = schema['x-uid'] || schema;
const currentSchema = findSchemaCache(pageBlocks, uid);
const virtualSchema = findFirstVirtualSchema(currentSchema, uid, wrap);
if (virtualSchema) {
await apiClient.request({
url: `/blockTemplates:saveSchema/${virtualSchema.insertTarget}?position=${virtualSchema.insertPosition}`,
method: 'post',
data: {
schema: convertToCreateSchema(virtualSchema.schema, [schemaId, wrap?.['x-uid']].filter(Boolean)),
},
});
setToTrueSchema(virtualSchema.schema['x-uid']);
}
const cs = findSchemaCache(pageBlocks, schemaId);
const vs = findFirstVirtualSchema(cs, schemaId, wrap);
if (vs && vs.insertTarget) {
await apiClient.request({
url: `/blockTemplates:saveSchema/${vs.insertTarget}?position=${vs.insertPosition}`,
method: 'post',
data: {
schema: convertToCreateSchema(vs.schema, [schemaId, wrap?.['x-uid']].filter(Boolean)),
},
});
setToTrueSchema(vs.schema['x-uid']);
if (wrap) {
setToTrueSchema(wrap['x-uid']);
}
}
}
return config;
});
}

Some files were not shown because too many files have changed in this diff Show More