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
- 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:
__E2E__: true
APP_ENV: production
@ -307,6 +387,7 @@ jobs:
needs:
- core-and-plugins
- plugin-workflow
- plugin-workflow-approval
- plugin-data-source-main
if: ${{ !cancelled() && github.event.pull_request.number }}
steps:

View File

@ -36,7 +36,6 @@ import {
import { DataBlockCollector } from '../filter-provider/FilterProvider';
import { useSourceId } from '../modules/blocks/useSourceId';
import { RecordProvider, useRecordIndex } from '../record-provider';
import { usePagePopup } from '../schema-component/antd/page/pagePopupUtils';
import { useAssociationNames } from './hooks';
import { useDataBlockParentRecord } from './hooks/useDataBlockParentRecord';
@ -300,11 +299,6 @@ export const useFilterByTk = () => {
const { getCollectionField } = useCollectionManager_deprecated();
const assoc = useBlockAssociationContext();
const withoutTableFieldResource = useContext(WithoutTableFieldResource);
const { popupParams } = usePagePopup();
if (popupParams?.filterbytk) {
return popupParams.filterbytk;
}
if (!withoutTableFieldResource) {
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.",
"URL search params": "URL search params",
"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",
"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",
"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",
"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",
"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\"": "この変数は非推奨です。代わりに「現在のフォーム」を使用してください",
"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検索パラメータ",
"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\"": "변수가 폐기되었습니다. \"현재 폼\"을 대체로 사용할 수 있습니다",
"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 검색 매개변수",
"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",
"Expand All": "Expandir tudo",
"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\"": "Переменная устарела; \"Текущая форма\" может быть использована в качестве замены",
"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",
"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",
"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",
"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\"": "Змінна була застарілою; \"Поточна форма\" може бути використана як заміна",
"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",
"Expand All": "Розгорнути все"
"Expand All": "Розгорнути все",
"Sorry, the page you visited does not exist.": "Вибачте, сторінка, яку ви відвідали, не існує."
}

View File

@ -963,5 +963,6 @@
"Add parameter": "添加参数",
"URL search params": "URL 查询参数",
"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\"": "該變數已被棄用,可以使用“當前表單”作為替代",
"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 查詢參數",
"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
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 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();
// 在第一级弹窗中,不应该包含 Parent popup record 变量
await page.getByLabel('block-item-CardItem-users-').hover();
await page.getByLabel('designer-schema-settings-CardItem-blockSettings:table-users').hover();
await page.getByText('UsersAdd newConfigure').hover();
await page.getByRole('button', { name: 'designer-schema-settings-' }).hover();
await page.getByRole('menuitem', { name: 'Set the data scope' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByLabel('variable-button').click();
@ -664,8 +664,8 @@ test.describe('set default value', () => {
await page.getByLabel('action-Action.Link-View').click();
// 在第一级弹窗中,不应该包含 Parent popup record 变量
await page.getByLabel('block-item-CardItem-users-').hover();
await page.getByLabel('designer-schema-settings-CardItem-blockSettings:table-users').hover();
await page.getByText('UsersAdd newConfigure').hover();
await page.getByRole('button', { name: 'designer-schema-settings-' }).hover();
await page.getByRole('menuitem', { name: 'Set the data scope' }).click();
await page.getByText('Add condition', { exact: true }).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.getByLabel('action-Action.Link-View in popup').click();
await page.getByLabel('schema-initializer-Grid-popup').hover();
await page.getByLabel('action-Action.Link-View in').click();
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: 'Other records right' }).hover();
await page.getByRole('menuitem', { name: 'Users' }).click();
@ -728,18 +728,18 @@ test.describe('set default value', () => {
// 3. Table 数据选择器中使用 `Parent popup record`
// 创建 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: 'Other records right' }).hover();
await page.getByRole('menuitem', { name: 'Users' }).click();
await page.mouse.move(300, 0);
// 显示 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.mouse.move(300, 0);
// 设置数据范围(使用 `Parent popup record` 变量)
await page.getByLabel('block-item-CardItem-users-table').hover();
await page.getByLabel('designer-schema-settings-CardItem-blockSettings:table-users').hover();
await page.getByLabel('block-item-CardItem-users-table').nth(1).hover();
await page.getByRole('button', { name: 'designer-schema-settings-' }).hover();
await page.getByRole('menuitem', { name: 'Set the data scope' }).click();
await page.getByText('Add condition', { exact: true }).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();
expect(page.url()).toContain('/subpages/');
// 配置出一个表单
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.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.getByTestId('select-single').click();
await page.getByRole('option', { name: 'option3' }).click();
// 提交后会自动返回
await page.getByLabel('action-Action-Submit-submit-').click();
await page.goBack();
await page.getByLabel('schema-initializer-TableV2-').hover();
await page.getByRole('menuitem', { name: 'Single select' }).click();
await page.mouse.move(300, 0);
@ -538,7 +536,6 @@ test.describe('actions schema settings', () => {
// 跳转到子页面后,其内容应该和弹窗中的内容一致
await page.getByLabel('action-Action.Link-View').click();
expect(page.url()).toContain('/subpages');
// 详情区块
await expect(
@ -732,7 +729,7 @@ test.describe('actions schema settings', () => {
// 使用变量 `Current popup record` 和 `Parent popup record` 设置默认值
await expect(
page
.getByLabel('block-item-CardItem-users-form')
.getByText("UsersUse 'Current popup")
.getByLabel('block-item-CollectionField-users-form-users.nickname-Nickname')
.getByRole('textbox'),
).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 { ErrorFallback } from '../schema-component/antd/error-fallback';
import { PagePopups } from '../schema-component/antd/page/PagePopups';
import { SubPage } from '../schema-component/antd/page/SubPages';
import { AssociationFilterPlugin, SchemaInitializerPlugin } from '../schema-initializer';
import { SchemaSettingsPlugin } from '../schema-settings';
import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates';
@ -316,10 +315,6 @@ export class NocoBaseBuildInPlugin extends Plugin {
path: '/admin/:name/tabs/:tabUid/popups/*',
Component: PagePopups,
});
this.router.add('admin.subPage', {
path: '/admin/subpages/*',
Component: SubPage,
});
}
addComponents() {

View File

@ -92,7 +92,7 @@ const MenuEditor = (props) => {
const ctx = useACLRoleContext();
const [current, setCurrent] = useState(null);
const onSelect = useCallback(({ item }) => {
const onSelect = useCallback(({ item }: { item; key; keyPath; domEvent }) => {
const schema = item.props.schema;
setTitle(schema.title);
setCurrent(schema);
@ -323,6 +323,33 @@ export const AdminDynamicPage = () => {
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 = () => {
const result = useSystemSettings();
const { token } = useToken();
@ -447,36 +474,9 @@ export const InternalAdminLayout = () => {
</div>
</Layout.Header>
<AdminSideBar sideMenuRef={sideMenuRef} />
<Layout.Content
className={css`
display: flex;
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>
{/* Use the "nb-subpages-slot-without-header-and-side" class name to locate the position of the subpages */}
<Layout.Content className={`${layoutContentClass} nb-subpages-slot-without-header-and-side`}>
<header className={layoutContentHeaderClass}></header>
<Outlet />
{/* {service.contentLoading ? render() : <Outlet />} */}
</Layout.Content>

View File

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

View File

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

View File

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

View File

@ -9,9 +9,17 @@
import { createStyles } from 'antd-style';
export const useSubPagesStyle = createStyles(({ css, token }: any) => {
export const useActionPageStyle = createStyles(({ css, token }: any) => {
return {
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 {
background: ${token.colorBgContainer};
padding: 0 ${token.paddingPageVertical}px;

View File

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

View File

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

View File

@ -11,7 +11,6 @@ import { fireEvent, render, screen, userEvent, waitFor } from '@nocobase/test/cl
import React from 'react';
import App1 from '../demos/demo1';
import App2 from '../demos/demo2';
import App3 from '../demos/demo3';
import App4 from '../demos/demo4';
describe('Action', () => {
@ -55,45 +54,6 @@ describe('Action', () => {
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', () => {

View File

@ -7,8 +7,11 @@
* 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 { useCurrentPopupContext } from '../page/PagePopups';
import { getBlockService, storeBlockService } from '../page/pagePopupUtils';
import { ActionContextProps } from './types';
export const ActionContext = createContext<ActionContextProps>({});
@ -17,24 +20,19 @@ ActionContext.displayName = 'ActionContext';
export const ActionContextProvider: React.FC<ActionContextProps & { value?: ActionContextProps }> = (props) => {
const [submitted, setSubmitted] = useState(false); //是否有提交记录
const { visible } = { ...props, ...props.value } || {};
const isFirstRender = useRef(true); // 使用ref跟踪是否为首次渲染
const service = useDataBlockRequest();
const { setSubmitted: setParentSubmitted } = { ...props, ...props.value };
const service = useBlockServiceInActionButton();
useEffect(() => {
if (visible !== undefined) {
if (isFirstRender.current) {
isFirstRender.current = false;
} else {
if (visible === false && submitted && service) {
service.refresh();
setParentSubmitted?.(true); //传递给上一层
}
}
if (visible === false && submitted && service) {
service.refresh();
setParentSubmitted?.(true); //传递给上一层
}
return () => {
setSubmitted(false);
};
}, [visible]);
}, [visible, service]);
return (
<ActionContext.Provider value={{ ...props, ...props?.value, submitted, setSubmitted }}>
@ -42,3 +40,25 @@ export const ActionContextProvider: React.FC<ActionContextProps & { value?: Acti
</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 {
...ctx,
setVisible(visible: boolean, confirm = false) {
if (ctx?.openMode !== 'page') {
if (!visible) {
if (confirm && ctx.formValueChanged) {
modal.confirm({
title: t('Unsaved changes'),
content: t("Are you sure you don't want to save?"),
async onOk() {
ctx.setFormValueChanged(false);
ctx.setVisible?.(false);
},
});
} else {
ctx?.setVisible?.(false);
}
if (!visible) {
if (confirm && ctx.formValueChanged) {
modal.confirm({
title: t('Unsaved changes'),
content: t("Are you sure you don't want to save?"),
async onOk() {
ctx.setFormValueChanged(false);
ctx.setVisible?.(false);
},
});
} 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]);
const handleSelect = useCallback(
(info: any) => {
(info: { item; key; keyPath; domEvent }) => {
const s = schema.properties?.[info.key];
if (!s) {
@ -274,7 +274,7 @@ const HeaderMenu = ({
<AntdMenu
{...others}
className={headerMenuClass}
onSelect={handleSelect}
onClick={handleSelect}
mode={mode === 'mix' ? 'horizontal' : mode}
defaultOpenKeys={defaultOpenKeys}
defaultSelectedKeys={defaultSelectedKeys}
@ -352,7 +352,7 @@ const SideMenu = ({
mode={'inline'}
openKeys={openKeys}
selectedKeys={selectedKeys}
onSelect={onSelect}
onClick={onSelect}
onOpenChange={setOpenKeys}
className={sideMenuClass}
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,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
'&:hover': { '> .general-schema-designer': { display: 'block' } },
'.ant-page-header': { zIndex: 1, position: 'relative' },
'> .general-schema-designer': {

View File

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

View File

@ -8,14 +8,18 @@
*/
import { ISchema } from '@formily/json-schema';
import { uid } from '@formily/shared';
import { Result } from 'antd';
import _ from 'lodash';
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 { useAPIClient } from '../../../api-client';
import { DataBlockProvider } from '../../../data-source/data-block/DataBlockProvider';
import { BlockRequestContext } from '../../../data-source/data-block/DataBlockRequestProvider';
import { SchemaComponent } from '../../core';
import { TabsContextProvider } from '../tabs/context';
import { BackButtonUsedInSubPage } from './BackButtonUsedInSubPage';
import { usePopupSettings } from './PopupSettingsProvider';
import { deleteRandomNestedSchemaKey, getRandomNestedSchemaKey } from './nestedSchemaKeyStorage';
import { PopupParams, getPopupParamsFromPath, getStoredPopupContext, usePagePopup } from './pagePopupUtils';
@ -32,17 +36,29 @@ interface PopupsVisibleProviderProps {
interface PopupProps {
params: PopupParams;
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 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';
PopupParamsProviderContext.displayName = 'PopupParamsProviderContext';
export const usePopupContextAndParams = () => {
const context = React.useContext(PopupParamsProviderContext);
return (context || {}) as PopupProps;
};
AllPopupsPropsProviderContext.displayName = 'AllPopupsPropsProviderContext';
/**
* The difference between this component and ActionContextProvider is that
@ -51,17 +67,21 @@ export const usePopupContextAndParams = () => {
* @returns
*/
export const PopupVisibleProvider: FC<PopupsVisibleProviderProps> = ({ children, visible, setVisible }) => {
return (
<PopupVisibleProviderContext.Provider value={{ visible, setVisible }}>
{children}
</PopupVisibleProviderContext.Provider>
);
const value = useMemo(() => {
return { visible, setVisible };
}, [visible, setVisible]);
return <PopupVisibleProviderContext.Provider value={value}>{children}</PopupVisibleProviderContext.Provider>;
};
const PopupParamsProvider: FC<PopupProps> = (props) => {
const PopupParamsProvider: FC<Omit<PopupProps, 'hidden'>> = (props) => {
const value = useMemo(() => {
return { params: props.params, context: props.context };
}, [props.params, props.context]);
return {
params: props.params,
context: props.context,
currentLevel: props.currentLevel,
};
}, [props.params, props.context, props.currentLevel]);
return <PopupParamsProviderContext.Provider value={value}>{props.children}</PopupParamsProviderContext.Provider>;
};
@ -74,19 +94,28 @@ const PopupTabsPropsProvider: FC<{ params: PopupParams }> = ({ children, params
[changeTab],
);
const { isPopupVisibleControlledByURL } = usePopupSettings();
const { isSubPage } = useCurrentPopupContext();
const tabBarExtraContent = useMemo(() => (isSubPage ? <BackButtonUsedInSubPage /> : null), [isSubPage]);
if (!isPopupVisibleControlledByURL) {
if (!isPopupVisibleControlledByURL()) {
return <>{children}</>;
}
return (
<TabsContextProvider activeKey={params.tab} onTabClick={onTabClick}>
<TabsContextProvider activeKey={params.tab} onTabClick={onTabClick} tabBarExtraContent={tabBarExtraContent}>
{children}
</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 [visible, _setVisible] = useState(true);
const setVisible = (visible: boolean) => {
@ -95,7 +124,7 @@ const PagePopupsItemProvider: FC<{ params: PopupParams; context: PopupContext }>
if (process.env.__E2E__) {
setTimeout(() => {
closePopup();
closePopup(params.popupuid);
// Deleting here ensures that the next time the same popup is opened, it will generate another random key.
deleteRandomNestedSchemaKey(params.popupuid);
});
@ -104,7 +133,7 @@ const PagePopupsItemProvider: FC<{ params: PopupParams; context: PopupContext }>
// Leave some time to refresh the block data
setTimeout(() => {
closePopup();
closePopup(params.popupuid);
// Deleting here ensures that the next time the same popup is opened, it will generate another random key.
deleteRandomNestedSchemaKey(params.popupuid);
}, 300);
@ -116,8 +145,16 @@ const PagePopupsItemProvider: FC<{ params: PopupParams; context: PopupContext }>
context = storedContext;
}
if (_.isEmpty(context)) {
return (
<PopupVisibleProvider visible={visible} setVisible={setVisible}>
<div style={{ display: 'none' }}>{children}</div>
</PopupVisibleProvider>
);
}
return (
<PopupParamsProvider params={params} context={context}>
<PopupParamsProvider params={params} context={context} currentLevel={currentLevel}>
<PopupVisibleProvider visible={visible} setVisible={setVisible}>
<DataBlockProvider
dataSource={context.dataSource}
@ -149,7 +186,7 @@ const PagePopupsItemProvider: FC<{ params: PopupParams; context: PopupContext }>
* @param parentSchema
*/
export const insertChildToParentSchema = (childSchema: ISchema, props: PopupProps, parentSchema: ISchema) => {
const { params, context } = props;
const { params, context, currentLevel } = props;
const componentSchema = {
type: 'void',
@ -157,6 +194,7 @@ export const insertChildToParentSchema = (childSchema: ISchema, props: PopupProp
'x-component-props': {
params,
context,
currentLevel,
},
properties: {
popupAction: childSchema,
@ -188,15 +226,32 @@ export const PagePopups = (props: { paramsList?: PopupParams[] }) => {
);
const schemas = await Promise.all(waitList);
const clonedSchemas = schemas.map((schema) => {
if (_.isEmpty(schema)) {
return get404Schema();
}
const result = _.cloneDeep(_.omit(schema, 'parent'));
result['x-read-pretty'] = true;
return result;
});
popupPropsRef.current = clonedSchemas.map((schema, index) => {
popupPropsRef.current = clonedSchemas.map((schema, index, items) => {
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 {
params: popupParams[index],
context: schemaContext,
hidden,
currentLevel: index + 1,
isSubPage: isSubPageSchema(schema),
};
});
const rootSchema = clonedSchemas[0];
@ -215,9 +270,15 @@ export const PagePopups = (props: { paramsList?: PopupParams[] }) => {
}
return (
<PagePopupsItemProvider params={popupPropsRef.current[0].params} context={popupPropsRef.current[0].context}>
<SchemaComponent components={components} schema={rootSchema} onlyRenderProperties />;
</PagePopupsItemProvider>
<AllPopupsPropsProviderContext.Provider value={popupPropsRef.current}>
<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 requestSchema = useCallback(async (uid: string) => {
const data = await api.request({
url: `/uiSchemas:getJsonSchema/${uid}`,
});
return data.data?.data as ISchema;
try {
const data = await api.request({
url: `/uiSchemas:getJsonSchema/${uid}`,
});
return data.data?.data as ISchema;
} catch (error) {
console.error(error);
return null;
}
}, []);
return { requestSchema };
@ -243,3 +309,101 @@ export const getPopupPath = (location: Location) => {
const [, ...popupsPath] = location.pathname.split('/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.
*/
import React, { FC, useMemo } 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>;
};
import { useCallback } from 'react';
/**
* Hook for accessing the popup settings.
* @returns The popup settings.
*/
export const usePopupSettings = () => {
return (
React.useContext(PopupSettingsContext) || {
isPopupVisibleControlledByURL: true,
}
);
const isPopupVisibleControlledByURL = useCallback(() => {
const pathname = window.location.pathname;
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 result1 = removeLastPopupPath(path1);
expect(result1).toBe('/admin/page/popups/popupUid/');
expect(result1).toBe('/admin/page/popups/popupUid');
const path2 = '/admin/page/popups/popupUid';
const result2 = removeLastPopupPath(path2);
expect(result2).toBe('/admin/page/');
expect(result2).toBe('/admin/page');
});
it('should handle paths without popups', () => {

View File

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

View File

@ -21,9 +21,8 @@ import {
useDataBlockRequest,
useDataSourceKey,
} from '../../../data-source';
import { useCurrentPopupRecord } from '../../../modules/variable/variablesProvider/VariablePopupRecordProvider';
import { ActionContext } from '../action/context';
import { PopupVisibleProviderContext, usePopupContextAndParams } from './PagePopups';
import { PopupVisibleProviderContext, useCurrentPopupContext } from './PagePopups';
import { usePopupSettings } from './PopupSettingsProvider';
import { PopupContext, usePopupContextInActionOrAssociationField } from './usePopupContextInActionOrAssociationField';
@ -53,10 +52,30 @@ export const getStoredPopupContext = (popupUid: string) => {
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) => {
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) => {
const popupPaths = path.split('/popups/');
return popupPaths.filter(Boolean).map((popupPath) => {
@ -100,17 +119,17 @@ export const usePagePopup = () => {
const cm = useCollectionManager();
const association = useAssociationName();
const { visible, setVisible } = useContext(PopupVisibleProviderContext) || { visible: false, setVisible: () => {} };
const { params: popupParams } = usePopupContextAndParams();
const { params: popupParams } = useCurrentPopupContext();
const service = useDataBlockRequest();
const { isPopupVisibleControlledByURL } = usePopupSettings();
const { setVisible: setVisibleFromAction } = useContext(ActionContext);
const { updatePopupContext } = usePopupContextInActionOrAssociationField();
const { value: parentPopupRecordData, collection: parentPopupRecordCollection } = useCurrentPopupRecord() || {};
const getSourceId = useCallback(
(_parentRecordData?: Record<string, any>) =>
(_parentRecordData || parentRecord?.data)?.[cm.getSourceKeyByAssociation(association)],
[parentRecord, association],
);
const currentPopupUidWithoutOpened = fieldSchema['x-uid'];
const getNewPathname = useCallback(
({
@ -140,17 +159,10 @@ export const usePagePopup = () => {
dataSource: dataSourceKey,
collection: association ? undefined : collection.name,
association,
parentPopupRecord: !_.isEmpty(parentPopupRecordData)
? {
// TODO: 这里应该需要 association 的 值
collection: parentPopupRecordCollection?.name,
filterByTk: cm.getFilterByTK(parentPopupRecordCollection, parentPopupRecordData),
}
: undefined,
};
return _.omitBy(context, _.isNil) as PopupContext;
}, [dataSourceKey, collection, association, parentPopupRecordData, parentPopupRecordCollection, cm]);
}, [dataSourceKey, collection, association]);
const openPopup = useCallback(
({
@ -160,20 +172,20 @@ export const usePagePopup = () => {
recordData?: Record<string, any>;
parentRecordData?: Record<string, any>;
} = {}) => {
if (!isPopupVisibleControlledByURL) {
if (!isPopupVisibleControlledByURL()) {
return setVisibleFromAction?.(true);
}
const sourceId = getSourceId(parentRecordData);
recordData = recordData || record?.data;
const pathname = getNewPathname({ popupUid: fieldSchema['x-uid'], recordData, sourceId });
const pathname = getNewPathname({ popupUid: currentPopupUidWithoutOpened, recordData, sourceId });
let url = location.pathname;
if (_.last(url) === '/') {
url = url.slice(0, -1);
}
storePopupContext(fieldSchema['x-uid'], {
storePopupContext(currentPopupUidWithoutOpened, {
schema: fieldSchema,
record: new CollectionRecord({ isNew: false, data: recordData }),
parentRecord: parentRecordData ? new CollectionRecord({ isNew: false, data: parentRecordData }) : parentRecord,
@ -182,13 +194,6 @@ export const usePagePopup = () => {
collection: collection.name,
association,
sourceId,
parentPopupRecord: parentPopupRecordData
? {
// TODO: 这里应该需要 association 的 值
collection: parentPopupRecordCollection?.name,
filterByTk: cm.getFilterByTK(parentPopupRecordCollection, parentPopupRecordData),
}
: undefined,
});
updatePopupContext(getPopupContext());
@ -208,19 +213,29 @@ export const usePagePopup = () => {
service,
location,
isPopupVisibleControlledByURL,
parentPopupRecordData,
getSourceId,
getPopupContext,
currentPopupUidWithoutOpened,
],
);
const closePopup = useCallback(() => {
if (!isPopupVisibleControlledByURL) {
return setVisibleFromAction?.(false);
}
const closePopup = useCallback(
(currentPopupUid: string) => {
if (!isPopupVisibleControlledByURL()) {
return setVisibleFromAction?.(false);
}
navigate(withSearchParams(removeLastPopupPath(location.pathname)));
}, [navigate, location, isPopupVisibleControlledByURL]);
// 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;
// 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(
(key: string) => {
@ -235,7 +250,9 @@ export const usePagePopup = () => {
if (_.last(url) === '/') {
url = url.slice(0, -1);
}
navigate(`${url}${pathname}`);
navigate(`${url}${pathname}`, {
replace: true,
});
},
[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) {
if (!path.includes('popups')) {
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) {

View File

@ -16,25 +16,6 @@ export interface PopupContext {
dataSource: string;
collection?: 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';

View File

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

View File

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

View File

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

View File

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