feat: open subpages within the main page (#4797)

* feat: open subpages within the main page

* fix: fix known bugs and fix tests

* refactor: optimize popups style

* fix(style): avoid flickering

* chore: add comment

* fix: optimize nested popups

* refactor: optimize path after closing popup

* fix: fix draging

* chore: optimize routing stack

* feat: add back button for sub page

* test: add e2e test

* fix: enable returning from URL-opened pop-ups and subpages

* fix: enable subpages to navigate via main page menu

* refactor: optimize code

* fix: fix closePopup method

* fix: ensure block data refreshes after submitting from pop-up

* fix: add 404 info when popup is deleted and add e2e test

* fix: fix embed page

* chore: add translation

* fix(duplicate): fix e2e test

* fix: fix filterByTK

* chore(CI): add job for workflow-approval

* chore(CI): fix syntax

* chore(CI): add 'plugin-workflow-approval' in needs
This commit is contained in:
Zeke Zhang 2024-07-05 20:15:11 +08:00 committed by GitHub
parent 9b691e7bf1
commit ec5e4b0336
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 3140 additions and 793 deletions

View File

@ -199,7 +199,87 @@ jobs:
- run: npx playwright install chromium --with-deps - run: npx playwright install chromium --with-deps
- name: Test with postgres - name: Test with postgres
run: yarn e2e p-test --match 'packages/**/{plugin-workflow,plugin-workflow-*}/**/__e2e__/**/*.test.ts' run: yarn e2e p-test --match 'packages/**/{plugin-workflow,plugin-workflow-*}/**/__e2e__/**/*.test.ts' --ignore 'packages/**/plugin-workflow-approval/**/__e2e__/**/*.test.ts'
env:
__E2E__: true
APP_ENV: production
LOGGER_LEVEL: error
DB_DIALECT: postgres
DB_HOST: postgres
DB_PORT: 5432
DB_USER: nocobase
DB_PASSWORD: password
DB_DATABASE: nocobase
APPEND_PRESET_LOCAL_PLUGINS: ${{ steps.vars.outputs.var2 }}
- name: Upload e2e-report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: e2e-report-${{ github.job }} # 为了防止在多个任务中存在冲突
path: ./storage/playwright/tests-report-blob/blob-*/*
timeout-minutes: 60
plugin-workflow-approval:
name: plugin-workflow-approval
needs: build
runs-on: ubuntu-latest
container: node:18
services:
# Label used to access the service container
postgres:
# Docker Hub image
image: postgres:11
# Provide the password for postgres
env:
POSTGRES_USER: nocobase
POSTGRES_PASSWORD: password
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Checkout pro-plugins
continue-on-error: true # 外部开发者提交 PR 的时候因为没有权限这里会报错,为了能够继续执行后续步骤,所以这里设置为 continue-on-error: true
uses: actions/checkout@v4
with:
repository: nocobase/pro-plugins
ref: main
path: packages/pro-plugins
ssh-key: ${{ secrets.SUBMODULE_SSH_KEY }}
- name: Set variables
continue-on-error: true # 外部开发者提交 PR 的时候因为没有权限这里会报错,为了能够继续执行后续步骤,所以这里设置为 continue-on-error: true
run: |
APPEND_PRESET_LOCAL_PLUGINS=$(find ./packages/pro-plugins/@nocobase -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sed 's/^plugin-//' | tr '\n' ',' | sed 's/,$//')
echo "var2=$APPEND_PRESET_LOCAL_PLUGINS" >> $GITHUB_OUTPUT
id: vars
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v4
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- run: yarn
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: build-artifact
path: packages
- run: npx playwright install chromium --with-deps
- name: Test with postgres
run: yarn e2e p-test --match 'packages/**/plugin-workflow-approval/**/__e2e__/**/*.test.ts'
env: env:
__E2E__: true __E2E__: true
APP_ENV: production APP_ENV: production
@ -307,6 +387,7 @@ jobs:
needs: needs:
- core-and-plugins - core-and-plugins
- plugin-workflow - plugin-workflow
- plugin-workflow-approval
- plugin-data-source-main - plugin-data-source-main
if: ${{ !cancelled() && github.event.pull_request.number }} if: ${{ !cancelled() && github.event.pull_request.number }}
steps: steps:

View File

@ -36,7 +36,6 @@ import {
import { DataBlockCollector } from '../filter-provider/FilterProvider'; import { DataBlockCollector } from '../filter-provider/FilterProvider';
import { useSourceId } from '../modules/blocks/useSourceId'; import { useSourceId } from '../modules/blocks/useSourceId';
import { RecordProvider, useRecordIndex } from '../record-provider'; import { RecordProvider, useRecordIndex } from '../record-provider';
import { usePagePopup } from '../schema-component/antd/page/pagePopupUtils';
import { useAssociationNames } from './hooks'; import { useAssociationNames } from './hooks';
import { useDataBlockParentRecord } from './hooks/useDataBlockParentRecord'; import { useDataBlockParentRecord } from './hooks/useDataBlockParentRecord';
@ -300,11 +299,6 @@ export const useFilterByTk = () => {
const { getCollectionField } = useCollectionManager_deprecated(); const { getCollectionField } = useCollectionManager_deprecated();
const assoc = useBlockAssociationContext(); const assoc = useBlockAssociationContext();
const withoutTableFieldResource = useContext(WithoutTableFieldResource); const withoutTableFieldResource = useContext(WithoutTableFieldResource);
const { popupParams } = usePagePopup();
if (popupParams?.filterbytk) {
return popupParams.filterbytk;
}
if (!withoutTableFieldResource) { if (!withoutTableFieldResource) {
if (resource instanceof TableFieldResource || __parent?.block === 'TableField') { if (resource instanceof TableFieldResource || __parent?.block === 'TableField') {

View File

@ -833,5 +833,6 @@
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.", "The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.",
"URL search params": "URL search params", "URL search params": "URL search params",
"Expand All": "Expand All", "Expand All": "Expand All",
"Search": "Search" "Search": "Search",
"Sorry, the page you visited does not exist.": "Sorry, the page you visited does not exist."
} }

View File

@ -762,5 +762,6 @@
"This variable has been deprecated and can be replaced with \"Current form\"": "La variable ha sido obsoleta; \"Formulario actual\" puede ser utilizada como sustituto", "This variable has been deprecated and can be replaced with \"Current form\"": "La variable ha sido obsoleta; \"Formulario actual\" puede ser utilizada como sustituto",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "El valor de esta variable se deriva de la cadena de consulta de la URL de la página. Esta variable sólo puede utilizarse normalmente cuando la página tiene una cadena de consulta.", "The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "El valor de esta variable se deriva de la cadena de consulta de la URL de la página. Esta variable sólo puede utilizarse normalmente cuando la página tiene una cadena de consulta.",
"URL search params": "Parámetros de búsqueda de URL", "URL search params": "Parámetros de búsqueda de URL",
"Expand All": "Expandir todo" "Expand All": "Expandir todo",
"Sorry, the page you visited does not exist.": "Lo siento, la página que visitaste no existe."
} }

View File

@ -782,5 +782,6 @@
"This variable has been deprecated and can be replaced with \"Current form\"": "La variable a été obsolète ; \"Formulaire actuel\" peut être utilisé comme substitut", "This variable has been deprecated and can be replaced with \"Current form\"": "La variable a été obsolète ; \"Formulaire actuel\" peut être utilisé comme substitut",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "La valeur de cette variable est dérivée de la chaîne de requête de l'URL de la page. Cette variable ne peut être utilisée normalement que lorsque la page a une chaîne de requête.", "The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "La valeur de cette variable est dérivée de la chaîne de requête de l'URL de la page. Cette variable ne peut être utilisée normalement que lorsque la page a une chaîne de requête.",
"URL search params": "Paramètres de recherche d'URL", "URL search params": "Paramètres de recherche d'URL",
"Expand All": "Tout déplier" "Expand All": "Tout déplier",
"Sorry, the page you visited does not exist.": "Désolé, la page que vous avez visitée n'existe pas."
} }

View File

@ -701,5 +701,6 @@
"This variable has been deprecated and can be replaced with \"Current form\"": "この変数は非推奨です。代わりに「現在のフォーム」を使用してください", "This variable has been deprecated and can be replaced with \"Current form\"": "この変数は非推奨です。代わりに「現在のフォーム」を使用してください",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "この変数の値はページURLのクエリ文字列から取得されます。この変数は、ページにクエリ文字列がある場合にのみ正常に使用できます。", "The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "この変数の値はページURLのクエリ文字列から取得されます。この変数は、ページにクエリ文字列がある場合にのみ正常に使用できます。",
"URL search params": "URL検索パラメータ", "URL search params": "URL検索パラメータ",
"Expand All": "すべて展開" "Expand All": "すべて展開",
"Sorry, the page you visited does not exist.": "申し訳ありませんが、お探しのページは存在しません。"
} }

View File

@ -873,5 +873,6 @@
"This variable has been deprecated and can be replaced with \"Current form\"": "변수가 폐기되었습니다. \"현재 폼\"을 대체로 사용할 수 있습니다", "This variable has been deprecated and can be replaced with \"Current form\"": "변수가 폐기되었습니다. \"현재 폼\"을 대체로 사용할 수 있습니다",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "이 변수의 값은 페이지 URL의 쿼리 문자열에서 파생됩니다. 이 변수는 페이지에 쿼리 문자열이 있는 경우에만 정상적으로 사용할 수 있습니다.", "The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "이 변수의 값은 페이지 URL의 쿼리 문자열에서 파생됩니다. 이 변수는 페이지에 쿼리 문자열이 있는 경우에만 정상적으로 사용할 수 있습니다.",
"URL search params": "URL 검색 매개변수", "URL search params": "URL 검색 매개변수",
"Expand All": "모두 펼치기" "Expand All": "모두 펼치기",
"Sorry, the page you visited does not exist.": "죄송합니다. 방문한 페이지가 존재하지 않습니다."
} }

View File

@ -739,5 +739,6 @@
"URL search params": "Parâmetros de pesquisa de URL", "URL search params": "Parâmetros de pesquisa de URL",
"Expand All": "Expandir tudo", "Expand All": "Expandir tudo",
"Parent popup record": "Registro pop-up pai", "Parent popup record": "Registro pop-up pai",
"Current popup record": "Registro pop-up atual" "Current popup record": "Registro pop-up atual",
"Sorry, the page you visited does not exist.": "Desculpe, a página que você visitou não existe."
} }

View File

@ -576,5 +576,6 @@
"This variable has been deprecated and can be replaced with \"Current form\"": "Переменная устарела; \"Текущая форма\" может быть использована в качестве замены", "This variable has been deprecated and can be replaced with \"Current form\"": "Переменная устарела; \"Текущая форма\" может быть использована в качестве замены",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "Значение этой переменной происходит из строки запроса URL страницы. Эта переменная может использоваться только в том случае, если у страницы есть строка запроса.", "The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "Значение этой переменной происходит из строки запроса URL страницы. Эта переменная может использоваться только в том случае, если у страницы есть строка запроса.",
"URL search params": "Параметры поиска URL", "URL search params": "Параметры поиска URL",
"Expand All": "Развернуть все" "Expand All": "Развернуть все",
"Sorry, the page you visited does not exist.": "Извините, посещенной вами страницы не существует."
} }

View File

@ -574,5 +574,6 @@
"This variable has been deprecated and can be replaced with \"Current form\"": "Değişken kullanımdan kaldırıldı; \"Geçerli form\" yerine kullanılabilir", "This variable has been deprecated and can be replaced with \"Current form\"": "Değişken kullanımdan kaldırıldı; \"Geçerli form\" yerine kullanılabilir",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "Bu değişkenin değeri sayfa URL'sinin sorgu dizgisinden türetilir. Bu değişken, sayfanın bir sorgu dizgisi olduğunda yalnızca normal olarak kullanılabilir.", "The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "Bu değişkenin değeri sayfa URL'sinin sorgu dizgisinden türetilir. Bu değişken, sayfanın bir sorgu dizgisi olduğunda yalnızca normal olarak kullanılabilir.",
"URL search params": "URL arama parametreleri", "URL search params": "URL arama parametreleri",
"Expand All": "Tümünü genişlet" "Expand All": "Tümünü genişlet",
"Sorry, the page you visited does not exist.": "Üzgünüz, ziyaret ettiğiniz sayfa mevcut değil."
} }

View File

@ -782,5 +782,6 @@
"This variable has been deprecated and can be replaced with \"Current form\"": "Змінна була застарілою; \"Поточна форма\" може бути використана як заміна", "This variable has been deprecated and can be replaced with \"Current form\"": "Змінна була застарілою; \"Поточна форма\" може бути використана як заміна",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "Значення цієї змінної походить з рядка запиту URL-адреси сторінки. Цю змінну можна використовувати нормально лише тоді, коли у сторінки є рядок запиту.", "The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "Значення цієї змінної походить з рядка запиту URL-адреси сторінки. Цю змінну можна використовувати нормально лише тоді, коли у сторінки є рядок запиту.",
"URL search params": "Параметри пошуку URL", "URL search params": "Параметри пошуку URL",
"Expand All": "Розгорнути все" "Expand All": "Розгорнути все",
"Sorry, the page you visited does not exist.": "Вибачте, сторінка, яку ви відвідали, не існує."
} }

View File

@ -963,5 +963,6 @@
"Add parameter": "添加参数", "Add parameter": "添加参数",
"URL search params": "URL 查询参数", "URL search params": "URL 查询参数",
"Expand All": "展开全部", "Expand All": "展开全部",
"Search": "搜索" "Search": "搜索",
"Sorry, the page you visited does not exist.": "抱歉,你访问的页面不存在。"
} }

View File

@ -871,5 +871,6 @@
"This variable has been deprecated and can be replaced with \"Current form\"": "該變數已被棄用,可以使用“當前表單”作為替代", "This variable has been deprecated and can be replaced with \"Current form\"": "該變數已被棄用,可以使用“當前表單”作為替代",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "該變數的值來自頁面 URL 的查詢字符串,只有當頁面有查詢字符串時,該變數才能正常使用。", "The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "該變數的值來自頁面 URL 的查詢字符串,只有當頁面有查詢字符串時,該變數才能正常使用。",
"URL search params": "URL 查詢參數", "URL search params": "URL 查詢參數",
"Expand All": "展開全部" "Expand All": "展開全部",
"Sorry, the page you visited does not exist.": "抱歉,你訪問的頁面不存在。"
} }

View File

@ -64,5 +64,22 @@ test.describe('action settings', () => {
// close the first popup // close the first popup
await page.getByLabel('drawer-Action.Container-users-Edit record-mask').click(); await page.getByLabel('drawer-Action.Container-users-Edit record-mask').click();
await expect(page.getByLabel('block-item-CardItem-users-').getByRole('button', { name: 'abc123' })).toBeVisible(); await expect(page.getByLabel('block-item-CardItem-users-').getByRole('button', { name: 'abc123' })).toBeVisible();
// 重复上面的步骤,中间加上刷新页面的操作 -----------------------------------------------------------------------------------
await page.getByLabel('action-Action.Link-Edit-update-users-table-1').click();
await page.getByTestId('drawer-Action.Container-users-Edit record').getByLabel('action-Action.Link-Edit-').click();
// 刷新页面后依然正常
await page.reload();
await page.getByLabel('block-item-CollectionField-').getByRole('textbox').fill('abc456');
await page.getByLabel('action-Action-Submit-submit-').click();
// the first popup
await expect(page.getByRole('button', { name: 'abc456' })).toBeVisible();
// close the first popup
await page.locator('.ant-drawer-mask').click();
await expect(page.getByLabel('block-item-CardItem-users-').getByRole('button', { name: 'abc456' })).toBeVisible();
}); });
}); });

View File

@ -526,8 +526,8 @@ test.describe('set default value', () => {
await page.getByLabel('action-Action.Link-View').click(); await page.getByLabel('action-Action.Link-View').click();
// 在第一级弹窗中,不应该包含 Parent popup record 变量 // 在第一级弹窗中,不应该包含 Parent popup record 变量
await page.getByLabel('block-item-CardItem-users-').hover(); await page.getByText('UsersAdd newConfigure').hover();
await page.getByLabel('designer-schema-settings-CardItem-blockSettings:table-users').hover(); await page.getByRole('button', { name: 'designer-schema-settings-' }).hover();
await page.getByRole('menuitem', { name: 'Set the data scope' }).click(); await page.getByRole('menuitem', { name: 'Set the data scope' }).click();
await page.getByText('Add condition', { exact: true }).click(); await page.getByText('Add condition', { exact: true }).click();
await page.getByLabel('variable-button').click(); await page.getByLabel('variable-button').click();
@ -664,8 +664,8 @@ test.describe('set default value', () => {
await page.getByLabel('action-Action.Link-View').click(); await page.getByLabel('action-Action.Link-View').click();
// 在第一级弹窗中,不应该包含 Parent popup record 变量 // 在第一级弹窗中,不应该包含 Parent popup record 变量
await page.getByLabel('block-item-CardItem-users-').hover(); await page.getByText('UsersAdd newConfigure').hover();
await page.getByLabel('designer-schema-settings-CardItem-blockSettings:table-users').hover(); await page.getByRole('button', { name: 'designer-schema-settings-' }).hover();
await page.getByRole('menuitem', { name: 'Set the data scope' }).click(); await page.getByRole('menuitem', { name: 'Set the data scope' }).click();
await page.getByText('Add condition', { exact: true }).click(); await page.getByText('Add condition', { exact: true }).click();
await page.getByLabel('variable-button').click(); await page.getByLabel('variable-button').click();
@ -675,8 +675,8 @@ test.describe('set default value', () => {
// 关闭数据范围设置弹窗 // 关闭数据范围设置弹窗
await page.getByRole('button', { name: 'Close', exact: true }).click(); await page.getByRole('button', { name: 'Close', exact: true }).click();
await page.getByLabel('action-Action.Link-View in popup').click(); await page.getByLabel('action-Action.Link-View in').click();
await page.getByLabel('schema-initializer-Grid-popup').hover(); await page.getByLabel('schema-initializer-Grid-popup').nth(1).hover();
await page.getByRole('menuitem', { name: 'form Form (Add new) right' }).hover(); await page.getByRole('menuitem', { name: 'form Form (Add new) right' }).hover();
await page.getByRole('menuitem', { name: 'Other records right' }).hover(); await page.getByRole('menuitem', { name: 'Other records right' }).hover();
await page.getByRole('menuitem', { name: 'Users' }).click(); await page.getByRole('menuitem', { name: 'Users' }).click();
@ -728,18 +728,18 @@ test.describe('set default value', () => {
// 3. Table 数据选择器中使用 `Parent popup record` // 3. Table 数据选择器中使用 `Parent popup record`
// 创建 Table 区块 // 创建 Table 区块
await page.getByLabel('schema-initializer-Grid-popup').hover(); await page.getByLabel('schema-initializer-Grid-popup').first().hover();
await page.getByRole('menuitem', { name: 'table Table right' }).hover(); await page.getByRole('menuitem', { name: 'table Table right' }).hover();
await page.getByRole('menuitem', { name: 'Other records right' }).hover(); await page.getByRole('menuitem', { name: 'Other records right' }).hover();
await page.getByRole('menuitem', { name: 'Users' }).click(); await page.getByRole('menuitem', { name: 'Users' }).click();
await page.mouse.move(300, 0); await page.mouse.move(300, 0);
// 显示 Nickname 字段 // 显示 Nickname 字段
await page.getByLabel('schema-initializer-TableV2-').hover(); await page.getByLabel('schema-initializer-TableV2-').nth(1).hover();
await page.getByRole('menuitem', { name: 'Nickname' }).click(); await page.getByRole('menuitem', { name: 'Nickname' }).click();
await page.mouse.move(300, 0); await page.mouse.move(300, 0);
// 设置数据范围(使用 `Parent popup record` 变量) // 设置数据范围(使用 `Parent popup record` 变量)
await page.getByLabel('block-item-CardItem-users-table').hover(); await page.getByLabel('block-item-CardItem-users-table').nth(1).hover();
await page.getByLabel('designer-schema-settings-CardItem-blockSettings:table-users').hover(); await page.getByRole('button', { name: 'designer-schema-settings-' }).hover();
await page.getByRole('menuitem', { name: 'Set the data scope' }).click(); await page.getByRole('menuitem', { name: 'Set the data scope' }).click();
await page.getByText('Add condition', { exact: true }).click(); await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click(); await page.getByTestId('select-filter-field').click();

View File

@ -101,7 +101,6 @@ test.describe('actions schema settings', () => {
// 点击按钮后会跳转到一个页面 // 点击按钮后会跳转到一个页面
await page.getByLabel('action-Action-Add new-create-').click(); await page.getByLabel('action-Action-Add new-create-').click();
expect(page.url()).toContain('/subpages/');
// 配置出一个表单 // 配置出一个表单
await page.getByLabel('schema-initializer-Grid-popup').hover(); await page.getByLabel('schema-initializer-Grid-popup').hover();
@ -112,17 +111,16 @@ test.describe('actions schema settings', () => {
await page.getByRole('menuitem', { name: 'Single select' }).click(); await page.getByRole('menuitem', { name: 'Single select' }).click();
await page.mouse.move(300, 0); await page.mouse.move(300, 0);
await page.getByLabel('schema-initializer-ActionBar-').hover(); await page.getByLabel('schema-initializer-ActionBar-createForm:configureActions-general').hover();
await page.getByRole('menuitem', { name: 'Submit' }).click(); await page.getByRole('menuitem', { name: 'Submit' }).click();
// 创建一条数据后返回,列表中应该有这条数据 // 创建一条数据后返回,列表中应该有这条数据
await page.getByTestId('select-single').click(); await page.getByTestId('select-single').click();
await page.getByRole('option', { name: 'option3' }).click(); await page.getByRole('option', { name: 'option3' }).click();
// 提交后会自动返回
await page.getByLabel('action-Action-Submit-submit-').click(); await page.getByLabel('action-Action-Submit-submit-').click();
await page.goBack();
await page.getByLabel('schema-initializer-TableV2-').hover(); await page.getByLabel('schema-initializer-TableV2-').hover();
await page.getByRole('menuitem', { name: 'Single select' }).click(); await page.getByRole('menuitem', { name: 'Single select' }).click();
await page.mouse.move(300, 0); await page.mouse.move(300, 0);
@ -538,7 +536,6 @@ test.describe('actions schema settings', () => {
// 跳转到子页面后,其内容应该和弹窗中的内容一致 // 跳转到子页面后,其内容应该和弹窗中的内容一致
await page.getByLabel('action-Action.Link-View').click(); await page.getByLabel('action-Action.Link-View').click();
expect(page.url()).toContain('/subpages');
// 详情区块 // 详情区块
await expect( await expect(
@ -732,7 +729,7 @@ test.describe('actions schema settings', () => {
// 使用变量 `Current popup record` 和 `Parent popup record` 设置默认值 // 使用变量 `Current popup record` 和 `Parent popup record` 设置默认值
await expect( await expect(
page page
.getByLabel('block-item-CardItem-users-form') .getByText("UsersUse 'Current popup")
.getByLabel('block-item-CollectionField-users-form-users.nickname-Nickname') .getByLabel('block-item-CollectionField-users-form-users.nickname-Nickname')
.getByRole('textbox'), .getByRole('textbox'),
).toHaveValue('admin'); ).toHaveValue('admin');

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 { expect, test } from '@nocobase/test/e2e';
test.describe('deleted popups', () => {
test('should display error info when deleted popups', async ({ page, mockPage }) => {
const nocoPage = await mockPage().waitForInit();
const url = await nocoPage.getUrl();
await page.goto(
url +
'/popups/vygn5ile3xz/filterbytk/1/popups/n24hos465bj/filterbytk/admin/sourceid/1/popups/s32h1ed5g9i/filterbytk/admin/sourceid/1',
);
await expect(page.getByText('Sorry, the page you visited does not exist.')).toHaveCount(3);
// close the popups
await page.getByLabel('drawer-Action.Container-Error message-mask').click();
await page.getByLabel('drawer-Action.Container-Error message-mask').click();
await page.getByLabel('drawer-Action.Container-Error message-mask').click();
await expect(page.getByText('Sorry, the page you visited does not exist.')).toHaveCount(0);
});
});

View File

@ -0,0 +1,68 @@
/**
* 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 { expect, test } from '@nocobase/test/e2e';
import { shouldBackAfterClickBackButton } from './templatesOfBug';
test.describe('popup router', () => {
test('should work opened by URL', async ({ page, mockPage }) => {
const nocoPage = await mockPage({
keepUid: true,
...shouldBackAfterClickBackButton,
}).waitForInit();
const url = await nocoPage.getUrl();
// 直接跳转到子页面,然后点击返回按钮,查看是否能返回到上一级页面
await page.goto(
url +
'/popups/56tsj7l3k35/filterbytk/1/popups/bd3nizznkdw/filterbytk/member/sourceid/1/popups/1ct9qd9jlbm/filterbytk/member/sourceid/1',
);
// close the sub page
await page.getByLabel('back-button').click();
// open the sub page again then close it
await page.getByLabel('action-Action-Edit-update-roles-details-member').click();
await page.getByLabel('back-button').click();
// close the drawer
await page.getByLabel('drawer-Action.Container-roles-View record-mask').click();
await page.locator('.ant-drawer-mask').click();
// expect to be back to the first page
await page.getByText('Users单层子页面Configure').hover();
await expect(
page.getByRole('button', { name: 'designer-schema-settings-CardItem-blockSettings:table-users' }),
).toBeVisible();
// the same steps again by manual click -------------------------------------------------------------
// first open the sub page
await page.getByLabel('action-Action.Link-View-view-').nth(2).click();
await page.getByLabel('action-Action.Link-View-view-roles-table-member').click();
await page.getByLabel('action-Action-Edit-update-').click();
// the same steps with above
// close the sub page
await page.getByLabel('back-button').click();
// open the sub page again then close it
await page.getByLabel('action-Action-Edit-update-roles-details-member').click();
await page.getByLabel('back-button').click();
// close the drawer
await page.getByLabel('drawer-Action.Container-roles-View record-mask').click();
await page.locator('.ant-drawer-mask').click();
// expect to be back to the first page
await page.getByText('Users单层子页面Configure').hover();
await expect(
page.getByRole('button', { name: 'designer-schema-settings-CardItem-blockSettings:table-users' }),
).toBeVisible();
});
});

View File

@ -0,0 +1,79 @@
/**
* 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 { expect, test } from '@nocobase/test/e2e';
import { shouldBackAfterClickBackButton } from './templatesOfBug';
test.describe('sub page', () => {
test('should back after click back button', async ({ page, mockPage }) => {
await mockPage(shouldBackAfterClickBackButton).goto();
// 单层子页面 ------------------------------------------------------------------------
await page.getByLabel('action-Action.Link-View-view-').first().click();
await expect(
page.getByLabel('block-item-Markdown.Void-').getByText('Markdown单层子页面中的内容。'),
).toBeVisible();
// 切换 tab 之后点击返回按钮
await page.getByText('tab2').click();
await expect(
page.getByLabel('block-item-Markdown.Void-').getByText('Markdown单层子页面中的内容tab2。'),
).toBeVisible();
await page.getByLabel('back-button').click();
// 从弹窗中打开子页面 ----------------------------------------------------------------------
await page.getByLabel('action-Action.Link-View-view-').nth(1).click();
await page.getByLabel('action-Action.Link-View-view-roles-table-admin').click();
await expect(
page.getByLabel('block-item-Markdown.Void-').getByText('Markdown从弹窗中打开的子页面。'),
).toBeVisible();
// 切换 tab 之后点击返回按钮
await page.getByText('tab2').click();
await expect(
page.getByLabel('block-item-Markdown.Void-').getByText('Markdown从弹窗中打开的子页面tab2。'),
).toBeVisible();
await page.getByLabel('back-button').click();
await page.goBack();
// 从嵌套弹窗中打开子页面 --------------------------------------------------------------------
await page.getByLabel('action-Action.Link-View-view-').nth(2).click();
await page.getByLabel('action-Action.Link-View-view-roles-table-admin').click();
await page.getByLabel('action-Action-Edit-update-').click();
await expect(
page.getByLabel('block-item-Markdown.Void-').getByText('Markdown从嵌套弹窗中打开的子页面。'),
).toBeVisible();
await page.getByLabel('back-button').click();
await page.getByLabel('drawer-Action.Container-roles-View record-mask').click();
await page.getByLabel('drawer-Action.Container-users-View record-mask').click();
// 嵌套的子页面 ----------------------------------------------------------------------------
await page.getByLabel('action-Action.Link-View-view-').nth(3).click();
await page.getByLabel('action-Action.Link-View-view-roles-table-member').click();
await expect(
page.getByLabel('block-item-Markdown.Void-').getByText('Markdown嵌套的子页面第二层级。'),
).toBeVisible();
// 切换 tab 之后点击返回按钮
await page.getByText('tab2').click();
await expect(
page.getByLabel('block-item-Markdown.Void-').getByText('Markdown嵌套的子页面第二层级tab2。'),
).toBeVisible();
await page.getByLabel('back-button').nth(1).click();
await page.getByLabel('back-button').click();
expect(page.url()).not.toContain('/popups/');
// 确认是否回到了主页面
await page.getByText('Users单层子页面Configure').hover();
await expect(
page.getByRole('button', { name: 'designer-schema-settings-CardItem-blockSettings:table-users' }),
).toBeVisible();
});
});

View File

@ -27,7 +27,6 @@ import { AdminLayoutPlugin, RouteSchemaComponent } from '../route-switch';
import { AntdSchemaComponentPlugin, PageTabs, SchemaComponentPlugin } from '../schema-component'; import { AntdSchemaComponentPlugin, PageTabs, SchemaComponentPlugin } from '../schema-component';
import { ErrorFallback } from '../schema-component/antd/error-fallback'; import { ErrorFallback } from '../schema-component/antd/error-fallback';
import { PagePopups } from '../schema-component/antd/page/PagePopups'; import { PagePopups } from '../schema-component/antd/page/PagePopups';
import { SubPage } from '../schema-component/antd/page/SubPages';
import { AssociationFilterPlugin, SchemaInitializerPlugin } from '../schema-initializer'; import { AssociationFilterPlugin, SchemaInitializerPlugin } from '../schema-initializer';
import { SchemaSettingsPlugin } from '../schema-settings'; import { SchemaSettingsPlugin } from '../schema-settings';
import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates'; import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates';
@ -316,10 +315,6 @@ export class NocoBaseBuildInPlugin extends Plugin {
path: '/admin/:name/tabs/:tabUid/popups/*', path: '/admin/:name/tabs/:tabUid/popups/*',
Component: PagePopups, Component: PagePopups,
}); });
this.router.add('admin.subPage', {
path: '/admin/subpages/*',
Component: SubPage,
});
} }
addComponents() { addComponents() {

View File

@ -92,7 +92,7 @@ const MenuEditor = (props) => {
const ctx = useACLRoleContext(); const ctx = useACLRoleContext();
const [current, setCurrent] = useState(null); const [current, setCurrent] = useState(null);
const onSelect = useCallback(({ item }) => { const onSelect = useCallback(({ item }: { item; key; keyPath; domEvent }) => {
const schema = item.props.schema; const schema = item.props.schema;
setTitle(schema.title); setTitle(schema.title);
setCurrent(schema); setCurrent(schema);
@ -323,6 +323,33 @@ export const AdminDynamicPage = () => {
return <RouteSchemaComponent />; return <RouteSchemaComponent />;
}; };
const layoutContentClass = css`
display: flex;
flex-direction: column;
position: relative;
overflow-y: hidden;
height: 100vh;
> div {
position: relative;
}
.ant-layout-footer {
position: absolute;
bottom: 0;
text-align: center;
width: 100%;
z-index: 0;
padding: 0px 50px;
}
`;
const layoutContentHeaderClass = css`
flex-shrink: 0;
height: var(--nb-header-height);
line-height: var(--nb-header-height);
background: transparent;
pointer-events: none;
`;
export const InternalAdminLayout = () => { export const InternalAdminLayout = () => {
const result = useSystemSettings(); const result = useSystemSettings();
const { token } = useToken(); const { token } = useToken();
@ -447,36 +474,9 @@ export const InternalAdminLayout = () => {
</div> </div>
</Layout.Header> </Layout.Header>
<AdminSideBar sideMenuRef={sideMenuRef} /> <AdminSideBar sideMenuRef={sideMenuRef} />
<Layout.Content {/* Use the "nb-subpages-slot-without-header-and-side" class name to locate the position of the subpages */}
className={css` <Layout.Content className={`${layoutContentClass} nb-subpages-slot-without-header-and-side`}>
display: flex; <header className={layoutContentHeaderClass}></header>
flex-direction: column;
position: relative;
overflow-y: auto;
height: 100vh;
max-height: 100vh;
> div {
position: relative;
}
.ant-layout-footer {
position: absolute;
bottom: 0;
text-align: center;
width: 100%;
z-index: 0;
padding: 0px 50px;
}
`}
>
<header
className={css`
flex-shrink: 0;
height: var(--nb-header-height);
line-height: var(--nb-header-height);
background: transparent;
pointer-events: none;
`}
></header>
<Outlet /> <Outlet />
{/* {service.contentLoading ? render() : <Outlet />} */} {/* {service.contentLoading ? render() : <Outlet />} */}
</Layout.Content> </Layout.Content>

View File

@ -64,5 +64,5 @@ export const useStyles = genStyleHook('nb-action-drawer', (token) => {
// margin: `-${token.paddingPopupVertical}px -${token.paddingPopupHorizontal}px`, // margin: `-${token.paddingPopupVertical}px -${token.paddingPopupHorizontal}px`,
// }, // },
}, },
}; } as any;
}); });

View File

@ -10,9 +10,10 @@
import { observer, RecursionField, useField, useFieldSchema } from '@formily/react'; import { observer, RecursionField, useField, useFieldSchema } from '@formily/react';
import { Drawer } from 'antd'; import { Drawer } from 'antd';
import classNames from 'classnames'; import classNames from 'classnames';
import React from 'react'; import React, { useMemo } from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { ErrorFallback } from '../error-fallback'; import { ErrorFallback } from '../error-fallback';
import { useCurrentPopupContext } from '../page/PagePopups';
import { useStyles } from './Action.Drawer.style'; import { useStyles } from './Action.Drawer.style';
import { useActionContext } from './hooks'; import { useActionContext } from './hooks';
import { useSetAriaLabelForDrawer } from './hooks/useSetAriaLabelForDrawer'; import { useSetAriaLabelForDrawer } from './hooks/useSetAriaLabelForDrawer';
@ -45,6 +46,14 @@ export const InternalActionDrawer: React.FC<ActionDrawerProps> = observer(
} }
return buf; return buf;
}); });
const { hidden } = useCurrentPopupContext();
const rootStyle: React.CSSProperties = useMemo(() => {
return {
...drawerProps?.style,
...others?.style,
display: hidden ? 'none' : 'block',
};
}, [hidden, drawerProps?.style, others?.style]);
if (process.env.__E2E__) { if (process.env.__E2E__) {
useSetAriaLabelForDrawer(visible); useSetAriaLabelForDrawer(visible);
@ -56,10 +65,7 @@ export const InternalActionDrawer: React.FC<ActionDrawerProps> = observer(
title={field.title} title={field.title}
{...others} {...others}
{...drawerProps} {...drawerProps}
rootStyle={{ rootStyle={rootStyle}
...drawerProps?.style,
...others?.style,
}}
destroyOnClose destroyOnClose
open={visible} open={visible}
onClose={() => setVisible(false, true)} onClose={() => setVisible(false, true)}

View File

@ -11,10 +11,11 @@ import { css } from '@emotion/css';
import { observer, RecursionField, useField, useFieldSchema } from '@formily/react'; import { observer, RecursionField, useField, useFieldSchema } from '@formily/react';
import { Modal, ModalProps } from 'antd'; import { Modal, ModalProps } from 'antd';
import classNames from 'classnames'; import classNames from 'classnames';
import React from 'react'; import React, { useMemo } from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { useToken } from '../../../style'; import { useToken } from '../../../style';
import { ErrorFallback } from '../error-fallback'; import { ErrorFallback } from '../error-fallback';
import { useCurrentPopupContext } from '../page/PagePopups';
import { useActionContext } from './hooks'; import { useActionContext } from './hooks';
import { useSetAriaLabelForModal } from './hooks/useSetAriaLabelForModal'; import { useSetAriaLabelForModal } from './hooks/useSetAriaLabelForModal';
import { ActionDrawerProps, ComposedActionDrawer, OpenSize } from './types'; import { ActionDrawerProps, ComposedActionDrawer, OpenSize } from './types';
@ -33,6 +34,7 @@ const openSizeWidthMap = new Map<OpenSize, string>([
['middle', '60%'], ['middle', '60%'],
['large', '80%'], ['large', '80%'],
]); ]);
export const InternalActionModal: React.FC<ActionDrawerProps<ModalProps>> = observer( export const InternalActionModal: React.FC<ActionDrawerProps<ModalProps>> = observer(
(props) => { (props) => {
const { footerNodeName = 'Action.Modal.Footer', width, ...others } = props; const { footerNodeName = 'Action.Modal.Footer', width, ...others } = props;
@ -47,6 +49,18 @@ export const InternalActionModal: React.FC<ActionDrawerProps<ModalProps>> = obse
} }
return buf; return buf;
}); });
const { hidden } = useCurrentPopupContext();
const styles: any = useMemo(() => {
return {
mask: {
display: hidden ? 'none' : 'block',
},
content: {
display: hidden ? 'none' : 'block',
},
};
}, [hidden]);
const showFooter = !!footerSchema; const showFooter = !!footerSchema;
if (process.env.__E2E__) { if (process.env.__E2E__) {
useSetAriaLabelForModal(visible); useSetAriaLabelForModal(visible);
@ -58,6 +72,7 @@ export const InternalActionModal: React.FC<ActionDrawerProps<ModalProps>> = obse
title={field.title} title={field.title}
{...(others as ModalProps)} {...(others as ModalProps)}
{...modalProps} {...modalProps}
styles={styles}
style={{ style={{
...modalProps?.style, ...modalProps?.style,
...others?.style, ...others?.style,

View File

@ -9,9 +9,17 @@
import { createStyles } from 'antd-style'; import { createStyles } from 'antd-style';
export const useSubPagesStyle = createStyles(({ css, token }: any) => { export const useActionPageStyle = createStyles(({ css, token }: any) => {
return { return {
container: css` container: css`
position: absolute !important;
top: var(--nb-header-height);
left: 0;
right: 0;
bottom: 0;
background-color: ${token.colorBgLayout};
overflow: auto;
.ant-tabs-nav { .ant-tabs-nav {
background: ${token.colorBgContainer}; background: ${token.colorBgContainer};
padding: 0 ${token.paddingPageVertical}px; padding: 0 ${token.paddingPageVertical}px;

View File

@ -7,81 +7,49 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { css } from '@emotion/css'; import { RecursionField, observer, useFieldSchema } from '@formily/react';
import { observer, RecursionField, SchemaExpressionScopeContext, useField, useFieldSchema } from '@formily/react'; import React, { useMemo } from 'react';
import React, { useContext } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useActionContext } from '.'; import { useActionContext } from '.';
import { useCurrentPopupContext } from '../page/PagePopups';
import { useActionPageStyle } from './Action.Page.style';
import { usePopupOrSubpagesContainerDOM } from './hooks/usePopupSlotDOM';
import { ComposedActionDrawer } from './types'; import { ComposedActionDrawer } from './types';
const useScope = (key: string) => {
const scope = useContext(SchemaExpressionScopeContext);
return scope[key];
};
export const ActionPage: ComposedActionDrawer = observer( export const ActionPage: ComposedActionDrawer = observer(
(props: any) => { () => {
const { footerNodeName = 'Action.Page.Footer', ...others } = props; const filedSchema = useFieldSchema();
const { containerRefKey, visible, setVisible } = useActionContext(); const ctx = useActionContext();
const containerRef = useScope(containerRefKey); const { getContainerDOM } = usePopupOrSubpagesContainerDOM();
const schema = useFieldSchema(); const { styles } = useActionPageStyle();
const field = useField(); const { currentLevel } = useCurrentPopupContext();
const footerSchema = schema.reduceProperties((buf, s) => {
if (s['x-component'] === footerNodeName) { const style = useMemo(() => {
return s; return {
} // 20 is the z-index value of the main page
return buf; zIndex: 20 + currentLevel,
}); };
return ( }, [currentLevel]);
<>
{containerRef?.current && if (!ctx.visible) {
visible && return null;
createPortal( }
<div data-testid="action-page" className="nb-action-page">
<RecursionField const actionPageNode = (
basePath={field.address} <div className={styles.container} style={style}>
schema={schema} <RecursionField schema={filedSchema} onlyRenderProperties />
onlyRenderProperties </div>
filterProperties={(s) => {
return s['x-component'] !== footerNodeName;
}}
/>
{footerSchema && (
<div
className={css`
display: flex;
/* justify-content: flex-end; */
/* flex-direction: row-reverse; */
width: 100%;
.ant-btn {
margin-right: 8px;
}
`}
>
<RecursionField
basePath={field.address}
schema={schema}
onlyRenderProperties
filterProperties={(s) => {
return s['x-component'] === footerNodeName;
}}
/>
</div>
)}
</div>,
containerRef?.current,
)}
</>
); );
return createPortal(actionPageNode, getContainerDOM());
}, },
{ displayName: 'ActionPage' }, { displayName: 'ActionPage' },
); );
ActionPage.Footer = observer( ActionPage.Footer = observer(
() => { () => {
const field = useField(); // TODO: Implement in the future if needed
const schema = useFieldSchema(); return null;
return <RecursionField basePath={field.address} schema={schema} onlyRenderProperties />;
}, },
{ displayName: 'ActionPage.Footer' }, { displayName: 'ActionPage.Footer' },
); );

View File

@ -31,7 +31,6 @@ import { useProps } from '../../hooks/useProps';
import { PopupVisibleProvider } from '../page/PagePopups'; import { PopupVisibleProvider } from '../page/PagePopups';
import { usePagePopup } from '../page/pagePopupUtils'; import { usePagePopup } from '../page/pagePopupUtils';
import { usePopupSettings } from '../page/PopupSettingsProvider'; import { usePopupSettings } from '../page/PopupSettingsProvider';
import { useNavigateTOSubPage } from '../page/SubPages';
import ActionContainer from './Action.Container'; import ActionContainer from './Action.Container';
import { ActionDesigner } from './Action.Designer'; import { ActionDesigner } from './Action.Designer';
import { ActionDrawer } from './Action.Drawer'; import { ActionDrawer } from './Action.Drawer';
@ -306,7 +305,6 @@ function RenderButton({
modal, modal,
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { navigateToSubPage } = useNavigateTOSubPage();
const { isPopupVisibleControlledByURL } = usePopupSettings(); const { isPopupVisibleControlledByURL } = usePopupSettings();
const { openPopup } = usePagePopup(); const { openPopup } = usePagePopup();
@ -320,20 +318,18 @@ function RenderButton({
if (!disabled && aclCtx) { if (!disabled && aclCtx) {
const onOk = () => { const onOk = () => {
if (openMode === 'page') {
return navigateToSubPage();
}
if (onClick) { if (onClick) {
onClick(e, () => { onClick(e, () => {
if (refreshDataBlockRequest !== false) { if (refreshDataBlockRequest !== false) {
service?.refresh?.(); service?.refresh?.();
} }
}); });
} else if (isBulkEditAction(fieldSchema) || !isPopupVisibleControlledByURL) { } else if (isBulkEditAction(fieldSchema) || !isPopupVisibleControlledByURL()) {
setVisible(true); setVisible(true);
run?.(); run?.();
} else { } else {
if ( if (
// Currently, only buttons of these types can control the visibility of popups through URLs.
['view', 'update', 'create', 'customize:popup'].includes(fieldSchema['x-action']) && ['view', 'update', 'create', 'customize:popup'].includes(fieldSchema['x-action']) &&
fieldSchema['x-uid'] fieldSchema['x-uid']
) { ) {

View File

@ -11,7 +11,6 @@ import { fireEvent, render, screen, userEvent, waitFor } from '@nocobase/test/cl
import React from 'react'; import React from 'react';
import App1 from '../demos/demo1'; import App1 from '../demos/demo1';
import App2 from '../demos/demo2'; import App2 from '../demos/demo2';
import App3 from '../demos/demo3';
import App4 from '../demos/demo4'; import App4 from '../demos/demo4';
describe('Action', () => { describe('Action', () => {
@ -55,45 +54,6 @@ describe('Action', () => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument(); expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
}); });
}); });
it('openMode', async () => {
const { getByText } = render(<App3 />);
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
expect(document.querySelector('.ant-modal')).not.toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
// drawer
await waitFor(async () => {
await userEvent.click(getByText('Drawer'));
await userEvent.click(getByText('Open'));
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
expect(document.querySelector('.ant-modal')).not.toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
});
// modal
await waitFor(async () => {
await userEvent.click(getByText('Close'));
await userEvent.click(getByText('Modal'));
await userEvent.click(getByText('Open'));
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
expect(document.querySelector('.ant-modal')).toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
});
await waitFor(async () => {
// page
await userEvent.click(getByText('Page'));
await userEvent.click(getByText('Open'));
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
expect(document.querySelector('.ant-modal')).not.toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).toBeInTheDocument();
});
await userEvent.click(getByText('Close'));
// TODO: 点击关闭按钮时应该消失
// expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
});
}); });
describe('Action.Drawer without Action', () => { describe('Action.Drawer without Action', () => {

View File

@ -7,8 +7,11 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import React, { createContext, useEffect, useRef, useState } from 'react'; import { useFieldSchema } from '@formily/react';
import React, { createContext, useEffect, useState } from 'react';
import { useDataBlockRequest } from '../../../data-source'; import { useDataBlockRequest } from '../../../data-source';
import { useCurrentPopupContext } from '../page/PagePopups';
import { getBlockService, storeBlockService } from '../page/pagePopupUtils';
import { ActionContextProps } from './types'; import { ActionContextProps } from './types';
export const ActionContext = createContext<ActionContextProps>({}); export const ActionContext = createContext<ActionContextProps>({});
@ -17,24 +20,19 @@ ActionContext.displayName = 'ActionContext';
export const ActionContextProvider: React.FC<ActionContextProps & { value?: ActionContextProps }> = (props) => { export const ActionContextProvider: React.FC<ActionContextProps & { value?: ActionContextProps }> = (props) => {
const [submitted, setSubmitted] = useState(false); //是否有提交记录 const [submitted, setSubmitted] = useState(false); //是否有提交记录
const { visible } = { ...props, ...props.value } || {}; const { visible } = { ...props, ...props.value } || {};
const isFirstRender = useRef(true); // 使用ref跟踪是否为首次渲染
const service = useDataBlockRequest();
const { setSubmitted: setParentSubmitted } = { ...props, ...props.value }; const { setSubmitted: setParentSubmitted } = { ...props, ...props.value };
const service = useBlockServiceInActionButton();
useEffect(() => { useEffect(() => {
if (visible !== undefined) { if (visible === false && submitted && service) {
if (isFirstRender.current) { service.refresh();
isFirstRender.current = false; setParentSubmitted?.(true); //传递给上一层
} else {
if (visible === false && submitted && service) {
service.refresh();
setParentSubmitted?.(true); //传递给上一层
}
}
} }
return () => { return () => {
setSubmitted(false); setSubmitted(false);
}; };
}, [visible]); }, [visible, service]);
return ( return (
<ActionContext.Provider value={{ ...props, ...props?.value, submitted, setSubmitted }}> <ActionContext.Provider value={{ ...props, ...props?.value, submitted, setSubmitted }}>
@ -42,3 +40,25 @@ export const ActionContextProvider: React.FC<ActionContextProps & { value?: Acti
</ActionContext.Provider> </ActionContext.Provider>
); );
}; };
const useBlockServiceInActionButton = () => {
const { params } = useCurrentPopupContext();
const popupUidWithoutOpened = useFieldSchema()?.['x-uid'];
const service = useDataBlockRequest();
const currentPopupUid = params?.popupuid;
// By using caching, we solve the problem of not being able to obtain the correct service when closing a popup through a URL
useEffect(() => {
// This case refers to when the current button is rendered on a page or in a popup
if (popupUidWithoutOpened && currentPopupUid !== popupUidWithoutOpened) {
storeBlockService(popupUidWithoutOpened, { service });
}
}, [popupUidWithoutOpened, service, currentPopupUid]);
// This case refers to when the current button is closed as a popup (the button's uid is the same as the popup's uid)
if (currentPopupUid === popupUidWithoutOpened) {
return getBlockService(currentPopupUid)?.service || service;
}
return service;
};

View File

@ -22,23 +22,21 @@ export const useActionContext = () => {
return { return {
...ctx, ...ctx,
setVisible(visible: boolean, confirm = false) { setVisible(visible: boolean, confirm = false) {
if (ctx?.openMode !== 'page') { if (!visible) {
if (!visible) { if (confirm && ctx.formValueChanged) {
if (confirm && ctx.formValueChanged) { modal.confirm({
modal.confirm({ title: t('Unsaved changes'),
title: t('Unsaved changes'), content: t("Are you sure you don't want to save?"),
content: t("Are you sure you don't want to save?"), async onOk() {
async onOk() { ctx.setFormValueChanged(false);
ctx.setFormValueChanged(false); ctx.setVisible?.(false);
ctx.setVisible?.(false); },
}, });
});
} else {
ctx?.setVisible?.(false);
}
} else { } else {
ctx?.setVisible?.(visible); ctx?.setVisible?.(false);
} }
} else {
ctx?.setVisible?.(visible);
} }
}, },
}; };

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 { useCallback, useMemo } from 'react';
/**
* Used to get the DOM container for rendering popups or subpages.
* @returns
*/
export const usePopupOrSubpagesContainerDOM = () => {
const containerDOM: HTMLElement = useMemo(
() => document.querySelector('.nb-subpages-slot-without-header-and-side'),
[],
);
const getContainerDOM = useCallback(() => containerDOM, [containerDOM]);
return { getContainerDOM };
};

View File

@ -230,7 +230,7 @@ const HeaderMenu = ({
}, [children, designable]); }, [children, designable]);
const handleSelect = useCallback( const handleSelect = useCallback(
(info: any) => { (info: { item; key; keyPath; domEvent }) => {
const s = schema.properties?.[info.key]; const s = schema.properties?.[info.key];
if (!s) { if (!s) {
@ -274,7 +274,7 @@ const HeaderMenu = ({
<AntdMenu <AntdMenu
{...others} {...others}
className={headerMenuClass} className={headerMenuClass}
onSelect={handleSelect} onClick={handleSelect}
mode={mode === 'mix' ? 'horizontal' : mode} mode={mode === 'mix' ? 'horizontal' : mode}
defaultOpenKeys={defaultOpenKeys} defaultOpenKeys={defaultOpenKeys}
defaultSelectedKeys={defaultSelectedKeys} defaultSelectedKeys={defaultSelectedKeys}
@ -352,7 +352,7 @@ const SideMenu = ({
mode={'inline'} mode={'inline'}
openKeys={openKeys} openKeys={openKeys}
selectedKeys={selectedKeys} selectedKeys={selectedKeys}
onSelect={onSelect} onClick={onSelect}
onOpenChange={setOpenKeys} onOpenChange={setOpenKeys}
className={sideMenuClass} className={sideMenuClass}
items={items as MenuProps['items']} items={items as MenuProps['items']}

View File

@ -0,0 +1,51 @@
/**
* 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 { ArrowLeftOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import React, { useCallback, useMemo } from 'react';
import { useToken } from '../../../style';
import { useCurrentPopupContext } from './PagePopups';
import { usePagePopup } from './pagePopupUtils';
/**
* Used for the back button in subpages
* @returns
*/
export const BackButtonUsedInSubPage = () => {
const { params } = useCurrentPopupContext();
const { closePopup } = usePagePopup();
const { token } = useToken();
// tab item gutter, this is fixed value in antd
const horizontalItemGutter = 32;
const resetStyle = useMemo(() => {
return {
width: 'auto',
height: 'auto',
lineHeight: 1,
padding: token.paddingXS,
marginRight: horizontalItemGutter - token.paddingXS,
};
}, [token.paddingXS]);
const handleClick = useCallback(() => {
closePopup(params.popupuid);
}, [params.popupuid]);
return (
<Button
aria-label="back-button"
type="text"
icon={<ArrowLeftOutlined />}
style={resetStyle}
onClick={handleClick}
/>
);
};

View File

@ -19,6 +19,8 @@ export const useStyles = genStyleHook('nb-page', (token) => {
flex: 1, flex: 1,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
overflow: 'auto',
'&:hover': { '> .general-schema-designer': { display: 'block' } }, '&:hover': { '> .general-schema-designer': { display: 'block' } },
'.ant-page-header': { zIndex: 1, position: 'relative' }, '.ant-page-header': { zIndex: 1, position: 'relative' },
'> .general-schema-designer': { '> .general-schema-designer': {

View File

@ -111,7 +111,7 @@ export const Page = (props) => {
}} }}
onTabClick={(activeKey) => { onTabClick={(activeKey) => {
setLoading(true); setLoading(true);
navigate(`/admin/${pageUid}/tabs/${activeKey}`); navigate(`/admin/${pageUid}/tabs/${activeKey}`, { replace: true });
setTimeout(() => { setTimeout(() => {
setLoading(false); setLoading(false);
}, 50); }, 50);

View File

@ -8,14 +8,18 @@
*/ */
import { ISchema } from '@formily/json-schema'; import { ISchema } from '@formily/json-schema';
import { uid } from '@formily/shared';
import { Result } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import { FC, default as React, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FC, default as React, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Location, useLocation } from 'react-router-dom'; import { Location, useLocation } from 'react-router-dom';
import { useAPIClient } from '../../../api-client'; import { useAPIClient } from '../../../api-client';
import { DataBlockProvider } from '../../../data-source/data-block/DataBlockProvider'; import { DataBlockProvider } from '../../../data-source/data-block/DataBlockProvider';
import { BlockRequestContext } from '../../../data-source/data-block/DataBlockRequestProvider'; import { BlockRequestContext } from '../../../data-source/data-block/DataBlockRequestProvider';
import { SchemaComponent } from '../../core'; import { SchemaComponent } from '../../core';
import { TabsContextProvider } from '../tabs/context'; import { TabsContextProvider } from '../tabs/context';
import { BackButtonUsedInSubPage } from './BackButtonUsedInSubPage';
import { usePopupSettings } from './PopupSettingsProvider'; import { usePopupSettings } from './PopupSettingsProvider';
import { deleteRandomNestedSchemaKey, getRandomNestedSchemaKey } from './nestedSchemaKeyStorage'; import { deleteRandomNestedSchemaKey, getRandomNestedSchemaKey } from './nestedSchemaKeyStorage';
import { PopupParams, getPopupParamsFromPath, getStoredPopupContext, usePagePopup } from './pagePopupUtils'; import { PopupParams, getPopupParamsFromPath, getStoredPopupContext, usePagePopup } from './pagePopupUtils';
@ -32,17 +36,29 @@ interface PopupsVisibleProviderProps {
interface PopupProps { interface PopupProps {
params: PopupParams; params: PopupParams;
context: PopupContext; context: PopupContext;
/**
* When set to true, the current popup will be hidden.
*/
hidden: boolean;
/**
* Used to identify the level of the current popup, where 0 represents the first level.
*/
currentLevel: number;
/**
* Whether the current popup is a subpage.
*/
isSubPage?: boolean;
} }
export const PopupVisibleProviderContext = React.createContext<PopupsVisibleProviderProps>(null); export const PopupVisibleProviderContext = React.createContext<PopupsVisibleProviderProps>(null);
export const PopupParamsProviderContext = React.createContext<PopupProps>(null); export const PopupParamsProviderContext = React.createContext<Omit<PopupProps, 'hidden'>>(null);
// Provides the context information for all levels of popups.
export const AllPopupsPropsProviderContext = React.createContext<PopupProps[]>(null);
PopupVisibleProviderContext.displayName = 'PopupVisibleProviderContext'; PopupVisibleProviderContext.displayName = 'PopupVisibleProviderContext';
PopupParamsProviderContext.displayName = 'PopupParamsProviderContext'; PopupParamsProviderContext.displayName = 'PopupParamsProviderContext';
AllPopupsPropsProviderContext.displayName = 'AllPopupsPropsProviderContext';
export const usePopupContextAndParams = () => {
const context = React.useContext(PopupParamsProviderContext);
return (context || {}) as PopupProps;
};
/** /**
* The difference between this component and ActionContextProvider is that * The difference between this component and ActionContextProvider is that
@ -51,17 +67,21 @@ export const usePopupContextAndParams = () => {
* @returns * @returns
*/ */
export const PopupVisibleProvider: FC<PopupsVisibleProviderProps> = ({ children, visible, setVisible }) => { export const PopupVisibleProvider: FC<PopupsVisibleProviderProps> = ({ children, visible, setVisible }) => {
return ( const value = useMemo(() => {
<PopupVisibleProviderContext.Provider value={{ visible, setVisible }}> return { visible, setVisible };
{children} }, [visible, setVisible]);
</PopupVisibleProviderContext.Provider>
); return <PopupVisibleProviderContext.Provider value={value}>{children}</PopupVisibleProviderContext.Provider>;
}; };
const PopupParamsProvider: FC<PopupProps> = (props) => { const PopupParamsProvider: FC<Omit<PopupProps, 'hidden'>> = (props) => {
const value = useMemo(() => { const value = useMemo(() => {
return { params: props.params, context: props.context }; return {
}, [props.params, props.context]); params: props.params,
context: props.context,
currentLevel: props.currentLevel,
};
}, [props.params, props.context, props.currentLevel]);
return <PopupParamsProviderContext.Provider value={value}>{props.children}</PopupParamsProviderContext.Provider>; return <PopupParamsProviderContext.Provider value={value}>{props.children}</PopupParamsProviderContext.Provider>;
}; };
@ -74,19 +94,28 @@ const PopupTabsPropsProvider: FC<{ params: PopupParams }> = ({ children, params
[changeTab], [changeTab],
); );
const { isPopupVisibleControlledByURL } = usePopupSettings(); const { isPopupVisibleControlledByURL } = usePopupSettings();
const { isSubPage } = useCurrentPopupContext();
const tabBarExtraContent = useMemo(() => (isSubPage ? <BackButtonUsedInSubPage /> : null), [isSubPage]);
if (!isPopupVisibleControlledByURL) { if (!isPopupVisibleControlledByURL()) {
return <>{children}</>; return <>{children}</>;
} }
return ( return (
<TabsContextProvider activeKey={params.tab} onTabClick={onTabClick}> <TabsContextProvider activeKey={params.tab} onTabClick={onTabClick} tabBarExtraContent={tabBarExtraContent}>
{children} {children}
</TabsContextProvider> </TabsContextProvider>
); );
}; };
const PagePopupsItemProvider: FC<{ params: PopupParams; context: PopupContext }> = ({ params, context, children }) => { const PagePopupsItemProvider: FC<{
params: PopupParams;
context: PopupContext;
/**
* Used to identify the level of the current popup, where 0 represents the first level.
*/
currentLevel: number;
}> = ({ params, context, currentLevel, children }) => {
const { closePopup } = usePagePopup(); const { closePopup } = usePagePopup();
const [visible, _setVisible] = useState(true); const [visible, _setVisible] = useState(true);
const setVisible = (visible: boolean) => { const setVisible = (visible: boolean) => {
@ -95,7 +124,7 @@ const PagePopupsItemProvider: FC<{ params: PopupParams; context: PopupContext }>
if (process.env.__E2E__) { if (process.env.__E2E__) {
setTimeout(() => { setTimeout(() => {
closePopup(); closePopup(params.popupuid);
// Deleting here ensures that the next time the same popup is opened, it will generate another random key. // Deleting here ensures that the next time the same popup is opened, it will generate another random key.
deleteRandomNestedSchemaKey(params.popupuid); deleteRandomNestedSchemaKey(params.popupuid);
}); });
@ -104,7 +133,7 @@ const PagePopupsItemProvider: FC<{ params: PopupParams; context: PopupContext }>
// Leave some time to refresh the block data // Leave some time to refresh the block data
setTimeout(() => { setTimeout(() => {
closePopup(); closePopup(params.popupuid);
// Deleting here ensures that the next time the same popup is opened, it will generate another random key. // Deleting here ensures that the next time the same popup is opened, it will generate another random key.
deleteRandomNestedSchemaKey(params.popupuid); deleteRandomNestedSchemaKey(params.popupuid);
}, 300); }, 300);
@ -116,8 +145,16 @@ const PagePopupsItemProvider: FC<{ params: PopupParams; context: PopupContext }>
context = storedContext; context = storedContext;
} }
if (_.isEmpty(context)) {
return (
<PopupVisibleProvider visible={visible} setVisible={setVisible}>
<div style={{ display: 'none' }}>{children}</div>
</PopupVisibleProvider>
);
}
return ( return (
<PopupParamsProvider params={params} context={context}> <PopupParamsProvider params={params} context={context} currentLevel={currentLevel}>
<PopupVisibleProvider visible={visible} setVisible={setVisible}> <PopupVisibleProvider visible={visible} setVisible={setVisible}>
<DataBlockProvider <DataBlockProvider
dataSource={context.dataSource} dataSource={context.dataSource}
@ -149,7 +186,7 @@ const PagePopupsItemProvider: FC<{ params: PopupParams; context: PopupContext }>
* @param parentSchema * @param parentSchema
*/ */
export const insertChildToParentSchema = (childSchema: ISchema, props: PopupProps, parentSchema: ISchema) => { export const insertChildToParentSchema = (childSchema: ISchema, props: PopupProps, parentSchema: ISchema) => {
const { params, context } = props; const { params, context, currentLevel } = props;
const componentSchema = { const componentSchema = {
type: 'void', type: 'void',
@ -157,6 +194,7 @@ export const insertChildToParentSchema = (childSchema: ISchema, props: PopupProp
'x-component-props': { 'x-component-props': {
params, params,
context, context,
currentLevel,
}, },
properties: { properties: {
popupAction: childSchema, popupAction: childSchema,
@ -188,15 +226,32 @@ export const PagePopups = (props: { paramsList?: PopupParams[] }) => {
); );
const schemas = await Promise.all(waitList); const schemas = await Promise.all(waitList);
const clonedSchemas = schemas.map((schema) => { const clonedSchemas = schemas.map((schema) => {
if (_.isEmpty(schema)) {
return get404Schema();
}
const result = _.cloneDeep(_.omit(schema, 'parent')); const result = _.cloneDeep(_.omit(schema, 'parent'));
result['x-read-pretty'] = true; result['x-read-pretty'] = true;
return result; return result;
}); });
popupPropsRef.current = clonedSchemas.map((schema, index) => { popupPropsRef.current = clonedSchemas.map((schema, index, items) => {
const schemaContext = getPopupContextFromActionOrAssociationFieldSchema(schema); const schemaContext = getPopupContextFromActionOrAssociationFieldSchema(schema);
let hidden = false;
for (let i = index + 1; i < items.length; i++) {
if (isSubPageSchema(items[i])) {
// Because the popup has a higher z-index, if the popup is not hidden, there will be an issue where the subpage is displayed below the popup.
hidden = true;
break;
}
}
return { return {
params: popupParams[index], params: popupParams[index],
context: schemaContext, context: schemaContext,
hidden,
currentLevel: index + 1,
isSubPage: isSubPageSchema(schema),
}; };
}); });
const rootSchema = clonedSchemas[0]; const rootSchema = clonedSchemas[0];
@ -215,9 +270,15 @@ export const PagePopups = (props: { paramsList?: PopupParams[] }) => {
} }
return ( return (
<PagePopupsItemProvider params={popupPropsRef.current[0].params} context={popupPropsRef.current[0].context}> <AllPopupsPropsProviderContext.Provider value={popupPropsRef.current}>
<SchemaComponent components={components} schema={rootSchema} onlyRenderProperties />; <PagePopupsItemProvider
</PagePopupsItemProvider> params={popupPropsRef.current[0].params}
context={popupPropsRef.current[0].context}
currentLevel={1}
>
<SchemaComponent components={components} schema={rootSchema} onlyRenderProperties />;
</PagePopupsItemProvider>
</AllPopupsPropsProviderContext.Provider>
); );
}; };
@ -225,10 +286,15 @@ export const useRequestSchema = () => {
const api = useAPIClient(); const api = useAPIClient();
const requestSchema = useCallback(async (uid: string) => { const requestSchema = useCallback(async (uid: string) => {
const data = await api.request({ try {
url: `/uiSchemas:getJsonSchema/${uid}`, const data = await api.request({
}); url: `/uiSchemas:getJsonSchema/${uid}`,
return data.data?.data as ISchema; });
return data.data?.data as ISchema;
} catch (error) {
console.error(error);
return null;
}
}, []); }, []);
return { requestSchema }; return { requestSchema };
@ -243,3 +309,101 @@ export const getPopupPath = (location: Location) => {
const [, ...popupsPath] = location.pathname.split('/popups/'); const [, ...popupsPath] = location.pathname.split('/popups/');
return popupsPath.join('/popups/'); return popupsPath.join('/popups/');
}; };
function isSubPageSchema(schema: ISchema) {
const openMode = _.get(schema, 'x-component-props.openMode');
return openMode === 'page';
}
export const useCurrentPopupContext = (): PopupProps => {
const { currentLevel } = React.useContext(PopupParamsProviderContext) || ({} as Omit<PopupProps, 'hidden'>);
const allPopupsProps = React.useContext(AllPopupsPropsProviderContext);
return allPopupsProps?.[currentLevel - 1] || ({} as PopupProps);
};
/**
* Used to display a message to the user indicating that the popup schema has been deleted
*/
function get404Schema() {
return {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("Error message") }}',
'x-action': 'view',
'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'actionSettings:view',
'x-component': 'Action.Link',
'x-component-props': {
openMode: 'drawer',
},
'x-action-context': {},
'x-decorator': 'ACLActionProvider',
'x-designer-props': {
linkageAction: true,
},
properties: {
drawer: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: 'Error message',
'x-component': 'Action.Container',
'x-component-props': {
className: 'nb-action-popup',
},
properties: {
tabs: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Tabs',
'x-component-props': {},
'x-initializer': 'popup:addTab',
properties: {
tab1: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '404',
'x-component': 'Tabs.TabPane',
'x-designer': 'Tabs.Designer',
'x-component-props': {},
properties: {
grid: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': function Com() {
const { t } = useTranslation();
return (
<Result status="404" title="404" subTitle={t('Sorry, the page you visited does not exist.')} />
);
},
'x-uid': uid(),
'x-async': false,
'x-index': 1,
},
},
'x-uid': uid(),
'x-async': false,
'x-index': 1,
},
},
'x-uid': uid(),
'x-async': false,
'x-index': 1,
},
},
'x-uid': uid(),
'x-async': false,
'x-index': 1,
},
},
name: uid(),
'x-uid': uid(),
'x-async': false,
'x-index': 2,
'x-read-pretty': true,
};
}

View File

@ -7,39 +7,18 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import React, { FC, useMemo } from 'react'; import { useCallback } from 'react';
interface PopupSettings {
/**
* @default true
*/
isPopupVisibleControlledByURL: boolean;
}
const PopupSettingsContext = React.createContext<PopupSettings>(null);
/**
* Provider component for the popup settings.
* @param props - The popup settings.
*/
export const PopupSettingsProvider: FC<PopupSettings> = (props) => {
const { isPopupVisibleControlledByURL } = props;
const value = useMemo(() => {
return { isPopupVisibleControlledByURL };
}, [isPopupVisibleControlledByURL]);
return <PopupSettingsContext.Provider value={value}>{props.children}</PopupSettingsContext.Provider>;
};
/** /**
* Hook for accessing the popup settings. * Hook for accessing the popup settings.
* @returns The popup settings. * @returns The popup settings.
*/ */
export const usePopupSettings = () => { export const usePopupSettings = () => {
return ( const isPopupVisibleControlledByURL = useCallback(() => {
React.useContext(PopupSettingsContext) || { const pathname = window.location.pathname;
isPopupVisibleControlledByURL: true, const hash = window.location.hash;
} return pathname?.includes('/admin/') && !hash?.includes('/mobile');
); }, []);
return { isPopupVisibleControlledByURL };
}; };

View File

@ -1,288 +0,0 @@
/**
* 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, RecursionField, useFieldSchema } from '@formily/react';
import _ from 'lodash';
import React, { FC, useCallback, useContext, useEffect, useState } from 'react';
import { Location, useLocation } from 'react-router-dom';
import { useNavigateNoUpdate } from '../../../application/CustomRouterContextProvider';
import {
useCollectionParentRecord,
useCollectionRecord,
useCollectionRecordData,
} from '../../../data-source/collection-record/CollectionRecordProvider';
import { useAssociationName } from '../../../data-source/collection/AssociationProvider';
import { useCollectionManager } from '../../../data-source/collection/CollectionManagerProvider';
import { useCollection } from '../../../data-source/collection/CollectionProvider';
import { DataBlockProvider } from '../../../data-source/data-block/DataBlockProvider';
import { useDataBlockRequest } from '../../../data-source/data-block/DataBlockRequestProvider';
import { useDataSourceKey } from '../../../data-source/data-source/DataSourceProvider';
import { TreeRecordProvider, useTreeParentRecord } from '../../../modules/blocks/data-blocks/table/TreeRecordProvider';
import {
VariablePopupRecordProvider,
useCurrentPopupRecord,
} from '../../../modules/variable/variablesProvider/VariablePopupRecordProvider';
import { ActionContext } from '../action/context';
import { TabsContextProvider } from '../tabs/context';
import { PagePopups, useRequestSchema } from './PagePopups';
import { usePopupSettings } from './PopupSettingsProvider';
import { useSubPagesStyle } from './SubPages.style';
import {
PopupParams,
decodePathValue,
encodePathValue,
getPopupParamsFromPath,
getStoredPopupContext,
storePopupContext,
withSearchParams,
} from './pagePopupUtils';
import {
SubPageContext,
getPopupContextFromActionOrAssociationFieldSchema,
usePopupContextInActionOrAssociationField,
} from './usePopupContextInActionOrAssociationField';
export interface SubPageParams extends Omit<PopupParams, 'popupuid'> {
/** sub page uid */
subpageuid: string;
}
const SubPageTabsPropsProvider: FC<{ params: SubPageParams }> = (props) => {
const navigate = useNavigateNoUpdate();
const onTabClick = useCallback((key: string) => {
let pathname = window.location.pathname.split('/tab/')[0];
if (pathname.endsWith('/')) {
pathname = pathname.slice(0, -1);
}
navigate(`${pathname}/tab/${key}`);
}, []);
return (
<TabsContextProvider activeKey={props.params.tab} onTabClick={onTabClick}>
{props.children}
</TabsContextProvider>
);
};
const TreeRecordProviderInSubPage: FC = (props) => {
const recordData = useCollectionRecordData();
return <TreeRecordProvider parent={recordData}>{props.children}</TreeRecordProvider>;
};
const SubPageProvider: FC<{ params: SubPageParams; context: SubPageContext | undefined; actionType: string }> = (
props,
) => {
const { params, context } = props;
if (!context) {
return null;
}
const nodes = {
addChild: <TreeRecordProviderInSubPage>{props.children}</TreeRecordProviderInSubPage>,
'': <VariablePopupRecordProvider>{props.children}</VariablePopupRecordProvider>,
};
const commonElements = (
<DataBlockProvider
dataSource={context.dataSource}
collection={context.collection}
association={context.association}
sourceId={params.sourceid}
filterByTk={params.filterbytk}
action="get"
>
<SubPageTabsPropsProvider params={props.params}>{nodes[props.actionType]}</SubPageTabsPropsProvider>
</DataBlockProvider>
);
if (context.parentPopupRecord) {
return (
<DataBlockProvider
dataSource={context.dataSource}
collection={context.parentPopupRecord.collection}
filterByTk={context.parentPopupRecord.filterByTk}
action="get"
>
<VariablePopupRecordProvider>{commonElements}</VariablePopupRecordProvider>
</DataBlockProvider>
);
}
return commonElements;
};
export const SubPage = () => {
const location = useLocation();
const { subPageParams, popupParams } = getSubPageParamsAndPopupsParams(getSubPagePath(location));
const { styles } = useSubPagesStyle();
const { requestSchema } = useRequestSchema();
const [actionSchema, setActionSchema] = useState(null);
useEffect(() => {
const run = async () => {
const stored = getStoredPopupContext(subPageParams.subpageuid);
if (stored) {
return setActionSchema(stored.schema);
}
const schema = await requestSchema(subPageParams.subpageuid);
setActionSchema(schema);
};
run();
}, [subPageParams.subpageuid]);
// When the URL changes, this component may be re-rendered, because at this time the Schema is still old, so there may be some issues, so here is a judgment.
if (!actionSchema || actionSchema['x-uid'] !== subPageParams.subpageuid) {
return null;
}
const subPageSchema = Object.values(actionSchema.properties)[0] as ISchema;
const context = getPopupContextFromActionOrAssociationFieldSchema(actionSchema) as SubPageContext;
const addChild = actionSchema?.['x-component-props']?.addChild;
return (
<div className={styles.container}>
<SubPageProvider params={subPageParams} context={context} actionType={addChild ? 'addChild' : ''}>
<RecursionField schema={subPageSchema} onlyRenderProperties />
{_.isEmpty(popupParams) ? null : <PagePopups paramsList={popupParams} />}
</SubPageProvider>
</div>
);
};
export const getSubPagePathFromParams = (params: SubPageParams) => {
const { subpageuid, tab, filterbytk, sourceid } = params;
const popupPath = [
subpageuid,
filterbytk && 'filterbytk',
filterbytk,
sourceid && 'sourceid',
sourceid,
tab && 'tab',
tab,
].filter(Boolean);
return `/subpages/${popupPath.map((item) => encodePathValue(item)).join('/')}`;
};
export const getSubPageParamsFromPath = _.memoize((path: string) => {
const [subPageUid, ...subPageParams] = path.split('/').filter(Boolean);
const result = {};
for (let i = 0; i < subPageParams.length; i += 2) {
result[subPageParams[i]] = decodePathValue(subPageParams[i + 1]);
}
return {
subpageuid: subPageUid,
...result,
} as SubPageParams;
});
export const useNavigateTOSubPage = () => {
const navigate = useNavigateNoUpdate();
const fieldSchema = useFieldSchema();
const dataSourceKey = useDataSourceKey();
const record = useCollectionRecord();
const parentRecord = useCollectionParentRecord();
const collection = useCollection();
const cm = useCollectionManager();
const association = useAssociationName();
const { updatePopupContext } = usePopupContextInActionOrAssociationField();
const { value: parentPopupRecordData, collection: parentPopupRecordCollection } = useCurrentPopupRecord() || {};
const { isPopupVisibleControlledByURL } = usePopupSettings();
const { setVisible: setVisibleFromAction } = useContext(ActionContext);
const service = useDataBlockRequest();
const treeParentRecord = useTreeParentRecord();
const navigateToSubPage = useCallback(() => {
if (!fieldSchema['x-uid']) {
return;
}
if (!isPopupVisibleControlledByURL) {
return setVisibleFromAction?.(true);
}
const filterByTK = cm.getFilterByTK(association || collection, record?.data || treeParentRecord);
const sourceId = parentRecord?.data?.[cm.getSourceKeyByAssociation(association)];
const params: SubPageParams = {
subpageuid: fieldSchema['x-uid'],
filterbytk: filterByTK,
sourceid: sourceId,
};
storePopupContext(fieldSchema['x-uid'], {
schema: fieldSchema,
record,
parentRecord,
service,
dataSource: dataSourceKey,
collection: collection.name,
association,
sourceId,
parentPopupRecord: parentPopupRecordData
? {
// TODO: 这里应该需要 association 的 值
collection: parentPopupRecordCollection?.name,
filterByTk: cm.getFilterByTK(parentPopupRecordCollection, parentPopupRecordData),
}
: undefined,
});
updatePopupContext({
dataSource: dataSourceKey,
collection: association ? undefined : collection.name,
association: association,
parentPopupRecord: parentPopupRecordData
? {
collection: parentPopupRecordCollection?.name,
filterByTk: cm.getFilterByTK(parentPopupRecordCollection, parentPopupRecordData),
}
: undefined,
});
const pathname = getSubPagePathFromParams(params);
navigate(withSearchParams(`/admin${pathname}`));
}, [
fieldSchema,
navigate,
dataSourceKey,
record,
parentRecord,
collection,
cm,
association,
parentPopupRecordData,
isPopupVisibleControlledByURL,
service,
]);
return { navigateToSubPage };
};
export const getSubPageParamsAndPopupsParams = _.memoize((path: string) => {
const [pagePath, ...popupsPath] = path.split('/popups/');
const subPageParams = getSubPageParamsFromPath(pagePath);
const popupParams = getPopupParamsFromPath(popupsPath.join('/popups/'));
return { subPageParams, popupParams };
});
/**
* The reason why we don't use the decoded data returned by useParams here is because we need the raw values.
* @param location
* @returns
*/
export function getSubPagePath(location: Location) {
const [, subPagePath] = location.pathname.split('/admin/subpages/');
return subPagePath || '';
}

View File

@ -1,122 +0,0 @@
/**
* 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 {
getSubPageParamsAndPopupsParams,
getSubPageParamsFromPath,
getSubPagePath,
getSubPagePathFromParams,
} from '../SubPages';
describe('getSubPagePathFromParams', () => {
it('should generate the correct subpage path', () => {
const params = {
subpageuid: 'subPage1',
filterbytk: 'filterbytk1',
tab: 'tab1',
sourceid: 'sourceid1',
};
const expectedPath = '/subpages/subPage1/filterbytk/filterbytk1/sourceid/sourceid1/tab/tab1';
expect(getSubPagePathFromParams(params)).toBe(expectedPath);
});
it('should generate the correct subpage path without optional parameters', () => {
const params = {
subpageuid: 'subPage1',
filterbytk: 'filterbytk1',
};
const expectedPath = '/subpages/subPage1/filterbytk/filterbytk1';
expect(getSubPagePathFromParams(params)).toBe(expectedPath);
});
it('when exist popups in path', () => {
const params = {
subpageuid: 'subPage1',
filterbytk: 'popups',
tab: 'popups',
};
const expectedPath = `/subpages/subPage1/filterbytk/${window.btoa('popups')}/tab/${window.btoa('popups')}`;
expect(getSubPagePathFromParams(params)).toBe(expectedPath);
});
});
describe('getSubPageParamsAndPopupsParams', () => {
it('should return the correct subPageParams and popupParams', () => {
const path =
'subPage1/datasource/datasource1/filterbytk/filterbytk1/popups/popupuid1/key1/value1/popups/popupuid2/key2/value2';
const expectedSubPageParams = {
subpageuid: 'subPage1',
datasource: 'datasource1',
filterbytk: 'filterbytk1',
};
const expectedPopupParams = [
{ popupuid: 'popupuid1', key1: 'value1' },
{ popupuid: 'popupuid2', key2: 'value2' },
];
expect(getSubPageParamsAndPopupsParams(path)).toEqual({
subPageParams: expectedSubPageParams,
popupParams: expectedPopupParams,
});
});
it('should return the correct subPageParams and empty popupParams', () => {
const path = 'subPage1/datasource/datasource1/filterbytk/filterbytk1';
const expectedSubPageParams = {
subpageuid: 'subPage1',
datasource: 'datasource1',
filterbytk: 'filterbytk1',
};
const expectedPopupParams: string[] = [];
expect(getSubPageParamsAndPopupsParams(path)).toEqual({
subPageParams: expectedSubPageParams,
popupParams: expectedPopupParams,
});
});
});
describe('getSubPageParamsFromPath', () => {
it('should return the correct subPageParams from path without popups', () => {
const path = 'subPage1/datasource/datasource1/filterbytk/filterbytk1/sourceid/sourceid1';
const expectedSubPageParams = {
subpageuid: 'subPage1',
datasource: 'datasource1',
filterbytk: 'filterbytk1',
sourceid: 'sourceid1',
};
expect(getSubPageParamsFromPath(path)).toEqual(expectedSubPageParams);
});
it('when exist popups in path', () => {
const path = `subPage1/datasource/datasource1/filterbytk/${window.btoa('popups')}`;
const expectedSubPageParams = {
subpageuid: 'subPage1',
datasource: 'datasource1',
filterbytk: 'popups',
};
expect(getSubPageParamsFromPath(path)).toEqual(expectedSubPageParams);
});
});
describe('getSubPagePath', () => {
it('should return the subpage path', () => {
const location: any = {
pathname: '/admin/subpages/subPage1/filterbytk/filterbytk1/tab/tab1',
};
const expectedPath = 'subPage1/filterbytk/filterbytk1/tab/tab1';
expect(getSubPagePath(location)).toBe(expectedPath);
});
it('should return an empty string if subpage path is not found', () => {
const location: any = {
pathname: '/admin',
};
const expectedPath = '';
expect(getSubPagePath(location)).toBe(expectedPath);
});
});

View File

@ -108,12 +108,12 @@ describe('removeLastPopupPath', () => {
const path1 = '/admin/page/popups/popupUid/popups/popupUid2'; const path1 = '/admin/page/popups/popupUid/popups/popupUid2';
const result1 = removeLastPopupPath(path1); const result1 = removeLastPopupPath(path1);
expect(result1).toBe('/admin/page/popups/popupUid/'); expect(result1).toBe('/admin/page/popups/popupUid');
const path2 = '/admin/page/popups/popupUid'; const path2 = '/admin/page/popups/popupUid';
const result2 = removeLastPopupPath(path2); const result2 = removeLastPopupPath(path2);
expect(result2).toBe('/admin/page/'); expect(result2).toBe('/admin/page');
}); });
it('should handle paths without popups', () => { it('should handle paths without popups', () => {

View File

@ -12,4 +12,3 @@ export * from './FixedBlockDesignerItem';
export * from './Page'; export * from './Page';
export * from './Page.Settings'; export * from './Page.Settings';
export * from './PageTab.Settings'; export * from './PageTab.Settings';
export { PopupSettingsProvider } from './PopupSettingsProvider';

View File

@ -21,9 +21,8 @@ import {
useDataBlockRequest, useDataBlockRequest,
useDataSourceKey, useDataSourceKey,
} from '../../../data-source'; } from '../../../data-source';
import { useCurrentPopupRecord } from '../../../modules/variable/variablesProvider/VariablePopupRecordProvider';
import { ActionContext } from '../action/context'; import { ActionContext } from '../action/context';
import { PopupVisibleProviderContext, usePopupContextAndParams } from './PagePopups'; import { PopupVisibleProviderContext, useCurrentPopupContext } from './PagePopups';
import { usePopupSettings } from './PopupSettingsProvider'; import { usePopupSettings } from './PopupSettingsProvider';
import { PopupContext, usePopupContextInActionOrAssociationField } from './usePopupContextInActionOrAssociationField'; import { PopupContext, usePopupContextInActionOrAssociationField } from './usePopupContextInActionOrAssociationField';
@ -53,10 +52,30 @@ export const getStoredPopupContext = (popupUid: string) => {
return popupsContextStorage[popupUid]; return popupsContextStorage[popupUid];
}; };
/**
* Used to store the context of the current popup when a button is clicked.
* @param popupUid
* @param params
*/
export const storePopupContext = (popupUid: string, params: PopupContextStorage) => { export const storePopupContext = (popupUid: string, params: PopupContextStorage) => {
popupsContextStorage[popupUid] = params; popupsContextStorage[popupUid] = params;
}; };
const blockServicesStorage: Record<string, { service: any }> = {};
export const getBlockService = (popupUid: string) => {
return blockServicesStorage[popupUid];
};
/**
* Used to store the service of the block when rendering the button.
* @param popupUid
* @param value
*/
export const storeBlockService = (popupUid: string, value: { service: any }) => {
blockServicesStorage[popupUid] = value;
};
export const getPopupParamsFromPath = _.memoize((path: string) => { export const getPopupParamsFromPath = _.memoize((path: string) => {
const popupPaths = path.split('/popups/'); const popupPaths = path.split('/popups/');
return popupPaths.filter(Boolean).map((popupPath) => { return popupPaths.filter(Boolean).map((popupPath) => {
@ -100,17 +119,17 @@ export const usePagePopup = () => {
const cm = useCollectionManager(); const cm = useCollectionManager();
const association = useAssociationName(); const association = useAssociationName();
const { visible, setVisible } = useContext(PopupVisibleProviderContext) || { visible: false, setVisible: () => {} }; const { visible, setVisible } = useContext(PopupVisibleProviderContext) || { visible: false, setVisible: () => {} };
const { params: popupParams } = usePopupContextAndParams(); const { params: popupParams } = useCurrentPopupContext();
const service = useDataBlockRequest(); const service = useDataBlockRequest();
const { isPopupVisibleControlledByURL } = usePopupSettings(); const { isPopupVisibleControlledByURL } = usePopupSettings();
const { setVisible: setVisibleFromAction } = useContext(ActionContext); const { setVisible: setVisibleFromAction } = useContext(ActionContext);
const { updatePopupContext } = usePopupContextInActionOrAssociationField(); const { updatePopupContext } = usePopupContextInActionOrAssociationField();
const { value: parentPopupRecordData, collection: parentPopupRecordCollection } = useCurrentPopupRecord() || {};
const getSourceId = useCallback( const getSourceId = useCallback(
(_parentRecordData?: Record<string, any>) => (_parentRecordData?: Record<string, any>) =>
(_parentRecordData || parentRecord?.data)?.[cm.getSourceKeyByAssociation(association)], (_parentRecordData || parentRecord?.data)?.[cm.getSourceKeyByAssociation(association)],
[parentRecord, association], [parentRecord, association],
); );
const currentPopupUidWithoutOpened = fieldSchema['x-uid'];
const getNewPathname = useCallback( const getNewPathname = useCallback(
({ ({
@ -140,17 +159,10 @@ export const usePagePopup = () => {
dataSource: dataSourceKey, dataSource: dataSourceKey,
collection: association ? undefined : collection.name, collection: association ? undefined : collection.name,
association, association,
parentPopupRecord: !_.isEmpty(parentPopupRecordData)
? {
// TODO: 这里应该需要 association 的 值
collection: parentPopupRecordCollection?.name,
filterByTk: cm.getFilterByTK(parentPopupRecordCollection, parentPopupRecordData),
}
: undefined,
}; };
return _.omitBy(context, _.isNil) as PopupContext; return _.omitBy(context, _.isNil) as PopupContext;
}, [dataSourceKey, collection, association, parentPopupRecordData, parentPopupRecordCollection, cm]); }, [dataSourceKey, collection, association]);
const openPopup = useCallback( const openPopup = useCallback(
({ ({
@ -160,20 +172,20 @@ export const usePagePopup = () => {
recordData?: Record<string, any>; recordData?: Record<string, any>;
parentRecordData?: Record<string, any>; parentRecordData?: Record<string, any>;
} = {}) => { } = {}) => {
if (!isPopupVisibleControlledByURL) { if (!isPopupVisibleControlledByURL()) {
return setVisibleFromAction?.(true); return setVisibleFromAction?.(true);
} }
const sourceId = getSourceId(parentRecordData); const sourceId = getSourceId(parentRecordData);
recordData = recordData || record?.data; recordData = recordData || record?.data;
const pathname = getNewPathname({ popupUid: fieldSchema['x-uid'], recordData, sourceId }); const pathname = getNewPathname({ popupUid: currentPopupUidWithoutOpened, recordData, sourceId });
let url = location.pathname; let url = location.pathname;
if (_.last(url) === '/') { if (_.last(url) === '/') {
url = url.slice(0, -1); url = url.slice(0, -1);
} }
storePopupContext(fieldSchema['x-uid'], { storePopupContext(currentPopupUidWithoutOpened, {
schema: fieldSchema, schema: fieldSchema,
record: new CollectionRecord({ isNew: false, data: recordData }), record: new CollectionRecord({ isNew: false, data: recordData }),
parentRecord: parentRecordData ? new CollectionRecord({ isNew: false, data: parentRecordData }) : parentRecord, parentRecord: parentRecordData ? new CollectionRecord({ isNew: false, data: parentRecordData }) : parentRecord,
@ -182,13 +194,6 @@ export const usePagePopup = () => {
collection: collection.name, collection: collection.name,
association, association,
sourceId, sourceId,
parentPopupRecord: parentPopupRecordData
? {
// TODO: 这里应该需要 association 的 值
collection: parentPopupRecordCollection?.name,
filterByTk: cm.getFilterByTK(parentPopupRecordCollection, parentPopupRecordData),
}
: undefined,
}); });
updatePopupContext(getPopupContext()); updatePopupContext(getPopupContext());
@ -208,19 +213,29 @@ export const usePagePopup = () => {
service, service,
location, location,
isPopupVisibleControlledByURL, isPopupVisibleControlledByURL,
parentPopupRecordData,
getSourceId, getSourceId,
getPopupContext, getPopupContext,
currentPopupUidWithoutOpened,
], ],
); );
const closePopup = useCallback(() => { const closePopup = useCallback(
if (!isPopupVisibleControlledByURL) { (currentPopupUid: string) => {
return setVisibleFromAction?.(false); if (!isPopupVisibleControlledByURL()) {
} return setVisibleFromAction?.(false);
}
navigate(withSearchParams(removeLastPopupPath(location.pathname))); // 1. If there is a value in the cache, it means that the current popup was opened by manual click, so we can simply return to the previous record;
}, [navigate, location, isPopupVisibleControlledByURL]); // 2. If there is no value in the cache, it means that the current popup was opened by clicking the URL elsewhere, and since there is no history,
// we need to construct the URL of the previous record to return to;
if (getStoredPopupContext(currentPopupUid)) {
navigate(-1);
} else {
navigate(withSearchParams(removeLastPopupPath(location.pathname)), { replace: true });
}
},
[navigate, location, isPopupVisibleControlledByURL],
);
const changeTab = useCallback( const changeTab = useCallback(
(key: string) => { (key: string) => {
@ -235,7 +250,9 @@ export const usePagePopup = () => {
if (_.last(url) === '/') { if (_.last(url) === '/') {
url = url.slice(0, -1); url = url.slice(0, -1);
} }
navigate(`${url}${pathname}`); navigate(`${url}${pathname}`, {
replace: true,
});
}, },
[getNewPathname, navigate, popupParams?.popupuid, record?.data, location], [getNewPathname, navigate, popupParams?.popupuid, record?.data, location],
); );
@ -257,12 +274,15 @@ export const usePagePopup = () => {
}; };
}; };
// e.g. /popups/popupUid/popups/popupUid2 -> /popups/popupUid // e.g. /popups/popupUid/popups/popupUid2 -> /popups/popupUid/
export function removeLastPopupPath(path: string) { export function removeLastPopupPath(path: string) {
if (!path.includes('popups')) { if (!path.includes('popups')) {
return path; return path;
} }
return path.split('popups').slice(0, -1).join('popups');
const result = path.split('popups').slice(0, -1).join('popups');
return result.endsWith('/') ? result.slice(0, -1) : result;
} }
export function withSearchParams(path: string) { export function withSearchParams(path: string) {

View File

@ -16,25 +16,6 @@ export interface PopupContext {
dataSource: string; dataSource: string;
collection?: string; collection?: string;
association?: string; association?: string;
/**
* Context for the parent popup record variable
*/
parentPopupRecord?: {
/** collection name */
collection: string;
filterByTk: string;
};
}
export interface SubPageContext extends PopupContext {
/**
* Context for the parent popup record variable
*/
parentPopupRecord: {
/** collection name */
collection: string;
filterByTk: string;
};
} }
export const CONTEXT_SCHEMA_KEY = 'x-action-context'; export const CONTEXT_SCHEMA_KEY = 'x-action-context';

View File

@ -12,13 +12,13 @@ import { observer, RecursionField, useField, useFieldSchema } from '@formily/rea
import { Tabs as AntdTabs, TabPaneProps, TabsProps } from 'antd'; import { Tabs as AntdTabs, TabPaneProps, TabsProps } from 'antd';
import classNames from 'classnames'; import classNames from 'classnames';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useSchemaInitializerRender } from '../../../application';
import { Icon } from '../../../icon'; import { Icon } from '../../../icon';
import { DndContext, SortableItem } from '../../common'; import { DndContext, SortableItem } from '../../common';
import { SchemaComponent } from '../../core';
import { useDesigner } from '../../hooks/useDesigner'; import { useDesigner } from '../../hooks/useDesigner';
import { useTabsContext } from './context'; import { useTabsContext } from './context';
import { TabsDesigner } from './Tabs.Designer'; import { TabsDesigner } from './Tabs.Designer';
import { useSchemaInitializerRender } from '../../../application';
import { SchemaComponent } from '../../core';
export const Tabs: any = observer( export const Tabs: any = observer(
(props: TabsProps) => { (props: TabsProps) => {
@ -48,7 +48,10 @@ export const Tabs: any = observer(
<AntdTabs <AntdTabs
{...contextProps} {...contextProps}
destroyInactiveTabPane destroyInactiveTabPane
tabBarExtraContent={render()} tabBarExtraContent={{
right: render(),
left: contextProps?.tabBarExtraContent,
}}
style={props.style} style={props.style}
items={items} items={items}
/> />

View File

@ -19,7 +19,6 @@ import { useTreeParentRecord } from '../../modules/blocks/data-blocks/table/Tree
import { useRecord } from '../../record-provider'; import { useRecord } from '../../record-provider';
import { useCompile } from '../../schema-component'; import { useCompile } from '../../schema-component';
import { linkageAction } from '../../schema-component/antd/action/utils'; import { linkageAction } from '../../schema-component/antd/action/utils';
import { useNavigateTOSubPage } from '../../schema-component/antd/page/SubPages';
import { usePagePopup } from '../../schema-component/antd/page/pagePopupUtils'; import { usePagePopup } from '../../schema-component/antd/page/pagePopupUtils';
import { parseVariables } from '../../schema-component/common/utils/uitls'; import { parseVariables } from '../../schema-component/common/utils/uitls';
import { useLocalVariables, useVariables } from '../../variables'; import { useLocalVariables, useVariables } from '../../variables';
@ -73,7 +72,6 @@ const InternalCreateRecordAction = (props: any, ref) => {
const variables = useVariables(); const variables = useVariables();
const localVariables = useLocalVariables({ currentForm: { values } as any }); const localVariables = useLocalVariables({ currentForm: { values } as any });
const { openPopup } = usePagePopup(); const { openPopup } = usePagePopup();
const { navigateToSubPage } = useNavigateTOSubPage();
const treeRecordData = useTreeParentRecord(); const treeRecordData = useTreeParentRecord();
useEffect(() => { useEffect(() => {
@ -101,10 +99,6 @@ const InternalCreateRecordAction = (props: any, ref) => {
<CreateAction <CreateAction
{...props} {...props}
onClick={(collection: Collection) => { onClick={(collection: Collection) => {
if (openMode === 'page') {
return navigateToSubPage();
}
if (treeRecordData) { if (treeRecordData) {
openPopup({ openPopup({
recordData: treeRecordData, recordData: treeRecordData,

View File

@ -30,7 +30,7 @@ export const SchemaInitializerOpenModeSchemaItems: React.FC<Options> = (options)
const { isPopupVisibleControlledByURL } = usePopupSettings(); const { isPopupVisibleControlledByURL } = usePopupSettings();
const openModeValue = fieldSchema?.['x-component-props']?.['openMode'] || 'drawer'; const openModeValue = fieldSchema?.['x-component-props']?.['openMode'] || 'drawer';
const modeOptions = useMemo(() => { const modeOptions = useMemo(() => {
if (isPopupVisibleControlledByURL) { if (isPopupVisibleControlledByURL()) {
return [ return [
{ label: t('Drawer'), value: 'drawer' }, { label: t('Drawer'), value: 'drawer' },
{ label: t('Dialog'), value: 'modal' }, { label: t('Dialog'), value: 'modal' },
@ -42,7 +42,7 @@ export const SchemaInitializerOpenModeSchemaItems: React.FC<Options> = (options)
{ label: t('Drawer'), value: 'drawer' }, { label: t('Drawer'), value: 'drawer' },
{ label: t('Dialog'), value: 'modal' }, { label: t('Dialog'), value: 'modal' },
]; ];
}, [t, isPopupVisibleControlledByURL]); }, [t, isPopupVisibleControlledByURL()]);
return ( return (
<> <>
@ -120,7 +120,7 @@ export const SchemaSettingOpenModeSchemaItems: React.FC<Options> = (props) => {
return modeOptions; return modeOptions;
} }
if (isPopupVisibleControlledByURL) { if (isPopupVisibleControlledByURL()) {
return [ return [
{ label: t('Drawer'), value: 'drawer' }, { label: t('Drawer'), value: 'drawer' },
{ label: t('Dialog'), value: 'modal' }, { label: t('Dialog'), value: 'modal' },

View File

@ -7,15 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { import { ActionContextProvider, AdminProvider, css, cx, RemoteSchemaComponent, useViewport } from '@nocobase/client';
ActionContextProvider,
AdminProvider,
css,
cx,
PopupSettingsProvider,
RemoteSchemaComponent,
useViewport,
} from '@nocobase/client';
import { DrawerProps, ModalProps } from 'antd'; import { DrawerProps, ModalProps } from 'antd';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Outlet, useParams } from 'react-router-dom'; import { Outlet, useParams } from 'react-router-dom';
@ -101,37 +93,35 @@ const MApplication: React.FC = (props) => {
return ( return (
<Provider> <Provider>
<MobileCore> <MobileCore>
<PopupSettingsProvider isPopupVisibleControlledByURL={false}> <OpenInNewTab />
<OpenInNewTab /> <ActionContextProvider modalProps={modalProps as ModalProps} drawerProps={drawerProps}>
<ActionContextProvider modalProps={modalProps as ModalProps} drawerProps={drawerProps}> <div
<div className={cx(
className={cx( 'nb-mobile-application',
'nb-mobile-application', commonDesignerCSS,
commonDesignerCSS, commonCSSVariables,
commonCSSVariables, commonCSSOverride,
commonCSSOverride, css`
css` display: flex;
display: flex; flex-direction: column;
flex-direction: column; width: 100%;
width: 100%; height: 100%;
height: 100%; position: relative;
position: relative; overflow: hidden;
overflow: hidden; `,
`, )}
)} >
> {params.name && !params.name.startsWith('tab_') ? (
{params.name && !params.name.startsWith('tab_') ? ( <Outlet />
<Outlet /> ) : (
) : ( <RemoteSchemaComponent key={mobileSchemaUid} uid={mobileSchemaUid}>
<RemoteSchemaComponent key={mobileSchemaUid} uid={mobileSchemaUid}> {props.children}
{props.children} </RemoteSchemaComponent>
</RemoteSchemaComponent> )}
)} {/* Global action will insert here */}
{/* Global action will insert here */} <div id="nb-position-container"></div>
<div id="nb-position-container"></div> </div>
</div> </ActionContextProvider>
</ActionContextProvider>
</PopupSettingsProvider>
</MobileCore> </MobileCore>
</Provider> </Provider>
); );