feat: enable direct dialog opening via URL and support for page mode (#4706)

* refactor: optimize page tabs routing

* test: add e2e test for page tabs

* feat: add popup routing

* fix: resolve nested issue

* refactor: rename file utils to pagePopupUtils

* perf: enhance animation and overall performance

* fix: fix filterByTK

* fix(sourceId): resolve error when sourceId is undefined

* fix: fix List and GridCard

* fix: fix params not fresh

* fix: fix parent record

* fix: resolve the issue on block data not refreshing after popup closure

* feat: bind tab with URL in popups

* feat(sub-page): enable popup to open in page mode

* chore: optimize

* feat: support association fields

* fix: address the issue of no data in associaiton field

* fix: resolve the issue with opening nested dialog in association field

* fix: fix the issue of dialog content not refreshing

* perf: use useNavigateNoUpdate to replace useNavigate

* perf: enhance popups performance by avoiding unnecessary rendering

* fix: fix tab page

* fix: fix bulk edit action

* chore: fix unit test

* chore: fix unit tests

* fix: fix bug to pass e2e tests

* chore: fix build

* fix: fix bugs to pass e2e tests

* chore: avoid crashing

* chore: make e2e tests pass

* chore: make e2e tests pass

* chore: fix unit tests

* fix(multi-app): fix known issues

* fix(Duplicate): should no page mode

* chore: fix build

* fix(mobile): fix known issues

* fix: fix open mode of Add new

* refactor: rename 'popupUid' to 'popupuid'

* refactor: rename 'subPageUid' tp 'subpageuid'

* refactor(subpage): simplify configuration of router

* fix(variable): refresh data after value change

* test: add e2e test for sub page

* refactor: refactor and add tests

* fix: fix association field

* refactor(subPage): avoid blank page occurrences

* chore: fix unit tests

* fix: correct first-click context setting for association fields

* refactor: use Action's uid for subpage

* refactor: rename x-nb-popup-context to x-action-context and move it to Action schema

* feat: add context during the creation of actions

* chore: fix build

* chore: make e2e tests pass

* fix(addChild): fix context of Add child

* fix: avoid loss or query string

* fix: avoid including 'popups' in the path

* fix: resolve issue with popup variables and add tests

* chore(e2e): fix e2e test

* fix(sideMenu): resolve the disappearing sidebar issue and add tests

* chore(e2e): fix e2e test

* fix: should refresh block data after mutiple popups closed

* chore: fix e2e test

* fix(associationField): fix wrong context

* fix: address issue with special characters
This commit is contained in:
Zeke Zhang 2024-06-30 23:25:01 +08:00 committed by GitHub
parent 44580ff9a8
commit 05cf9986b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
106 changed files with 9880 additions and 1054 deletions

View File

@ -45,7 +45,6 @@ const getRouteUrl = (props) => {
};
export const ACLRolesCheckProvider = (props) => {
const route = getRouteUrl(props.children.props);
const { setDesignable } = useDesignable();
const { render } = useAppSpin();
const api = useAPIClient();

View File

@ -0,0 +1,89 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { FC, useEffect } from 'react';
import { Location, NavigateFunction, NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
const NavigateNoUpdateContext = React.createContext<NavigateFunction>(null);
const LocationNoUpdateContext = React.createContext<Location>(null);
export const LocationSearchContext = React.createContext<string>('');
/**
* When the URL changes, components that use `useNavigate` will re-render.
* This provider provides a `navigateNoUpdate` method that can avoid re-rendering.
*
* see: https://github.com/remix-run/react-router/issues/7634
* @param param0
* @returns
*/
const NavigateNoUpdateProvider: FC = ({ children }) => {
const navigate = useNavigate();
const navigateRef = React.useRef(navigate);
navigateRef.current = navigate;
const navigateNoUpdate = React.useCallback((to: string, options?: NavigateOptions) => {
navigateRef.current(to, options);
}, []);
return (
<NavigateNoUpdateContext.Provider value={navigateNoUpdate as NavigateFunction}>
{children}
</NavigateNoUpdateContext.Provider>
);
};
/**
* When the URL changes, components that use `useLocation` will re-render.
* This provider provides a `useLocationNoUpdate` method that can avoid re-rendering.
**/
const LocationNoUpdateProvider: FC = ({ children }) => {
const location = useLocation();
const locationRef = React.useRef<any>({});
useEffect(() => {
Object.assign(locationRef.current, location);
}, [location]);
return <LocationNoUpdateContext.Provider value={locationRef.current}>{children}</LocationNoUpdateContext.Provider>;
};
const LocationSearchProvider: FC = ({ children }) => {
const location = useLocation();
return <LocationSearchContext.Provider value={location.search}>{children}</LocationSearchContext.Provider>;
};
/**
* use `useNavigateNoUpdate` to avoid components that use `useNavigateNoUpdate` re-rendering.
* @returns
*/
export const useNavigateNoUpdate = () => {
return React.useContext(NavigateNoUpdateContext);
};
/**
* use `useLocationNoUpdate` to avoid components that use `useLocationNoUpdate` re-rendering.
* @returns
*/
export const useLocationNoUpdate = () => {
return React.useContext(LocationNoUpdateContext);
};
export const useLocationSearch = () => {
return React.useContext(LocationSearchContext);
};
export const CustomRouterContextProvider: FC = ({ children }) => {
return (
<NavigateNoUpdateProvider>
<LocationNoUpdateProvider>
<LocationSearchProvider>{children}</LocationSearchProvider>
</LocationNoUpdateProvider>
</NavigateNoUpdateProvider>
);
};

View File

@ -20,6 +20,7 @@ import {
useRoutes,
} from 'react-router-dom';
import { Application } from './Application';
import { CustomRouterContextProvider } from './CustomRouterContextProvider';
import { BlankComponent, RouterContextCleaner } from './components';
export interface BrowserRouterOptions extends Omit<BrowserRouterProps, 'children'> {
@ -143,10 +144,12 @@ export class RouterManager {
return (
<RouterContextCleaner>
<ReactRouter {...opts}>
<CustomRouterContextProvider>
<BaseLayout>
<RenderRoutes />
{children}
</BaseLayout>
</CustomRouterContextProvider>
</ReactRouter>
</RouterContextCleaner>
);

View File

@ -8,12 +8,16 @@
*/
import { Spin } from 'antd';
import React from 'react';
import React, { useCallback } from 'react';
import { useApp } from './useApp';
export const useAppSpin = () => {
const app = useApp();
const renderSpin = useCallback(
() => (app?.renderComponent ? app?.renderComponent?.('AppSpin') : React.createElement(Spin)),
[app],
);
return {
render: () => (app?.renderComponent ? app?.renderComponent?.('AppSpin') : React.createElement(Spin)),
render: renderSpin,
};
};

View File

@ -8,6 +8,7 @@
*/
export * from './Application';
export * from './CustomRouterContextProvider';
export * from './Plugin';
export * from './PluginSettingsManager';
export * from './RouterManager';
@ -19,5 +20,8 @@ export * from './schema-initializer';
export * from './schema-settings';
export * from './schema-settings/context/SchemaSettingItemContext';
export * from './schema-settings/hooks/useSchemaSettingsRender';
export * from './schema-settings/utils/createModalSettingsItem';
export * from './schema-settings/utils/createSelectSettingsItem';
export * from './schema-settings/utils/createSwitchSettingsItem';
export * from './schema-toolbar';
export * from './utils';

View File

@ -12,4 +12,3 @@ export * from './SchemaSettingsManager';
export * from './components';
export * from './context/SchemaSettingItemContext';
export * from './types';
export * from './utils';

View File

@ -1,12 +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.
*/
export * from './createSelectSettingsItem';
export * from './createSwitchSettingsItem';
export * from './createModalSettingsItem';

View File

@ -20,6 +20,7 @@ import {
WithoutTableFieldResource,
useCollectionParentRecord,
useCollectionRecord,
useCollectionRecordData,
useDataBlockProps,
useDataBlockRequest,
useDataBlockResource,
@ -35,6 +36,7 @@ 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';
@ -293,11 +295,17 @@ export const useBlockAssociationContext = () => {
export const useFilterByTk = () => {
const { resource, __parent } = useBlockRequestContext();
const recordIndex = useRecordIndex();
const record = useRecord();
const recordData = useCollectionRecordData();
const collection = useCollection_deprecated();
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') {
return recordIndex;
@ -306,9 +314,9 @@ export const useFilterByTk = () => {
if (assoc) {
const association = getCollectionField(assoc);
return record?.[association.targetKey || 'id'];
return recordData?.[association.targetKey || 'id'];
}
return record?.[collection.filterTargetKey || 'id'];
return recordData?.[collection.filterTargetKey || 'id'];
};
/**

View File

@ -154,10 +154,8 @@ export const useFormBlockContext = () => {
export const useFormBlockProps = () => {
const ctx = useFormBlockContext();
const treeParentRecord = useTreeParentRecord();
const { fieldSchema } = useActionContext();
const addChild = fieldSchema?.['x-component-props']?.addChild;
useEffect(() => {
if (addChild) {
if (treeParentRecord) {
ctx.form?.query('parent').take((field) => {
field.disabled = true;
field.value = treeParentRecord;

View File

@ -159,7 +159,7 @@ export const TableBlockProvider = withDynamicSchemaProps((props) => {
}
params['tree'] = true;
} else {
const f = collection.fields.find((f) => f.treeChildren);
const f = collection?.fields.find((f) => f.treeChildren);
if (f) {
childrenColumnName = f.name;
}

View File

@ -19,7 +19,6 @@ import omit from 'lodash/omit';
import qs from 'qs';
import { ChangeEvent, useCallback, useContext, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useReactToPrint } from 'react-to-print';
import {
AssociationFilter,
@ -30,6 +29,7 @@ import {
useTableBlockContext,
} from '../..';
import { useAPIClient, useRequest } from '../../api-client';
import { useNavigateNoUpdate } from '../../application/CustomRouterContextProvider';
import { useFormBlockContext } from '../../block-provider/FormBlockProvider';
import { useCollectionManager_deprecated, useCollection_deprecated } from '../../collection-manager';
import { DataBlock, useFilterBlock } from '../../filter-provider/FilterProvider';
@ -206,7 +206,7 @@ export const useCreateActionProps = () => {
const form = useForm();
const { field, resource } = useBlockRequestContext();
const { setVisible, setSubmitted, setFormValueChanged } = useActionContext();
const navigate = useNavigate();
const navigate = useNavigateNoUpdate();
const actionSchema = useFieldSchema();
const actionField = useField();
const compile = useCompile();
@ -548,7 +548,7 @@ export const useCustomizeUpdateActionProps = () => {
const { resource, __parent, service } = useBlockRequestContext();
const filterByTk = useFilterByTk();
const actionSchema = useFieldSchema();
const navigate = useNavigate();
const navigate = useNavigateNoUpdate();
const compile = useCompile();
const form = useForm();
const { modal } = App.useApp();
@ -643,7 +643,7 @@ export const useCustomizeBulkUpdateActionProps = () => {
const { rowKey } = tableBlockContext;
const selectedRecordKeys =
tableBlockContext.field?.data?.selectedRowKeys ?? expressionScope?.selectedRecordKeys ?? {};
const navigate = useNavigate();
const navigate = useNavigateNoUpdate();
const compile = useCompile();
const { t } = useTranslation();
const actionField = useField();
@ -754,7 +754,7 @@ export const useCustomizeBulkUpdateActionProps = () => {
export const useCustomizeRequestActionProps = () => {
const apiClient = useAPIClient();
const navigate = useNavigate();
const navigate = useNavigateNoUpdate();
const filterByTk = useFilterByTk();
const actionSchema = useFieldSchema();
const compile = useCompile();
@ -850,7 +850,7 @@ export const useUpdateActionProps = () => {
const { field, resource, __parent } = useBlockRequestContext();
const { setVisible, setSubmitted, setFormValueChanged } = useActionContext();
const actionSchema = useFieldSchema();
const navigate = useNavigate();
const navigate = useNavigateNoUpdate();
const { fields, getField, name } = useCollection_deprecated();
const compile = useCompile();
const actionField = useField();
@ -1530,7 +1530,7 @@ export function appendQueryStringToUrl(url: string, queryString: string) {
}
export function useLinkActionProps() {
const navigate = useNavigate();
const navigate = useNavigateNoUpdate();
const fieldSchema = useFieldSchema();
const { t } = useTranslation();
const url = fieldSchema?.['x-component-props']?.['url'];

View File

@ -73,7 +73,7 @@ export function useParsedFilter({ filterOption }: { filterOption: any }) {
equals: _.isEqual,
},
);
}, [JSON.stringify(filterOption)]);
}, [JSON.stringify(filterOption), parseFilter, findVariable]);
return {
/** 数据范围的筛选参数 */

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { createContext, useContext, useEffect } from 'react';
import React, { createContext, useCallback, useContext, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useAPIClient, useRequest } from '../api-client';
import { useAppSpin } from '../application/hooks/useAppSpin';
@ -23,10 +23,7 @@ const CollectionHistoryContext = createContext<CollectionHistoryContextValue>({
});
CollectionHistoryContext.displayName = 'CollectionHistoryContext';
export const CollectionHistoryProvider: React.FC = (props) => {
const api = useAPIClient();
const options = {
const options = {
resource: 'collectionsHistory',
action: 'list',
params: {
@ -37,12 +34,12 @@ export const CollectionHistoryProvider: React.FC = (props) => {
},
sort: ['sort'],
},
};
};
export const CollectionHistoryProvider: React.FC = (props) => {
const api = useAPIClient();
const location = useLocation();
// console.log('location', location);
const isAdminPage = location.pathname.startsWith('/admin');
const token = api.auth.getToken() || '';
const { render } = useAppSpin();
@ -55,26 +52,24 @@ export const CollectionHistoryProvider: React.FC = (props) => {
});
// 刷新 collecionHistory
const refreshCH = async () => {
const refreshCH = useCallback(async () => {
const { data } = await api.request(options);
service.mutate(data);
return data?.data || [];
}, [service]);
const value = useMemo(() => {
return {
historyCollections: service.data?.data,
refreshCH,
};
}, [refreshCH, service.data?.data]);
if (service.loading) {
return render();
}
return (
<CollectionHistoryContext.Provider
value={{
historyCollections: service.data?.data,
refreshCH,
}}
>
{props.children}
</CollectionHistoryContext.Provider>
);
return <CollectionHistoryContext.Provider value={value}>{props.children}</CollectionHistoryContext.Provider>;
};
export const useHistoryCollectionsByNames = (collectionNames: string[]) => {

View File

@ -7,15 +7,15 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import React, { useCallback } from 'react';
import { useAPIClient, useRequest } from '../api-client';
import { CollectionManagerSchemaComponentProvider } from './CollectionManagerSchemaComponentProvider';
import { CollectionCategroriesContext } from './context';
import { CollectionManagerOptions } from './types';
import { useAppSpin } from '../application/hooks/useAppSpin';
import { CollectionManagerProvider } from '../data-source/collection/CollectionManagerProvider';
import { useDataSourceManager } from '../data-source/data-source/DataSourceManagerProvider';
import { useCollectionHistory } from './CollectionHistoryProvider';
import { useAppSpin } from '../application/hooks/useAppSpin';
import { CollectionManagerSchemaComponentProvider } from './CollectionManagerSchemaComponentProvider';
import { CollectionCategroriesContext } from './context';
import { CollectionManagerOptions } from './types';
/**
* @deprecated use `CollectionManagerProvider` instead
@ -28,18 +28,19 @@ export const CollectionManagerProvider_deprecated: React.FC<CollectionManagerOpt
);
};
export const RemoteCollectionManagerProvider = (props: any) => {
const api = useAPIClient();
const dm = useDataSourceManager();
const { refreshCH } = useCollectionHistory();
const coptions = {
const coptions = {
url: 'collectionCategories:list',
params: {
paginate: false,
sort: ['sort'],
},
};
};
export const RemoteCollectionManagerProvider = (props: any) => {
const api = useAPIClient();
const dm = useDataSourceManager();
const { refreshCH } = useCollectionHistory();
const service = useRequest<{
data: any;
}>(() => {
@ -50,17 +51,18 @@ export const RemoteCollectionManagerProvider = (props: any) => {
}>(coptions);
const { render } = useAppSpin();
const refreshCategory = useCallback(async () => {
const { data } = await api.request(coptions);
result.mutate(data);
return data?.data || [];
}, [result]);
if (service.loading) {
return render();
}
const refreshCategory = async () => {
const { data } = await api.request(coptions);
result.mutate(data);
return data?.data || [];
};
return (
<CollectionCategroriesProvider service={{ ...result }} refreshCategory={refreshCategory}>
<CollectionCategroriesProvider service={result} refreshCategory={refreshCategory}>
<CollectionManagerProvider_deprecated {...props}></CollectionManagerProvider_deprecated>
</CollectionCategroriesProvider>
);

View File

@ -9,21 +9,22 @@
import React from 'react';
import { SchemaComponentOptions } from '..';
import { CollectionProvider_deprecated, ResourceActionProvider, useDataSourceFromRAC } from '.';
import { CollectionProvider_deprecated } from './CollectionProvider_deprecated';
import { ResourceActionProvider, useDataSourceFromRAC } from './ResourceActionProvider';
import * as hooks from './action-hooks';
import { DataSourceProvider_deprecated, ds, SubFieldDataSourceProvider_deprecated } from './sub-table';
import { DataSourceProvider_deprecated, SubFieldDataSourceProvider_deprecated, ds } from './sub-table';
export const CollectionManagerSchemaComponentProvider: React.FC = (props) => {
return (
<SchemaComponentOptions
scope={{ cm: { ...hooks, useDataSourceFromRAC }, ds }}
components={{
const scope = { cm: { ...hooks, useDataSourceFromRAC }, ds };
const components = {
SubFieldDataSourceProvider_deprecated,
DataSourceProvider_deprecated,
CollectionProvider_deprecated,
ResourceActionProvider,
}}
>
};
export const CollectionManagerSchemaComponentProvider: React.FC = (props) => {
return (
<SchemaComponentOptions scope={scope} components={components}>
{props.children}
</SchemaComponentOptions>
);

View File

@ -7,17 +7,17 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { uid } from '@formily/shared';
import { CascaderProps } from 'antd';
import _ from 'lodash';
import { useCallback, useMemo, useState } from 'react';
import { useCompile, useSchemaComponentContext } from '../../schema-component';
import { CollectionFieldOptions_deprecated, CollectionOptions } from '../types';
import { InheritanceCollectionMixin } from '../mixins/InheritanceCollectionMixin';
import { uid } from '@formily/shared';
import { useCollectionManager } from '../../data-source/collection/CollectionManagerProvider';
import { DEFAULT_DATA_SOURCE_KEY } from '../../data-source/data-source/DataSourceManager';
import { useDataSourceManager } from '../../data-source/data-source/DataSourceManagerProvider';
import { useDataSource } from '../../data-source/data-source/DataSourceProvider';
import { DEFAULT_DATA_SOURCE_KEY } from '../../data-source/data-source/DataSourceManager';
import { useCollectionManager } from '../../data-source/collection/CollectionManagerProvider';
import { useCompile, useSchemaComponentContext } from '../../schema-component';
import { InheritanceCollectionMixin } from '../mixins/InheritanceCollectionMixin';
import { CollectionFieldOptions_deprecated, CollectionOptions } from '../types';
/**
* @deprecated use `useCollectionManager` instead
@ -273,9 +273,12 @@ export const useCollectionManager_deprecated = (dataSourceName?: string) => {
);
// 是否可以作为标题字段
const isTitleField = (field) => {
const isTitleField = useCallback(
(field) => {
return getInterface(field.interface)?.titleUsable;
};
},
[getInterface],
);
const getParentCollectionFields = useCallback(
(parentCollection, currentCollection, customDataSource?: string) => {

View File

@ -7,7 +7,6 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useFieldSchema } from '@formily/react';
import React, { FC, ReactNode, createContext, useContext, useMemo } from 'react';
import { ACLCollectionProvider } from '../../acl/ACLProvider';

View File

@ -38,7 +38,7 @@ function useCurrentRequest<T>(options: Omit<AllDataBlockProps, 'type'>) {
}
const paramsValue = params.filterByTk === undefined ? _.omit(params, 'filterByTk') : params;
return resource[action]({ ...paramsValue, ...customParams }).then((res) => res.data);
return resource[action]?.({ ...paramsValue, ...customParams }).then((res) => res.data);
};
}, [resource, action, JSON.stringify(params), JSON.stringify(record), requestService]);
@ -129,7 +129,16 @@ export const BlockRequestProvider: FC = ({ children }) => {
});
const memoizedParentRecord = useMemo(() => {
return parentRequest.data?.data && new CollectionRecord({ isNew: false, data: parentRequest.data?.data });
return (
parentRequest.data?.data &&
new CollectionRecord({
isNew: false,
data:
parentRequest.data?.data instanceof CollectionRecord
? parentRequest.data?.data.data
: parentRequest.data?.data,
})
);
}, [parentRequest.data?.data]);
return (
@ -137,13 +146,13 @@ export const BlockRequestProvider: FC = ({ children }) => {
{action !== 'list' ? (
<CollectionRecordProvider
isNew={action == null}
record={currentRequest.data?.data}
parentRecord={memoizedParentRecord}
record={currentRequest.data?.data || record}
parentRecord={memoizedParentRecord || parentRecord}
>
{children}
</CollectionRecordProvider>
) : (
<CollectionRecordProvider isNew={false} record={null} parentRecord={memoizedParentRecord}>
<CollectionRecordProvider isNew={false} record={null} parentRecord={memoizedParentRecord || parentRecord}>
{children}
</CollectionRecordProvider>
)}

View File

@ -40,7 +40,7 @@ export const DataBlockResourceProvider: FC<{ children?: ReactNode }> = ({ childr
const resource = useMemo(() => {
if (association) {
return api.resource(association, sourceIdValue, headers);
return api.resource(association, sourceIdValue, headers, !sourceIdValue);
}
return api.resource(collectionName, undefined, headers);
}, [api, association, collection, sourceIdValue, headers]);

View File

@ -40,7 +40,7 @@ export interface ForeignKeyField {
type Collection = ReturnType<typeof useCollection_deprecated>;
export interface DataBlock {
/** 唯一标识符schema 中的 name 值 */
/** 唯一标识符schema 中的 x-uid 值 */
uid: string;
/** 用户自行设置的区块名称 */
title?: string;

View File

@ -8,7 +8,8 @@
*/
import { expect, test } from '@nocobase/test/e2e';
import { OneTableWithDelete } from './templates';
import { OneTableWithDelete, shouldRefreshBlockDataAfterMultiplePopupsClosed } from './templates';
test.describe('action settings', () => {
test('refresh data on action', async ({ page, mockPage, mockRecords }) => {
await mockPage(OneTableWithDelete).goto();
@ -44,4 +45,24 @@ test.describe('action settings', () => {
await page.waitForTimeout(500);
expect(requestMade).toBeFalsy();
});
test('should refresh block data after multiple popups closed', async ({ page, mockPage, mockRecord }) => {
const nocoPage = await mockPage(shouldRefreshBlockDataAfterMultiplePopupsClosed).waitForInit();
await mockRecord('users', { username: 'Test', roles: [{ title: 'Test role' }] });
await nocoPage.goto();
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.getByLabel('block-item-CollectionField-').getByRole('textbox').fill('abc123');
await page.getByLabel('action-Action-Submit-submit-').click();
// the first popup
await expect(
page.getByTestId('drawer-Action.Container-users-Edit record').getByRole('button', { name: 'abc123' }),
).toBeVisible();
// 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();
});
});

View File

@ -8,7 +8,7 @@
*/
import { expect, test } from '@nocobase/test/e2e';
import { oneEmptyTableWithUsers } from './templates';
import { PopupAndSubPageWithParams, oneEmptyTableWithUsers } from './templates';
test.describe('Link', () => {
test('basic', async ({ page, mockPage, mockRecords }) => {
@ -84,4 +84,30 @@ test.describe('Link', () => {
await expect(page.getByRole('button', { name: 'nocobase', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: users[1].username, exact: true })).toBeVisible();
});
test('popup and sub page with search params', async ({ page, mockPage, mockRecords }) => {
const nocoPage = mockPage(PopupAndSubPageWithParams);
const url = await nocoPage.getUrl();
await page.goto(url + '?name=abc');
// open popup with drawer mode
await page.getByLabel('action-Action.Link-View-view-').click();
await expect(page.getByLabel('block-item-CollectionField-').getByRole('textbox')).toHaveValue('abc');
await page.reload();
await expect(page.getByLabel('block-item-CollectionField-').getByRole('textbox')).toHaveValue('abc');
await page.goBack();
await page.getByLabel('action-Action.Link-View-view-').hover();
await page.getByLabel('designer-schema-settings-Action.Link-actionSettings:view-users').hover();
await page.getByRole('menuitem', { name: 'Open mode Drawer' }).click();
await page.getByRole('option', { name: 'Page' }).click();
// open sub page with page mode
await page.getByLabel('action-Action.Link-View-view-').click();
await expect(page.getByLabel('block-item-CollectionField-').getByRole('textbox')).toHaveValue('abc');
await page.reload();
await expect(page.getByLabel('block-item-CollectionField-').getByRole('textbox')).toHaveValue('abc');
});
});

View File

@ -222,3 +222,368 @@ export const oneEmptyTableWithUsers = {
'x-index': 1,
},
};
export const PopupAndSubPageWithParams = {
pageSchema: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Page',
properties: {
b1atmxyqbff: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'page:addBlock',
properties: {
rkebzj98nvq: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.2.11-alpha',
properties: {
kxz1o673sem: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.2.11-alpha',
properties: {
t79t7o2scw5: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-decorator': 'TableBlockProvider',
'x-acl-action': 'users:list',
'x-use-decorator-props': 'useTableBlockDecoratorProps',
'x-decorator-props': {
collection: 'users',
dataSource: 'main',
action: 'list',
params: {
pageSize: 20,
},
rowKey: 'id',
showIndex: true,
dragSort: false,
},
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': 'blockSettings:table',
'x-component': 'CardItem',
'x-filter-targets': [],
'x-app-version': '1.2.11-alpha',
properties: {
actions: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-initializer': 'table:configureActions',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 'var(--nb-spacing)',
},
},
'x-app-version': '1.2.11-alpha',
'x-uid': 'm3lmb6s616o',
'x-async': false,
'x-index': 1,
},
sghz67ot4pa: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'array',
'x-initializer': 'table:configureColumns',
'x-component': 'TableV2',
'x-use-component-props': 'useTableBlockProps',
'x-component-props': {
rowKey: 'id',
rowSelection: {
type: 'checkbox',
},
},
'x-app-version': '1.2.11-alpha',
properties: {
actions: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("Actions") }}',
'x-action-column': 'actions',
'x-decorator': 'TableV2.Column.ActionBar',
'x-component': 'TableV2.Column',
'x-toolbar': 'TableColumnSchemaToolbar',
'x-initializer': 'table:configureItemActions',
'x-settings': 'fieldSettings:TableColumn',
'x-toolbar-props': {
initializer: 'table:configureItemActions',
},
'x-app-version': '1.2.11-alpha',
properties: {
ebq18cea3uo: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-decorator': 'DndContext',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
'x-app-version': '1.2.11-alpha',
properties: {
c14cpxkc4ke: {
'x-uid': 'ech5j6ogus0',
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("View") }}',
'x-action': 'view',
'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'actionSettings:view',
'x-component': 'Action.Link',
'x-component-props': {
openMode: 'drawer',
},
'x-action-context': {
dataSource: 'main',
collection: 'users',
},
'x-decorator': 'ACLActionProvider',
'x-designer-props': {
linkageAction: true,
},
properties: {
drawer: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("View record") }}',
'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: '{{t("Details")}}',
'x-component': 'Tabs.TabPane',
'x-designer': 'Tabs.Designer',
'x-component-props': {},
properties: {
grid: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'popup:common:addBlock',
properties: {
z1h7wnh6s60: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.2.11-alpha',
properties: {
bxzkcif7vu9: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.2.11-alpha',
properties: {
e2pjbwaou03: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-acl-action-props': {
skipScopeCheck: true,
},
'x-acl-action': 'users:create',
'x-decorator': 'FormBlockProvider',
'x-use-decorator-props':
'useCreateFormBlockDecoratorProps',
'x-decorator-props': {
dataSource: 'main',
collection: 'users',
isCusomeizeCreate: true,
},
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': 'blockSettings:createForm',
'x-component': 'CardItem',
'x-app-version': '1.2.11-alpha',
properties: {
j86fmzva2n0: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'FormV2',
'x-use-component-props': 'useCreateFormBlockProps',
'x-app-version': '1.2.11-alpha',
properties: {
grid: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'form:configureFields',
'x-app-version': '1.2.11-alpha',
properties: {
s1cu9qfbyx6: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.2.11-alpha',
properties: {
rmck9ducuw7: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.2.11-alpha',
properties: {
nickname: {
'x-uid': '22a0rhtgo1x',
_isJSONSchemaObject: true,
version: '2.0',
type: 'string',
'x-toolbar':
'FormItemSchemaToolbar',
'x-settings':
'fieldSettings:FormItem',
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-collection-field':
'users.nickname',
'x-component-props': {},
'x-app-version': '1.2.11-alpha',
default:
'{{$nURLSearchParams.name}}',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'eudbw9veltu',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'jdt8bqzqi2s',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '5hq25kdeet9',
'x-async': false,
'x-index': 1,
},
kke3cg70wha: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-initializer': 'createForm:configureActions',
'x-component': 'ActionBar',
'x-component-props': {
layout: 'one-column',
},
'x-app-version': '1.2.11-alpha',
'x-uid': 'usbcj3rxwdq',
'x-async': false,
'x-index': 2,
},
},
'x-uid': 'xk49vnplykb',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'xz0fzo8aiwd',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'c2z7nkp1er8',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '817scrzocq2',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'o5aa5p4qdfy',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'cyys2ghczmx',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'pte28pwapoh',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'nnc6fz5v38k',
'x-async': false,
'x-index': 1,
},
},
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'nba9caa8pc1',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '14vqyxwyyty',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'tu8n9op1z9r',
'x-async': false,
'x-index': 2,
},
},
'x-uid': 'v2gig2bsyex',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '9r7wta3gbml',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'zj3df6jm0dh',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'fz1jj9rlu87',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'de0k4foh9cc',
'x-async': true,
'x-index': 1,
},
};

View File

@ -196,3 +196,780 @@ export const OneTableWithDelete = {
'x-index': 1,
},
};
export const shouldRefreshBlockDataAfterMultiplePopupsClosed = {
pageSchema: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Page',
properties: {
tyx0aioxlr1: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'page:addBlock',
properties: {
'0i5raczm0ne': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.2.11-alpha',
properties: {
zjk1te2zpix: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.2.11-alpha',
properties: {
k7eyfysw52n: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-decorator': 'TableBlockProvider',
'x-acl-action': 'users:list',
'x-use-decorator-props': 'useTableBlockDecoratorProps',
'x-decorator-props': {
collection: 'users',
dataSource: 'main',
action: 'list',
params: {
pageSize: 20,
},
rowKey: 'id',
showIndex: true,
dragSort: false,
},
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': 'blockSettings:table',
'x-component': 'CardItem',
'x-filter-targets': [],
'x-app-version': '1.2.11-alpha',
properties: {
actions: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-initializer': 'table:configureActions',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 'var(--nb-spacing)',
},
},
'x-app-version': '1.2.11-alpha',
'x-uid': 'fkgbia9i3kt',
'x-async': false,
'x-index': 1,
},
pn8pb7x81kh: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'array',
'x-initializer': 'table:configureColumns',
'x-component': 'TableV2',
'x-use-component-props': 'useTableBlockProps',
'x-component-props': {
rowKey: 'id',
rowSelection: {
type: 'checkbox',
},
},
'x-app-version': '1.2.11-alpha',
properties: {
actions: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("Actions") }}',
'x-action-column': 'actions',
'x-decorator': 'TableV2.Column.ActionBar',
'x-component': 'TableV2.Column',
'x-toolbar': 'TableColumnSchemaToolbar',
'x-initializer': 'table:configureItemActions',
'x-settings': 'fieldSettings:TableColumn',
'x-toolbar-props': {
initializer: 'table:configureItemActions',
},
'x-app-version': '1.2.11-alpha',
properties: {
g3flunks833: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-decorator': 'DndContext',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
'x-app-version': '1.2.11-alpha',
properties: {
noihi5pm44q: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("Edit") }}',
'x-action': 'update',
'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'actionSettings:edit',
'x-component': 'Action.Link',
'x-component-props': {
openMode: 'drawer',
icon: 'EditOutlined',
},
'x-action-context': {
dataSource: 'main',
collection: 'users',
},
'x-decorator': 'ACLActionProvider',
'x-designer-props': {
linkageAction: true,
},
properties: {
drawer: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("Edit record") }}',
'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: '{{t("Edit")}}',
'x-component': 'Tabs.TabPane',
'x-designer': 'Tabs.Designer',
'x-component-props': {},
properties: {
grid: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'popup:common:addBlock',
properties: {
suv1o9u1ti1: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.2.11-alpha',
properties: {
oqzxplx2qap: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.2.11-alpha',
properties: {
uxrg8z6qz3w: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-decorator': 'TableBlockProvider',
'x-acl-action': 'users.roles:list',
'x-use-decorator-props': 'useTableBlockDecoratorProps',
'x-decorator-props': {
association: 'users.roles',
dataSource: 'main',
action: 'list',
params: {
pageSize: 20,
},
rowKey: 'name',
showIndex: true,
dragSort: false,
},
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': 'blockSettings:table',
'x-component': 'CardItem',
'x-filter-targets': [],
'x-app-version': '1.2.11-alpha',
properties: {
actions: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-initializer': 'table:configureActions',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 'var(--nb-spacing)',
},
},
'x-app-version': '1.2.11-alpha',
'x-uid': 'nzb7njucb1g',
'x-async': false,
'x-index': 1,
},
prcxluma72r: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'array',
'x-initializer': 'table:configureColumns',
'x-component': 'TableV2',
'x-use-component-props': 'useTableBlockProps',
'x-component-props': {
rowKey: 'id',
rowSelection: {
type: 'checkbox',
},
},
'x-app-version': '1.2.11-alpha',
properties: {
actions: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("Actions") }}',
'x-action-column': 'actions',
'x-decorator': 'TableV2.Column.ActionBar',
'x-component': 'TableV2.Column',
'x-toolbar': 'TableColumnSchemaToolbar',
'x-initializer': 'table:configureItemActions',
'x-settings': 'fieldSettings:TableColumn',
'x-toolbar-props': {
initializer: 'table:configureItemActions',
},
'x-app-version': '1.2.11-alpha',
properties: {
z3n08qgeo8y: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-decorator': 'DndContext',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
'x-app-version': '1.2.11-alpha',
properties: {
pla7f5mwirx: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("Edit") }}',
'x-action': 'update',
'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'actionSettings:edit',
'x-component': 'Action.Link',
'x-component-props': {
openMode: 'drawer',
icon: 'EditOutlined',
},
'x-action-context': {
dataSource: 'main',
association: 'users.roles',
sourceId: 1,
parentPopupRecord: {
collection: 'users',
filterByTk: 1,
},
},
'x-decorator': 'ACLActionProvider',
'x-designer-props': {
linkageAction: true,
},
properties: {
drawer: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("Edit record") }}',
'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: '{{t("Edit")}}',
'x-component':
'Tabs.TabPane',
'x-designer':
'Tabs.Designer',
'x-component-props': {},
properties: {
grid: {
_isJSONSchemaObject:
true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer':
'popup:common:addBlock',
properties: {
k6k23ljfmwv: {
_isJSONSchemaObject:
true,
version: '2.0',
type: 'void',
'x-component':
'Grid.Row',
'x-app-version':
'1.2.11-alpha',
properties: {
ddkwboqqe19: {
_isJSONSchemaObject:
true,
version: '2.0',
type: 'void',
'x-component':
'Grid.Col',
'x-app-version':
'1.2.11-alpha',
properties: {
'33h5pkdwmt5':
{
_isJSONSchemaObject:
true,
version:
'2.0',
type: 'void',
'x-acl-action-props':
{
skipScopeCheck:
false,
},
'x-acl-action':
'users.roles:update',
'x-decorator':
'FormBlockProvider',
'x-use-decorator-props':
'useEditFormBlockDecoratorProps',
'x-decorator-props':
{
action:
'get',
dataSource:
'main',
association:
'users.roles',
},
'x-toolbar':
'BlockSchemaToolbar',
'x-settings':
'blockSettings:editForm',
'x-component':
'CardItem',
'x-is-current':
true,
'x-app-version':
'1.2.11-alpha',
properties:
{
'7t3234fb1yu':
{
_isJSONSchemaObject:
true,
version:
'2.0',
type: 'void',
'x-component':
'FormV2',
'x-use-component-props':
'useEditFormBlockProps',
'x-app-version':
'1.2.11-alpha',
properties:
{
grid: {
_isJSONSchemaObject:
true,
version:
'2.0',
type: 'void',
'x-component':
'Grid',
'x-initializer':
'form:configureFields',
'x-app-version':
'1.2.11-alpha',
properties:
{
xizv324ooej:
{
_isJSONSchemaObject:
true,
version:
'2.0',
type: 'void',
'x-component':
'Grid.Row',
'x-app-version':
'1.2.11-alpha',
properties:
{
gjzwqen45hw:
{
_isJSONSchemaObject:
true,
version:
'2.0',
type: 'void',
'x-component':
'Grid.Col',
'x-app-version':
'1.2.11-alpha',
properties:
{
title:
{
_isJSONSchemaObject:
true,
version:
'2.0',
type: 'string',
'x-toolbar':
'FormItemSchemaToolbar',
'x-settings':
'fieldSettings:FormItem',
'x-component':
'CollectionField',
'x-decorator':
'FormItem',
'x-collection-field':
'roles.title',
'x-component-props':
{},
'x-app-version':
'1.2.11-alpha',
'x-uid':
'i1q5apprfo3',
'x-async':
false,
'x-index': 1,
},
},
'x-uid':
'cyrc9goozvc',
'x-async':
false,
'x-index': 1,
},
},
'x-uid':
'1yonjmmam2w',
'x-async':
false,
'x-index': 1,
},
},
'x-uid':
'yzom6z8f3t2',
'x-async':
false,
'x-index': 1,
},
u0l41h24oqc:
{
_isJSONSchemaObject:
true,
version:
'2.0',
type: 'void',
'x-initializer':
'editForm:configureActions',
'x-component':
'ActionBar',
'x-component-props':
{
layout:
'one-column',
},
'x-app-version':
'1.2.11-alpha',
properties:
{
se7ggsy8wv0:
{
_isJSONSchemaObject:
true,
version:
'2.0',
title:
'{{ t("Submit") }}',
'x-action':
'submit',
'x-component':
'Action',
'x-use-component-props':
'useUpdateActionProps',
'x-toolbar':
'ActionSchemaToolbar',
'x-settings':
'actionSettings:updateSubmit',
'x-component-props':
{
type: 'primary',
htmlType:
'submit',
},
'x-action-settings':
{
triggerWorkflows:
[],
},
type: 'void',
'x-app-version':
'1.2.11-alpha',
'x-uid':
'38c98jagvqk',
'x-async':
false,
'x-index': 1,
},
},
'x-uid':
'4s6eki715ub',
'x-async':
false,
'x-index': 2,
},
},
'x-uid':
'xnnwdz56xzc',
'x-async':
false,
'x-index': 1,
},
},
'x-uid':
'zvr9ezaqhn8',
'x-async':
false,
'x-index': 1,
},
},
'x-uid':
'l3xw7vpnnmi',
'x-async':
false,
'x-index': 1,
},
},
'x-uid':
'mydjjzt5wjb',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '1a12yaxkvlh',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '28wuyvfuzxa',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '739bd4xxp5r',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'sc3bt4kz43j',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'bxmc5k79nhw',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '4hz3jkt2wfo',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'x6x0ygv9b0s',
'x-async': false,
'x-index': 1,
},
lyn5o91qf9v: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-decorator': 'TableV2.Column.Decorator',
'x-toolbar': 'TableColumnSchemaToolbar',
'x-settings': 'fieldSettings:TableColumn',
'x-component': 'TableV2.Column',
'x-app-version': '1.2.11-alpha',
properties: {
title: {
_isJSONSchemaObject: true,
version: '2.0',
'x-collection-field': 'roles.title',
'x-component': 'CollectionField',
'x-component-props': {
ellipsis: true,
},
'x-read-pretty': true,
'x-decorator': null,
'x-decorator-props': {
labelStyle: {
display: 'none',
},
},
'x-app-version': '1.2.11-alpha',
'x-uid': 'a47ajmlmdq4',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'v3uq1qkh0f1',
'x-async': false,
'x-index': 2,
},
},
'x-uid': 'qxusos5qytp',
'x-async': false,
'x-index': 2,
},
},
'x-uid': 'skdhbz3b5wb',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '9keek6gthp6',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'n6k49p6zvw5',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'sod8zbrugmq',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'lt3vak0nmab',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'g1ee1hpkd4p',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'p4enijxueeg',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'bxd64x3df56',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '0y4f0f650h9',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '2qk92ft951c',
'x-async': false,
'x-index': 1,
},
fv22e8igzgz: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-decorator': 'TableV2.Column.Decorator',
'x-toolbar': 'TableColumnSchemaToolbar',
'x-settings': 'fieldSettings:TableColumn',
'x-component': 'TableV2.Column',
'x-app-version': '1.2.11-alpha',
properties: {
roles: {
'x-uid': 'zkenfmew3f7',
_isJSONSchemaObject: true,
version: '2.0',
'x-collection-field': 'users.roles',
'x-component': 'CollectionField',
'x-component-props': {
fieldNames: {
label: 'title',
value: 'name',
},
ellipsis: true,
size: 'small',
},
'x-read-pretty': true,
'x-decorator': null,
'x-decorator-props': {
labelStyle: {
display: 'none',
},
},
'x-app-version': '1.2.11-alpha',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '2fmv2b2n8it',
'x-async': false,
'x-index': 2,
},
},
'x-uid': 'n48nmuoljua',
'x-async': false,
'x-index': 2,
},
},
'x-uid': 'oiu3t5hup6s',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'kc32hfy3m57',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'aayu9ix6qh6',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'vutamnve682',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'da87s7d7xs0',
'x-async': true,
'x-index': 1,
},
};

View File

@ -8,8 +8,11 @@
*/
import React from 'react';
import { usePagePopup } from '../../../schema-component/antd/page/pagePopupUtils';
import { CONTEXT_SCHEMA_KEY } from '../../../schema-component/antd/page/usePopupContextInActionOrAssociationField';
import { ActionInitializerItem } from '../../../schema-initializer/items/ActionInitializerItem';
export const CreateChildInitializer = (props) => {
const { getPopupContext } = usePagePopup();
const schema = {
type: 'void',
title: '{{ t("Add child") }}',
@ -63,6 +66,7 @@ export const CreateChildInitializer = (props) => {
},
},
},
[CONTEXT_SCHEMA_KEY]: getPopupContext(),
};
return <ActionInitializerItem {...props} schema={schema} />;
};

View File

@ -9,9 +9,12 @@
import React from 'react';
import { useSchemaInitializerItem } from '../../../application';
import { usePagePopup } from '../../../schema-component/antd/page/pagePopupUtils';
import { CONTEXT_SCHEMA_KEY } from '../../../schema-component/antd/page/usePopupContextInActionOrAssociationField';
import { ActionInitializerItem } from '../../../schema-initializer/items/ActionInitializerItem';
export const CreateActionInitializer = () => {
const { getPopupContext } = usePagePopup();
const schema = {
type: 'void',
'x-action': 'create',
@ -65,6 +68,7 @@ export const CreateActionInitializer = () => {
},
},
},
[CONTEXT_SCHEMA_KEY]: getPopupContext(),
};
const itemConfig = useSchemaInitializerItem();
return <ActionInitializerItem {...itemConfig} item={itemConfig} schema={schema} />;

View File

@ -9,9 +9,12 @@
import React from 'react';
import { useSchemaInitializerItem } from '../../../application';
import { usePagePopup } from '../../../schema-component/antd/page/pagePopupUtils';
import { CONTEXT_SCHEMA_KEY } from '../../../schema-component/antd/page/usePopupContextInActionOrAssociationField';
import { BlockInitializer } from '../../../schema-initializer/items';
export const PopupActionInitializer = (props) => {
const { getPopupContext } = usePagePopup();
const schema = {
type: 'void',
title: '{{ t("Popup") }}',
@ -58,6 +61,7 @@ export const PopupActionInitializer = (props) => {
},
},
},
[CONTEXT_SCHEMA_KEY]: getPopupContext(),
};
const itemConfig = useSchemaInitializerItem();

View File

@ -8,9 +8,12 @@
*/
import React from 'react';
import { usePagePopup } from '../../../schema-component/antd/page/pagePopupUtils';
import { CONTEXT_SCHEMA_KEY } from '../../../schema-component/antd/page/usePopupContextInActionOrAssociationField';
import { ActionInitializerItem } from '../../../schema-initializer/items/ActionInitializerItem';
export const UpdateActionInitializer = (props) => {
const { getPopupContext } = usePagePopup();
const schema = {
type: 'void',
title: '{{ t("Edit") }}',
@ -57,6 +60,7 @@ export const UpdateActionInitializer = (props) => {
},
},
},
[CONTEXT_SCHEMA_KEY]: getPopupContext(),
};
return <ActionInitializerItem {...props} schema={schema} />;
};

View File

@ -8,9 +8,12 @@
*/
import React from 'react';
import { usePagePopup } from '../../../schema-component/antd/page/pagePopupUtils';
import { CONTEXT_SCHEMA_KEY } from '../../../schema-component/antd/page/usePopupContextInActionOrAssociationField';
import { ActionInitializerItem } from '../../../schema-initializer/items/ActionInitializerItem';
export const ViewActionInitializer = (props) => {
const { getPopupContext } = usePagePopup();
const schema = {
type: 'void',
title: '{{ t("View") }}',
@ -56,6 +59,7 @@ export const ViewActionInitializer = (props) => {
},
},
},
[CONTEXT_SCHEMA_KEY]: getPopupContext(),
};
return <ActionInitializerItem {...props} schema={schema} />;
};

View File

@ -37,6 +37,7 @@ test.describe('where single data details block can be added', () => {
await nocoPage.goto();
// 1.打开弹窗
await page.getByRole('button', { name: '2', exact: true }).getByText('2').hover();
await page.getByRole('button', { name: '2', exact: true }).getByText('2').click();
// 2.通过 Current record 创建一个详情区块
@ -121,7 +122,7 @@ test.describe('configure actions', () => {
// create delete ------------------------------------------------------------------------------------
await createAction(page, 'Delete');
await expect(page.getByLabel('action-Action-Delete-destroy-general-details-')).toBeVisible();
await expect(page.getByLabel('action-Action-Delete-destroy-general-details')).toBeVisible();
// create print
await createAction(page, 'Print');

View File

@ -48,13 +48,13 @@ test.describe('association form block', () => {
test('association table block add new ', async ({ page, mockPage, mockRecord }) => {
await mockPage(T3979).goto();
await mockRecord('general');
await expect(await page.getByLabel('block-item-CardItem-general-')).toBeVisible();
await expect(page.getByLabel('block-item-CardItem-general-')).toBeVisible();
// 1. 打开关系字段弹窗
await page.getByLabel('block-item-CardItem-general-').locator('a').click();
await page.getByLabel('block-item-CardItem-roles-').click();
// 2. 提交后Table 会显示新增的数据
await page.getByLabel('action-Action-Add new-create-roles-table-').click();
await page.getByLabel('action-Action-Add new-create-roles-table').click();
// 3. 区块数据表为关系字段的区块
await page
@ -64,6 +64,6 @@ test.describe('association form block', () => {
await page.getByRole('menuitem', { name: 'form Form' }).hover();
await page.getByRole('menuitem', { name: 'Current collection' }).click();
await expect(await page.getByLabel('block-item-CardItem-roles-form')).toBeVisible();
await expect(page.getByLabel('block-item-CardItem-roles-form')).toBeVisible();
});
});

View File

@ -17,7 +17,15 @@ import {
test,
} from '@nocobase/test/e2e';
import { oneEmptyTableWithUsers } from '../../../details-multi/__e2e__/templatesOfBug';
import { T2174, T3871, oneFormAndOneTableWithUsers, oneTableWithNestPopups } from './templatesOfBug';
import {
T2174,
T3871,
currentPopupRecordInPopupThatOpenedByAssociationField,
oneFormAndOneTableWithUsers,
oneTableWithNestPopups,
parentPopupRecordInSubPageTheFirstLevelIsASubpageAndTheSecondLevelIsAPopup,
parentPopupRecordInSubPageTheFirstLevelIsASubpageAndTheSecondLevelIsASubpageToo,
} from './templatesOfBug';
test.describe('set default value', () => {
test('basic fields', async ({ page, mockPage }) => {
@ -203,7 +211,6 @@ test.describe('set default value', () => {
// https://nocobase.height.app/T-4028/description
// 刷新页面后,默认值应该依然存在
await page.reload();
await page.getByRole('button', { name: 'Add new' }).click();
await page
.getByTestId('drawer-Action.Container-general-Add record')
.getByRole('button', { name: 'Add new' })
@ -363,11 +370,51 @@ test.describe('set default value', () => {
).toBeVisible();
});
test('Current popup record in popup that opened by association field', async ({ mockPage, page }) => {
await mockPage(currentPopupRecordInPopupThatOpenedByAssociationField).goto();
await page.getByText('Member').click();
// Current popup record in the first popup
await expect(page.getByLabel('block-item-CollectionField-').getByRole('textbox')).toHaveValue('Member');
// Current popup record and Parent popup record in the second popup
await page.getByLabel('action-Action-Edit-update-').click();
await expect(
page
.getByTestId('drawer-Action.Container-roles-Edit record')
.getByLabel('block-item-CollectionField-users-form-users.nickname-Current popup record')
.getByRole('textbox'),
).toHaveValue('Member');
await expect(
page.getByLabel('block-item-CollectionField-users-form-users.username-Parent popup record').getByRole('textbox'),
).toHaveValue('Member');
});
test('Parent popup record', async ({ page, mockPage }) => {
await mockPage(oneTableWithNestPopups).goto();
// 1. 表单字段默认值中使用 `Parent popup record`
await page.getByLabel('action-Action.Link-View').click();
// 在第一级弹窗中,不应该包含 Parent popup record 变量
await page
.getByTestId('drawer-Action.Container-users-View record')
.getByLabel('block-item-CardItem-users-')
.hover();
await page
.getByTestId('drawer-Action.Container-users-View record')
.getByLabel('designer-schema-settings-CardItem-blockSettings:table-users')
.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();
await expect(page.getByRole('menuitemcheckbox', { name: 'Current popup record right' })).toBeVisible();
await expect(page.getByRole('menuitemcheckbox', { name: 'Parent popup record right' })).not.toBeVisible();
// 关闭数据范围设置弹窗
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').nth(1).hover();
await page.getByRole('menuitem', { name: 'form Form (Add new) right' }).hover();
@ -462,11 +509,273 @@ test.describe('set default value', () => {
await page.getByLabel('designer-schema-settings-CollectionField-fieldSettings:FormItem-users-users.').hover();
await page.getByRole('menuitem', { name: 'Set default value' }).click();
await page.getByLabel('variable-button').click();
await expect(page.getByRole('menuitemcheckbox', { name: 'Current popup record right' })).not.toBeVisible();
await page.getByRole('menuitemcheckbox', { name: 'Parent popup record right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Nickname' }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click();
await expect(page.getByLabel('block-item-CollectionField-').getByRole('textbox')).toHaveValue('Super Admin');
});
test('Parent popup record in sub page. The first level is a subpage, and the second level is a popup', async ({
page,
mockPage,
}) => {
await mockPage(parentPopupRecordInSubPageTheFirstLevelIsASubpageAndTheSecondLevelIsAPopup).goto();
// 1. 表单字段默认值中使用 `Parent popup record`
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.getByRole('menuitem', { name: 'Set the data scope' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByLabel('variable-button').click();
await expect(page.getByRole('menuitemcheckbox', { name: 'Current popup record right' })).toBeVisible();
await expect(page.getByRole('menuitemcheckbox', { name: 'Parent popup record right' })).not.toBeVisible();
// 关闭数据范围设置弹窗
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').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();
await page.mouse.move(300, 0);
await page.getByLabel('schema-initializer-Grid-form:').hover();
await page.getByRole('menuitem', { name: 'Nickname' }).click();
await page.getByLabel('block-item-CollectionField-').hover();
await page.getByLabel('designer-schema-settings-CollectionField-fieldSettings:FormItem-users-users.').hover();
await page.getByRole('menuitem', { name: 'Set default value' }).click();
await page.getByLabel('variable-button').click();
await page.getByRole('menuitemcheckbox', { name: 'Parent popup record right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Nickname' }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click();
await expect(page.getByLabel('block-item-CollectionField-').getByRole('textbox')).toHaveValue('Super Admin');
await page.reload();
await expect(page.getByLabel('block-item-CollectionField-').getByRole('textbox')).toHaveValue('Super Admin');
// 2. 表单联动规则中使用 `Parent popup record`
// 创建 Username 字段
await page.getByLabel('schema-initializer-Grid-form:').hover();
await page.getByRole('menuitem', { name: 'Username' }).click();
// 设置联动规则
await page.getByLabel('block-item-CardItem-users-form').hover();
await page.getByLabel('designer-schema-settings-CardItem-blockSettings:createForm-users').hover();
await page.getByRole('menuitem', { name: 'Linkage rules' }).click();
await page.mouse.move(300, 0);
await page.getByRole('button', { name: 'plus Add linkage rule' }).click();
await page.getByText('Add property').click();
await page.getByTestId('select-linkage-property-field').click();
await page.getByTitle('Username').click();
await page.getByTestId('select-linkage-action-field').click();
await page.getByRole('option', { name: 'Value', exact: true }).click();
await page.getByTestId('select-linkage-value-type').click();
await page.getByTitle('Expression').click();
await page.getByLabel('variable-button').click();
await page.getByRole('menuitemcheckbox', { name: 'Parent popup record right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Username' }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click();
// 需正确显示变量的值
await expect(
page.getByLabel('block-item-CollectionField-users-form-users.username-Username').getByRole('textbox'),
).toHaveValue('nocobase');
await page.reload();
await expect(
page.getByLabel('block-item-CollectionField-users-form-users.username-Username').getByRole('textbox'),
).toHaveValue('nocobase');
// 3. Table 数据选择器中使用 `Parent popup record`
// 创建 Table 区块
await page.getByLabel('schema-initializer-Grid-popup').nth(1).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
.getByTestId('drawer-Action.Container-users-View record')
.getByLabel('schema-initializer-TableV2-')
.hover();
await page.getByRole('menuitem', { name: 'Nickname' }).click();
await page.mouse.move(300, 0);
// 设置数据范围(使用 `Parent popup record` 变量)
await page
.getByTestId('drawer-Action.Container-users-View record')
.getByLabel('block-item-CardItem-users-table')
.hover();
await page
.getByTestId('drawer-Action.Container-users-View record')
.getByLabel('designer-schema-settings-CardItem-blockSettings:table-users')
.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();
await page.getByRole('menuitemcheckbox', { name: 'Nickname' }).click();
await page.getByLabel('variable-button').click();
await page.getByRole('menuitemcheckbox', { name: 'Parent popup record right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Nickname' }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click();
// 数据需显示正确
await expect(
page
.getByTestId('drawer-Action.Container-users-View record')
.getByLabel('block-item-CardItem-users-table')
.getByRole('button', { name: 'Super Admin' }),
).toBeVisible();
await page.reload();
await expect(
page
.getByTestId('drawer-Action.Container-users-View record')
.getByLabel('block-item-CardItem-users-table')
.getByRole('button', { name: 'Super Admin' }),
).toBeVisible();
// 4. 退出二级弹窗,在第一级弹窗中点击 Add new 按钮
await page.goBack();
await page.getByLabel('action-Action-Add new-create-').click();
// 5. 在新增表单中使用 `Parent popup record`
await page.getByLabel('block-item-CollectionField-').hover();
await page.getByLabel('designer-schema-settings-CollectionField-fieldSettings:FormItem-users-users.').hover();
await page.getByRole('menuitem', { name: 'Set default value' }).click();
await page.getByLabel('variable-button').click();
await expect(page.getByRole('menuitemcheckbox', { name: 'Current popup record right' })).not.toBeVisible();
await page.getByRole('menuitemcheckbox', { name: 'Parent popup record right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Nickname' }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click();
await expect(page.getByLabel('block-item-CollectionField-').getByRole('textbox')).toHaveValue('Super Admin');
await page.reload();
await expect(page.getByLabel('block-item-CollectionField-').getByRole('textbox')).toHaveValue('Super Admin');
});
test('Parent popup record in sub page. The first level is a subpage, and the second level is a subpage too', async ({
page,
mockPage,
}) => {
await mockPage(parentPopupRecordInSubPageTheFirstLevelIsASubpageAndTheSecondLevelIsASubpageToo).goto();
// 1. 表单字段默认值中使用 `Parent popup record`
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.getByRole('menuitem', { name: 'Set the data scope' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByLabel('variable-button').click();
await expect(page.getByRole('menuitemcheckbox', { name: 'Current popup record right' })).toBeVisible();
await expect(page.getByRole('menuitemcheckbox', { name: 'Parent popup record right' })).not.toBeVisible();
// 关闭数据范围设置弹窗
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.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();
await page.mouse.move(300, 0);
await page.getByLabel('schema-initializer-Grid-form:').hover();
await page.getByRole('menuitem', { name: 'Nickname' }).click();
await page.getByLabel('block-item-CollectionField-').hover();
await page.getByLabel('designer-schema-settings-CollectionField-fieldSettings:FormItem-users-users.').hover();
await page.getByRole('menuitem', { name: 'Set default value' }).click();
await page.getByLabel('variable-button').click();
await page.getByRole('menuitemcheckbox', { name: 'Parent popup record right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Nickname' }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click();
await expect(page.getByLabel('block-item-CollectionField-').getByRole('textbox')).toHaveValue('Super Admin');
await page.reload();
await expect(page.getByLabel('block-item-CollectionField-').getByRole('textbox')).toHaveValue('Super Admin');
// 2. 表单联动规则中使用 `Parent popup record`
// 创建 Username 字段
await page.getByLabel('schema-initializer-Grid-form:').hover();
await page.getByRole('menuitem', { name: 'Username' }).click();
// 设置联动规则
await page.getByLabel('block-item-CardItem-users-form').hover();
await page.getByLabel('designer-schema-settings-CardItem-blockSettings:createForm-users').hover();
await page.getByRole('menuitem', { name: 'Linkage rules' }).click();
await page.mouse.move(300, 0);
await page.getByRole('button', { name: 'plus Add linkage rule' }).click();
await page.getByText('Add property').click();
await page.getByTestId('select-linkage-property-field').click();
await page.getByTitle('Username').click();
await page.getByTestId('select-linkage-action-field').click();
await page.getByRole('option', { name: 'Value', exact: true }).click();
await page.getByTestId('select-linkage-value-type').click();
await page.getByTitle('Expression').click();
await page.getByLabel('variable-button').click();
await page.getByRole('menuitemcheckbox', { name: 'Parent popup record right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Username' }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click();
// 需正确显示变量的值
await expect(
page.getByLabel('block-item-CollectionField-users-form-users.username-Username').getByRole('textbox'),
).toHaveValue('nocobase');
await page.reload();
await expect(
page.getByLabel('block-item-CollectionField-users-form-users.username-Username').getByRole('textbox'),
).toHaveValue('nocobase');
// 3. Table 数据选择器中使用 `Parent popup record`
// 创建 Table 区块
await page.getByLabel('schema-initializer-Grid-popup').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.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.getByRole('menuitem', { name: 'Set the data scope' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'Nickname' }).click();
await page.getByLabel('variable-button').click();
await page.getByRole('menuitemcheckbox', { name: 'Parent popup record right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Nickname' }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click();
// 数据需显示正确
await expect(
page.getByLabel('block-item-CardItem-users-table').getByRole('button', { name: 'Super Admin' }),
).toBeVisible();
await page.reload();
await expect(
page.getByLabel('block-item-CardItem-users-table').getByRole('button', { name: 'Super Admin' }),
).toBeVisible();
// 4. 退出二级弹窗,在第一级弹窗中点击 Add new 按钮
await page.goBack();
await page.getByLabel('action-Action-Add new-create-').click();
// 5. 在新增表单中使用 `Parent popup record`
await page.getByLabel('block-item-CollectionField-').hover();
await page.getByLabel('designer-schema-settings-CollectionField-fieldSettings:FormItem-users-users.').hover();
await page.getByRole('menuitem', { name: 'Set default value' }).click();
await page.getByLabel('variable-button').click();
await expect(page.getByRole('menuitemcheckbox', { name: 'Current popup record right' })).not.toBeVisible();
await page.getByRole('menuitemcheckbox', { name: 'Parent popup record right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Nickname' }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click();
await expect(page.getByLabel('block-item-CollectionField-').getByRole('textbox')).toHaveValue('Super Admin');
await page.reload();
await expect(page.getByLabel('block-item-CollectionField-').getByRole('textbox')).toHaveValue('Super Admin');
});
});
test.describe('actions schema settings', () => {

View File

@ -48,7 +48,6 @@ test.describe('creation form block schema settings', () => {
// 刷新页面后,显示的应该依然是上次设置的值
await page.reload();
await page.getByRole('button', { name: 'Add new' }).click();
await runExpect();
});
@ -68,7 +67,6 @@ test.describe('creation form block schema settings', () => {
// 刷新页面
await page.reload();
await page.getByRole('button', { name: 'Add new' }).click();
await page.getByLabel('block-item-CardItem-general-form').hover();
await page.getByLabel('designer-schema-settings-CardItem-FormV2.Designer-general').hover();
await expect(page.getByRole('menuitem', { name: 'Save as block template' })).not.toBeVisible();
@ -82,7 +80,6 @@ test.describe('creation form block schema settings', () => {
// 刷新页面
await page.reload();
await page.getByRole('button', { name: 'Add new' }).click();
await page.getByLabel('block-item-CardItem-general-form').hover();
await page.getByLabel('designer-schema-settings-CardItem-FormV2.Designer-general').hover();
await expect(page.getByRole('menuitem', { name: 'Save as block template' })).toBeVisible();
@ -115,7 +112,6 @@ test.describe('creation form block schema settings', () => {
// 刷新页面后,区块依然是被删除状态
await page.reload();
await page.getByRole('button', { name: 'Add new' }).click();
await expect(page.getByLabel('block-item-CardItem-general-form')).not.toBeVisible();
});

View File

@ -36,6 +36,7 @@ test.describe('where edit form block can be added', () => {
await nocoPage.goto();
// 1.打开弹窗
await page.getByRole('button', { name: '2', exact: true }).getByText('2').hover();
await page.getByRole('button', { name: '2', exact: true }).getByText('2').click();
// 2.创建一个编辑表单区块

View File

@ -50,7 +50,6 @@ test.describe('edit form block schema settings', () => {
// 刷新页面后,显示的应该依然是上次设置的值
await page.reload();
await page.getByText('Edit', { exact: true }).click();
await runExpect();
});
@ -81,7 +80,6 @@ test.describe('edit form block schema settings', () => {
// 刷新页面后,设置的值应该依然存在
await page.reload();
await page.getByText('Edit', { exact: true }).click();
await clickOption(page, 'Linkage rules');
await runExpect();
});
@ -104,7 +102,6 @@ test.describe('edit form block schema settings', () => {
// 刷新页面
await page.reload();
await page.getByText('Edit', { exact: true }).click();
await page.getByLabel('block-item-CardItem-general-form').hover();
await page.getByLabel('designer-schema-settings-CardItem-FormV2.Designer-general').hover();
await expect(page.getByRole('menuitem', { name: 'Save as block template' })).not.toBeVisible();
@ -117,7 +114,6 @@ test.describe('edit form block schema settings', () => {
// 刷新页面
await page.reload();
await page.getByText('Edit', { exact: true }).click();
await page.getByLabel('block-item-CardItem-general-form').hover();
await page.getByLabel('designer-schema-settings-CardItem-FormV2.Designer-general').hover();
await expect(page.getByRole('menuitem', { name: 'Save as block template' })).toBeVisible();
@ -152,30 +148,28 @@ test.describe('edit form block schema settings', () => {
// 刷新页面后,区块依然是被删除状态
await page.reload();
await page.getByText('Edit', { exact: true }).click();
await expect(page.getByLabel('block-item-CardItem-general-form')).not.toBeVisible();
});
// https://nocobase.height.app/T-3825
test('Unsaved changes warning display', async ({ page, mockPage, mockRecord }) => {
await mockPage(T3825).goto();
await mockRecord('general', { number: 9, formula: 10 });
await expect(await page.getByLabel('block-item-CardItem-general-')).toBeVisible();
await expect(page.getByLabel('block-item-CardItem-general-')).toBeVisible();
//没有改动时不显示提示
await page.getByLabel('action-Action.Link-Edit-').click();
await page.getByLabel('action-Action.Link-Edit record-update-general-table-').click();
await page.getByLabel('drawer-Action.Container-general-Edit record-mask').click();
await expect(await page.getByLabel('action-Action-Add new-create-')).toBeVisible();
await expect(page.getByLabel('action-Action-Add new-create-')).toBeVisible();
//有改动时显示提示
await page.getByLabel('action-Action.Link-Edit-').click();
await page.getByLabel('action-Action.Link-Edit record-update-general-table-').click();
await page.getByRole('spinbutton').fill('');
await page.getByRole('spinbutton').fill('10');
await expect(
await page
page
.getByLabel('block-item-CollectionField-general-form-general.formula-formula')
.locator('.nb-read-pretty-input-number')
.innerText(),
).toBe('11');
.locator('.nb-read-pretty-input-number'),
).toHaveText('11');
await page.getByLabel('drawer-Action.Container-general-Edit record-mask').click();
await expect(await page.getByText('Unsaved changes')).toBeVisible();
await expect(page.getByText('Unsaved changes')).toBeVisible();
});
});

View File

@ -426,7 +426,7 @@ export const T3825: PageConfig = {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("Edit") }}',
title: '{{ t("Edit record") }}',
'x-action': 'update',
'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'actionSettings:edit',

View File

@ -16,7 +16,6 @@ import { useAPIClient } from '../../../../api-client';
import { CompatibleSchemaInitializer } from '../../../../application/schema-initializer/CompatibleSchemaInitializer';
import { SchemaInitializerActionModal } from '../../../../application/schema-initializer/components/SchemaInitializerActionModal';
import { SchemaInitializerItem } from '../../../../application/schema-initializer/components/SchemaInitializerItem';
import { useSchemaInitializer } from '../../../../application/schema-initializer/context';
import { useCollection_deprecated } from '../../../../collection-manager';
import { SelectWithTitle } from '../../../../common/SelectWithTitle';
import { useDataBlockProps } from '../../../../data-source';

View File

@ -17,7 +17,13 @@ import {
oneTableBlockWithAddNewAndViewAndEditAndBasicFields,
test,
} from '@nocobase/test/e2e';
import { T3843, oneTableWithColumnFixed, oneTableWithUpdateRecord } from './templatesOfBug';
import {
T3843,
oneTableWithColumnFixed,
oneTableWithUpdateRecord,
testingOfOpenModeForAddChild,
testingWithPageMode,
} from './templatesOfBug';
const addSomeCustomActions = async (page: Page) => {
// 先删除掉之前的 actions
@ -50,7 +56,7 @@ test.describe('actions schema settings', () => {
await page.getByRole('button', { name: 'designer-schema-settings-Action-Action.Designer-general' }).hover();
};
test('supported options', async ({ page, mockPage, mockRecord }) => {
test('supported options', async ({ page, mockPage }) => {
await mockPage(oneEmptyTableBlockWithActions).goto();
await expectSettingsMenu({
@ -60,7 +66,7 @@ test.describe('actions schema settings', () => {
});
});
test('edit button', async ({ page, mockPage, mockRecord }) => {
test('edit button', async ({ page, mockPage }) => {
await mockPage(oneEmptyTableBlockWithActions).goto();
await showMenu(page);
@ -72,7 +78,7 @@ test.describe('actions schema settings', () => {
await expect(page.getByRole('button', { name: '1234' })).toBeVisible();
});
test('open mode', async ({ page, mockPage, mockRecord }) => {
test('open mode', async ({ page, mockPage }) => {
await mockPage(oneEmptyTableBlockWithActions).goto();
await showMenu(page);
@ -85,9 +91,45 @@ test.describe('actions schema settings', () => {
await page.getByRole('button', { name: 'Add new' }).click();
await expect(page.getByTestId('modal-Action.Container-general-Add record')).toBeVisible();
await page.getByLabel('modal-Action.Container-general-Add record-mask').click();
// 切换为 page
await page.getByLabel('action-Action-Add new-create-').hover();
await page.getByRole('button', { name: 'designer-schema-settings-Action-Action.Designer-general' }).hover();
await page.getByRole('menuitem', { name: 'Open mode Dialog' }).click();
await page.getByRole('option', { name: 'Page' }).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.getByRole('menuitem', { name: 'form Form right' }).hover();
await page.getByRole('menuitem', { name: 'Current collection' }).click();
await page.getByLabel('schema-initializer-Grid-form:').hover();
await page.getByRole('menuitem', { name: 'Single select' }).click();
await page.mouse.move(300, 0);
await page.getByLabel('schema-initializer-ActionBar-').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);
await expect(page.getByRole('button', { name: 'option3' })).toHaveCount(1);
});
test('popup size', async ({ page, mockPage, mockRecord }) => {
test('popup size', async ({ page, mockPage }) => {
await mockPage(oneEmptyTableBlockWithActions).goto();
await showMenu(page);
@ -116,7 +158,7 @@ test.describe('actions schema settings', () => {
expect(drawerWidth2).toBeGreaterThanOrEqual(800);
});
test('delete', async ({ page, mockPage, mockRecord }) => {
test('delete', async ({ page, mockPage }) => {
await mockPage(oneEmptyTableBlockWithActions).goto();
await showMenu(page);
@ -149,7 +191,7 @@ test.describe('actions schema settings', () => {
await page.getByLabel('designer-schema-settings-Filter.Action-Filter.Action.Designer-general').hover();
};
test('supported options', async ({ page, mockPage, mockRecord }) => {
test('supported options', async ({ page, mockPage }) => {
await mockPage(oneEmptyTableBlockWithActions).goto();
await expectSettingsMenu({
@ -316,6 +358,390 @@ test.describe('actions schema settings', () => {
'',
);
});
test('open mode: page', async ({ page, mockPage }) => {
await mockPage(testingWithPageMode).goto();
// 打开弹窗
await page.getByLabel('action-Action.Link-View').click();
// 详情区块
await expect(
page
.getByLabel('block-item-CardItem-users-details')
.getByLabel('block-item-CollectionField-users-details-users.nickname-Nickname'),
).toHaveText('Nickname:Super Admin');
// 关系区块
await expect(
page
.getByLabel('block-item-CardItem-roles-details')
.getByLabel('block-item-CollectionField-roles-details-roles.name-Role UID'),
).toHaveText('Role UID:admin');
// 使用变量 `Current role` 设置默认值
await expect(page.getByLabel('block-item-CardItem-roles-form').getByRole('textbox')).toHaveValue('root');
// 使用变量 `Current popup record` 设置默认值
await expect(page.getByLabel('block-item-CardItem-users-form').getByRole('textbox')).toHaveValue('Super Admin');
// -----------------------------------------------------------------------------------------------
// 打开嵌套弹窗
await page.getByLabel('action-Action.Link-View role-view-roles-table-admin').click();
// 详情区块
await expect(page.getByLabel('block-item-CollectionField-roles-details-roles.title-Role name')).toHaveText(
'Role name:Admin',
);
// 使用变量 `Parent popup record` 设置数据范围
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Admin', exact: true }),
).toBeVisible();
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Member', exact: true }),
).toBeVisible();
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Root', exact: true }),
).toBeVisible();
// 使用变量 `Current popup record` 和 `Parent popup record` 设置默认值
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-users-form')
.getByLabel('block-item-CollectionField-users-form-users.nickname-Nickname')
.getByRole('textbox'),
).toHaveValue('admin');
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-users-form')
.getByLabel('block-item-CollectionField-users-form-users.username-Username')
.getByRole('textbox'),
).toHaveValue('nocobase');
// -----------------------------------------------------------------------------------
// 关闭嵌套弹窗后,再点击不同的行打开嵌套弹窗,应该显示不同的数据
await page.getByLabel('drawer-Action.Container-roles-View record-mask').click();
await page.getByLabel('action-Action.Link-View role-view-roles-table-member').click();
// 详情区块
await expect(page.getByLabel('block-item-CollectionField-roles-details-roles.title-Role name')).toHaveText(
'Role name:Member',
);
// 使用变量 `Parent popup record` 设置数据范围
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Admin', exact: true }),
).toBeVisible();
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Member', exact: true }),
).toBeVisible();
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Root', exact: true }),
).toBeVisible();
// 使用变量 `Current popup record` 和 `Parent popup record` 设置默认值
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-users-form')
.getByLabel('block-item-CollectionField-users-form-users.nickname-Nickname')
.getByRole('textbox'),
).toHaveValue('member');
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-users-form')
.getByLabel('block-item-CollectionField-users-form-users.username-Username')
.getByRole('textbox'),
).toHaveValue('nocobase');
// -----------------------------------------------------------------------------------
// 刷新页面后,弹窗中的内容不变
await page.reload();
// 详情区块
await expect(page.getByLabel('block-item-CollectionField-roles-details-roles.title-Role name')).toHaveText(
'Role name:Member',
);
// 使用变量 `Parent popup record` 设置数据范围
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Admin', exact: true }),
).toBeVisible();
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Member', exact: true }),
).toBeVisible();
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Root', exact: true }),
).toBeVisible();
// 使用变量 `Current popup record` 和 `Parent popup record` 设置默认值
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-users-form')
.getByLabel('block-item-CollectionField-users-form-users.nickname-Nickname')
.getByRole('textbox'),
).toHaveValue('member');
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-users-form')
.getByLabel('block-item-CollectionField-users-form-users.username-Username')
.getByRole('textbox'),
).toHaveValue('nocobase');
// -----------------------------------------------------------------------------------
// 关闭弹窗,然后将 Open mode 调整为 page
await page.getByLabel('drawer-Action.Container-roles-View record-mask').click();
await page.locator('.ant-drawer-mask').click();
await page.getByLabel('action-Action.Link-View').hover();
await page.getByLabel('designer-schema-settings-Action.Link-actionSettings:view-users').hover();
await page.getByRole('menuitem', { name: 'Open mode Drawer' }).click();
await page.getByRole('option', { name: 'Page' }).click();
// 跳转到子页面后,其内容应该和弹窗中的内容一致
await page.getByLabel('action-Action.Link-View').click();
expect(page.url()).toContain('/subpages');
// 详情区块
await expect(
page
.getByLabel('block-item-CardItem-users-details')
.getByLabel('block-item-CollectionField-users-details-users.nickname-Nickname'),
).toHaveText('Nickname:Super Admin');
// 关系区块
await expect(
page
.getByLabel('block-item-CardItem-roles-details')
.getByLabel('block-item-CollectionField-roles-details-roles.name-Role UID'),
).toHaveText('Role UID:admin');
// 使用变量 `Current role` 设置默认值
await expect(page.getByLabel('block-item-CardItem-roles-form').getByRole('textbox')).toHaveValue('root');
// 使用变量 `Current popup record` 设置默认值
await expect(page.getByLabel('block-item-CardItem-users-form').getByRole('textbox')).toHaveValue('Super Admin');
// ---------------------------------------------------------------------------------------------------------------
// 打开子页面中的弹窗
await page.getByLabel('action-Action.Link-View role-view-roles-table-member').click();
// 详情区块
await expect(page.getByLabel('block-item-CollectionField-roles-details-roles.title-Role name')).toHaveText(
'Role name:Member',
);
// 使用变量 `Parent popup record` 设置数据范围
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Admin', exact: true }),
).toBeVisible();
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Member', exact: true }),
).toBeVisible();
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Root', exact: true }),
).toBeVisible();
// 使用变量 `Current popup record` 和 `Parent popup record` 设置默认值
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-users-form')
.getByLabel('block-item-CollectionField-users-form-users.nickname-Nickname')
.getByRole('textbox'),
).toHaveValue('member');
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-users-form')
.getByLabel('block-item-CollectionField-users-form-users.username-Username')
.getByRole('textbox'),
).toHaveValue('nocobase');
// -----------------------------------------------------------------------------------
// 关闭弹窗后,重新选择一条不同的数据打开,应该显示不同的数据
await page.getByLabel('drawer-Action.Container-roles-View record-mask').click();
await page.getByLabel('action-Action.Link-View role-view-roles-table-admin').click();
// 详情区块
await expect(page.getByLabel('block-item-CollectionField-roles-details-roles.title-Role name')).toHaveText(
'Role name:Admin',
);
// 使用变量 `Parent popup record` 设置数据范围
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Admin', exact: true }),
).toBeVisible();
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Member', exact: true }),
).toBeVisible();
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Root', exact: true }),
).toBeVisible();
// 使用变量 `Current popup record` 和 `Parent popup record` 设置默认值
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-users-form')
.getByLabel('block-item-CollectionField-users-form-users.nickname-Nickname')
.getByRole('textbox'),
).toHaveValue('admin');
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-users-form')
.getByLabel('block-item-CollectionField-users-form-users.username-Username')
.getByRole('textbox'),
).toHaveValue('nocobase');
// -----------------------------------------------------------------------------------
// 刷新页面后,弹窗中的内容不变
await page.reload();
// 详情区块
await expect(page.getByLabel('block-item-CollectionField-roles-details-roles.title-Role name')).toHaveText(
'Role name:Admin',
);
// 使用变量 `Parent popup record` 设置数据范围
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Admin', exact: true }),
).toBeVisible();
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Member', exact: true }),
).toBeVisible();
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-roles-table')
.getByRole('button', { name: 'Root', exact: true }),
).toBeVisible();
// 使用变量 `Current popup record` 和 `Parent popup record` 设置默认值
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-users-form')
.getByLabel('block-item-CollectionField-users-form-users.nickname-Nickname')
.getByRole('textbox'),
).toHaveValue('admin');
await expect(
page
.getByTestId('drawer-Action.Container-roles-View record')
.getByLabel('block-item-CardItem-users-form')
.getByLabel('block-item-CollectionField-users-form-users.username-Username')
.getByRole('textbox'),
).toHaveValue('nocobase');
// -----------------------------------------------------------------------------------
// 关闭弹窗后,将子页面中的 Open mode 调整为 page
await page.goBack();
await page.getByLabel('action-Action.Link-View role-view-roles-table-admin').hover();
await page
.getByRole('button', { name: 'designer-schema-settings-Action.Link-actionSettings:view-roles' })
.hover();
await page.getByRole('menuitem', { name: 'Open mode Drawer' }).click();
await page.getByRole('option', { name: 'Page' }).click();
// 点击按钮跳转到子页面
await page.getByLabel('action-Action.Link-View role-view-roles-table-admin').click();
// 详情区块
await expect(page.getByLabel('block-item-CollectionField-roles-details-roles.title-Role name')).toHaveText(
'Role name:Admin',
);
// 使用变量 `Parent popup record` 设置数据范围
await expect(
page.getByLabel('block-item-CardItem-roles-table').getByRole('button', { name: 'Admin', exact: true }),
).toBeVisible();
await expect(
page.getByLabel('block-item-CardItem-roles-table').getByRole('button', { name: 'Member', exact: true }),
).toBeVisible();
await expect(
page.getByLabel('block-item-CardItem-roles-table').getByRole('button', { name: 'Root', exact: true }),
).toBeVisible();
// 使用变量 `Current popup record` 和 `Parent popup record` 设置默认值
await expect(
page
.getByLabel('block-item-CardItem-users-form')
.getByLabel('block-item-CollectionField-users-form-users.nickname-Nickname')
.getByRole('textbox'),
).toHaveValue('admin');
await expect(
page
.getByLabel('block-item-CardItem-users-form')
.getByLabel('block-item-CollectionField-users-form-users.username-Username')
.getByRole('textbox'),
).toHaveValue('nocobase');
});
});
test.describe('popup', () => {
@ -459,7 +885,7 @@ test.describe('actions schema settings', () => {
await page.getByLabel('designer-schema-settings-Action.Link-actionSettings:addChild-tree').hover();
};
test('supported options', async ({ page, mockPage, mockRecord }) => {
test('supported options', async ({ page, mockPage }) => {
const nocoPage = await mockPage(oneEmptyTableWithTreeCollection).waitForInit();
await nocoPage.goto();
await page.getByLabel('block-item-CardItem-treeCollection-table').hover();
@ -511,6 +937,35 @@ test.describe('actions schema settings', () => {
.getByText('1', { exact: true }),
).toBeVisible();
});
test('open mode', async ({ page, mockPage }) => {
const nocoPage = await mockPage(testingOfOpenModeForAddChild).waitForInit();
await nocoPage.goto();
// add a record
await page.getByLabel('action-Action-Add new-create-').click();
await page.getByLabel('action-Action-Submit-submit-').click();
// open popup with drawer mode
await page.getByLabel('action-Action.Link-Add child-').click();
await expect(page.getByTestId('select-object-single')).toHaveText('1');
await page.reload();
await expect(page.getByTestId('select-object-single')).toHaveText('1');
await page.goBack();
await page.getByLabel('action-Action.Link-Add child-').hover();
await page.getByLabel('designer-schema-settings-Action.Link-actionSettings:addChild-treeCollection').hover();
await page.getByRole('menuitem', { name: 'Open mode Drawer' }).click();
await page.getByRole('option', { name: 'Page' }).click();
// open popup with page mode
await page.getByLabel('action-Action.Link-Add child-').click();
await expect(page.getByTestId('select-object-single')).toHaveText('1');
await page.reload();
await expect(page.getByTestId('select-object-single')).toHaveText('1');
});
});
test.describe('add record', () => {
@ -519,7 +974,7 @@ test.describe('actions schema settings', () => {
await page.getByRole('button', { name: 'designer-schema-settings-Action-Action.Designer-general' }).hover();
};
test('supported options', async ({ page, mockPage, mockRecord }) => {
test('supported options', async ({ page, mockPage }) => {
await mockPage(oneEmptyTableBlockWithActions).goto();
await expectSettingsMenu({

View File

@ -11,6 +11,7 @@ import { useField, useFieldSchema } from '@formily/react';
import { useTranslation } from 'react-i18next';
import { useAPIClient } from '../../../../api-client';
import { SchemaSettings } from '../../../../application/schema-settings/SchemaSettings';
import { createSwitchSettingsItem } from '../../../../application/schema-settings/utils/createSwitchSettingsItem';
import { useTableBlockContext } from '../../../../block-provider';
import { useCollectionManager_deprecated, useCollection_deprecated } from '../../../../collection-manager';
import { FilterBlockType } from '../../../../filter-provider/utils';
@ -20,10 +21,9 @@ import { SchemaSettingsBlockTitleItem } from '../../../../schema-settings/Schema
import { SchemaSettingsConnectDataBlocks } from '../../../../schema-settings/SchemaSettingsConnectDataBlocks';
import { SchemaSettingsSortField } from '../../../../schema-settings/SchemaSettingsSortField';
import { SchemaSettingsTemplate } from '../../../../schema-settings/SchemaSettingsTemplate';
import { setDataLoadingModeSettingsItem } from '../details-multi/setDataLoadingModeSettingsItem';
import { setDefaultSortingRulesSchemaSettingsItem } from '../../../../schema-settings/setDefaultSortingRulesSchemaSettingsItem';
import { setTheDataScopeSchemaSettingsItem } from '../../../../schema-settings/setTheDataScopeSchemaSettingsItem';
import { createSwitchSettingsItem } from '../../../../application/schema-settings/utils';
import { setDataLoadingModeSettingsItem } from '../details-multi/setDataLoadingModeSettingsItem';
export const tableBlockSettings = new SchemaSettings({
name: 'blockSettings:table',

View File

@ -171,7 +171,7 @@ test.describe('where filter block can be added', () => {
await connectToOtherBlock('Users #o1nq');
await page.getByLabel('block-item-CardItem-users-filter-form').getByRole('textbox').fill(usersRecords[0].nickname);
await page.getByLabel('action-Action-Filter-submit-users-filter-form-').click({ position: { x: 10, y: 10 } });
await page.getByLabel('action-Action-Filter-submit-users-filter-form').click({ position: { x: 10, y: 10 } });
await page.waitForLoadState('networkidle');
for (const record of usersRecords) {
await expect(page.getByLabel('block-item-CardItem-users-details').getByText(record.nickname)).toBeVisible({

View File

@ -0,0 +1,53 @@
/**
* 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 { pageTabRouting } from './templatesOfBug';
test.describe('router', () => {
test('page tabs', async ({ page, mockPage }) => {
const nocoPage = await mockPage(pageTabRouting).waitForInit();
const pageUrl = await nocoPage.getUrl();
// 1. 旧版的 URL
await page.goto(`${pageUrl}/?tab=bbch3c9b5jl`);
await expect(page.getByText('This is tab2.')).toBeVisible();
// 2. 点击 tab1 应该跳转到 tab1并使用新版 URL
await page.getByText('tab1').click();
await expect(page.getByText('This is tab1.')).toBeVisible();
expect(page.url()).toMatch(new RegExp(`${pageUrl}/tabs/u4earq3d9go`));
// 3. 点击 tab2 应该跳转到 tab2并使用新版 URL
await page.getByText('tab2').click();
await expect(page.getByText('This is tab2.')).toBeVisible();
expect(page.url()).toMatch(new RegExp(`${pageUrl}/tabs/bbch3c9b5jl`));
// 4. 使用不带 tab 参数的 URL应该默认显示第一个 tab
await nocoPage.goto();
await expect(page.getByText('This is tab1.')).toBeVisible();
expect(page.url()).toMatch(new RegExp(pageUrl));
});
test('side menu should not hide when go back from settings page', async ({ page, mockPage }) => {
await mockPage({
type: 'group',
}).goto();
// 最初是有侧边菜单的
await expect(page.getByTestId('schema-initializer-Menu-side')).toBeVisible();
// 跳转到插件设置页面后再返回,侧边菜单应该还在
await page.getByTestId('plugin-settings-button').hover();
await page.getByRole('link', { name: 'API keys' }).click();
await expect(page.getByText('API keys').first()).toBeVisible();
await page.goBack();
await expect(page.getByTestId('schema-initializer-Menu-side')).toBeVisible();
});
});

View File

@ -71,7 +71,7 @@ test.describe('page schema settings', () => {
});
test.describe('tabs schema settings', () => {
async function showSettings(page: Page) {
async function showSettingsOfTab(page: Page) {
await page.getByText('Unnamed').hover();
await page.getByRole('tab').getByLabel('designer-schema-settings-Page').hover();
}
@ -87,7 +87,7 @@ test.describe('tabs schema settings', () => {
await mockPage().goto();
await enablePageTabs(page);
await showSettings(page);
await showSettingsOfTab(page);
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
await page.getByLabel('block-item-Input-Tab name').getByRole('textbox').click();
await page.getByLabel('block-item-Input-Tab name').getByRole('textbox').fill('new name of page tab');
@ -103,7 +103,7 @@ test.describe('tabs schema settings', () => {
await mockPage().goto();
await enablePageTabs(page);
await showSettings(page);
await showSettingsOfTab(page);
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click();

View File

@ -7,3 +7,135 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export const pageTabRouting = {
pageSchema: {
'x-uid': 'i7o68vu98u8',
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Page',
'x-app-version': '1.2.3-alpha',
'x-component-props': {
enablePageTabs: true,
},
properties: {
u4earq3d9go: {
'x-uid': 'xbx6zg90ij2',
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'page:addBlock',
'x-app-version': '1.2.3-alpha',
title: 'tab1',
properties: {
nq41b5sodws: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.2.3-alpha',
properties: {
mhiac303xfd: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.2.3-alpha',
properties: {
feimcvzb7rs: {
'x-uid': 'maurznopx6g',
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-settings': 'blockSettings:markdown',
'x-decorator': 'CardItem',
'x-decorator-props': {
name: 'markdown',
},
'x-component': 'Markdown.Void',
'x-editable': false,
'x-component-props': {
content: 'This is tab1.',
},
'x-app-version': '1.2.3-alpha',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'im4ufr0vu12',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'nt3axo5te3r',
'x-async': false,
'x-index': 1,
},
},
'x-async': false,
'x-index': 1,
},
bbch3c9b5jl: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: 'tab2',
'x-component': 'Grid',
'x-initializer': 'page:addBlock',
'x-app-version': '1.2.3-alpha',
properties: {
'8veis5l6j8x': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.2.3-alpha',
properties: {
ayw9hqnz30u: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.2.3-alpha',
properties: {
njodwctznc4: {
'x-uid': 'ox614ghc544',
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-settings': 'blockSettings:markdown',
'x-decorator': 'CardItem',
'x-decorator-props': {
name: 'markdown',
},
'x-component': 'Markdown.Void',
'x-editable': false,
'x-component-props': {
content: 'This is tab2.',
},
'x-app-version': '1.2.3-alpha',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'ca5bt5ygrkp',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'ranzuquuert',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'qhjdmy9nk6q',
'x-async': false,
'x-index': 2,
},
},
'x-async': true,
'x-index': 1,
},
keepUid: true,
};

View File

@ -27,7 +27,7 @@ test.describe('where to open a popup and what can be added to it', () => {
await page.getByLabel('schema-initializer-Tabs-').click();
await page.getByRole('textbox').click();
await page.getByRole('textbox').fill('test123');
await page.getByLabel('action-Action-Submit-general-table').click();
await page.getByLabel('action-Action-Submit-general').click();
await expect(page.getByText('test123')).toBeVisible();
@ -54,7 +54,7 @@ test.describe('where to open a popup and what can be added to it', () => {
await page.getByLabel('schema-initializer-Tabs-').click();
await page.getByRole('textbox').click();
await page.getByRole('textbox').fill('test7');
await page.getByLabel('action-Action-Submit-general-table').click();
await page.getByLabel('action-Action-Submit-general').click();
await expect(page.getByText('test7')).toBeVisible();
@ -83,7 +83,7 @@ test.describe('where to open a popup and what can be added to it', () => {
await page.getByLabel('schema-initializer-Tabs-').click();
await page.getByRole('textbox').click();
await page.getByRole('textbox').fill('test8');
await page.getByLabel('action-Action-Submit-general-table-0').click();
await page.getByLabel('action-Action-Submit-general').click();
await expect(page.getByText('test8')).toBeVisible();
@ -140,7 +140,7 @@ test.describe('where to open a popup and what can be added to it', () => {
await page.getByLabel('schema-initializer-Tabs-').click();
await page.getByRole('textbox').click();
await page.getByRole('textbox').fill('test1');
await page.getByLabel('action-Action-Submit-general-table').click();
await page.getByLabel('action-Action-Submit-general').click();
await expect(page.getByText('test1')).toBeVisible();
@ -171,7 +171,73 @@ test.describe('where to open a popup and what can be added to it', () => {
await page.getByLabel('schema-initializer-Tabs-').click();
await page.getByRole('textbox').click();
await page.getByRole('textbox').fill('test8');
await page.getByLabel('action-Action-Submit-general-details').click();
await page.getByLabel('action-Action-Submit-general').click();
await expect(page.getByText('test8')).toBeVisible();
// add blocks
await page.getByLabel('schema-initializer-Grid-popup:common:addBlock-general').hover();
await page.getByRole('menuitem', { name: 'Details' }).hover();
await page.getByRole('menuitem', { name: 'Current record' }).click();
await page.getByLabel('schema-initializer-Grid-popup:common:addBlock-general').hover();
await page.getByRole('menuitem', { name: 'form Form (Edit)' }).first().click();
await page.getByLabel('schema-initializer-Grid-popup:common:addBlock-general').hover();
await page.getByRole('menuitem', { name: 'Markdown' }).click();
await page.mouse.move(300, 0);
await expect(page.getByLabel('block-item-CardItem-general-details')).toBeVisible();
await expect(page.getByLabel('block-item-CardItem-general-form')).toBeVisible();
await expect(page.getByLabel('block-item-Markdown.Void-')).toBeVisible();
// add relationship blocks
// 下拉列表中,可选择以下区块进行创建
await page.getByLabel('schema-initializer-Grid-popup:common:addBlock-general').hover();
await expect(page.getByRole('menuitem', { name: 'table Details right' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'form Form (Edit)' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'form Form (Add new) right' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'form Form (Add new) right' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'table Table right' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'ordered-list List right' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'ordered-list Grid Card right' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Calendar' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Gantt' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Kanban' })).toBeVisible();
await page.getByLabel('schema-initializer-Grid-popup:common:addBlock-general').hover();
await page.getByRole('menuitem', { name: 'Details' }).hover();
await page.getByRole('menuitem', { name: 'Associated records' }).hover();
await page.getByRole('menuitem', { name: 'Many to one' }).click();
await page.mouse.move(300, 0);
await expect(page.getByLabel('block-item-CardItem-users-')).toBeVisible();
await page.getByLabel('schema-initializer-Grid-popup:common:addBlock-general').hover();
await page.getByRole('menuitem', { name: 'table Table right' }).hover();
await page.getByRole('menuitem', { name: 'Associated records' }).hover();
await page.getByRole('menuitem', { name: 'One to many' }).click();
await page.mouse.move(300, 0);
await expect(page.getByLabel('block-item-CardItem-users-table')).toBeVisible();
// 屏幕上没有显示错误提示
await expect(page.locator('.ant-notification-notice').first()).toBeHidden({ timeout: 1000 });
});
test('association link after reload page', async ({ page, mockPage, mockRecord }) => {
const nocoPage = await mockPage(oneDetailBlockWithM2oFieldToGeneral).waitForInit();
const record = await mockRecord('targetToGeneral');
await nocoPage.goto();
// open dialog
await page
.getByLabel('block-item-CollectionField-targetToGeneral-details-targetToGeneral.toGeneral-toGeneral')
.getByText(record.id, { exact: true })
.click();
await page.reload();
// add a tab
await page.getByLabel('schema-initializer-Tabs-').click();
await page.getByRole('textbox').click();
await page.getByRole('textbox').fill('test8');
await page.getByLabel('action-Action-Submit-general').click();
await expect(page.getByText('test8')).toBeVisible();

View File

@ -31,6 +31,9 @@ test.describe('tabs schema settings', () => {
await page.goto(commonPageUrl);
await page.getByRole('button', { name: 'Add new' }).click();
// There will be a prompt at the top, wait for it to disappear
await page.waitForTimeout(1000);
await showSettings(page);
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
await page.mouse.move(300, 0);
@ -48,6 +51,9 @@ test.describe('tabs schema settings', () => {
await page.goto(commonPageUrl);
await page.getByRole('button', { name: 'Add new' }).click();
// There will be a prompt at the top, wait for it to disappear
await page.waitForTimeout(1000);
await showSettings(page);
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click();

View File

@ -24,8 +24,10 @@ import { RemoteDocumentTitlePlugin } from '../document-title';
import { PinnedListPlugin } from '../plugin-manager';
import { PMPlugin } from '../pm';
import { AdminLayoutPlugin, RouteSchemaComponent } from '../route-switch';
import { AntdSchemaComponentPlugin, SchemaComponentPlugin } from '../schema-component';
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';
@ -302,6 +304,22 @@ export class NocoBaseBuildInPlugin extends Plugin {
path: '/admin/:name',
Component: 'AdminDynamicPage',
});
this.router.add('admin.page.tab', {
path: '/admin/:name/tabs/:tabUid',
Component: PageTabs as any,
});
this.router.add('admin.page.popup', {
path: '/admin/:name/popups/*',
Component: PagePopups,
});
this.router.add('admin.page.tab.popup', {
path: '/admin/:name/tabs/:tabUid/popups/*',
Component: PagePopups,
});
this.router.add('admin.subPage', {
path: '/admin/subpages/*',
Component: SubPage,
});
}
addComponents() {

View File

@ -10,12 +10,11 @@
import { PageHeader } from '@ant-design/pro-layout';
import { css } from '@emotion/css';
import { Layout, Menu, Result } from 'antd';
import _, { get } from 'lodash';
import React, { createContext, useCallback, useMemo } from 'react';
import { Navigate, Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
import { useStyles } from './style';
import { ADMIN_SETTINGS_PATH, PluginSettingsPageType, useApp } from '../application';
import { useCompile } from '../schema-component';
import { useStyles } from './style';
export const SettingsCenterContext = createContext<any>({});
SettingsCenterContext.displayName = 'SettingsCenterContext';

View File

@ -11,8 +11,8 @@ import { css } from '@emotion/css';
import { useSessionStorageState } from 'ahooks';
import { App, ConfigProvider, Divider, Layout } from 'antd';
import { createGlobalStyle } from 'antd-style';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Link, Outlet, useLocation, useMatch, useNavigate, useParams } from 'react-router-dom';
import React, { FC, createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Link, Outlet, useMatch, useParams } from 'react-router-dom';
import {
ACLRolesCheckProvider,
CurrentAppInfoProvider,
@ -33,11 +33,12 @@ import {
useSystemSettings,
useToken,
} from '../../../';
import { useLocationNoUpdate, useNavigateNoUpdate } from '../../../application/CustomRouterContextProvider';
import { Plugin } from '../../../application/Plugin';
import { useAppSpin } from '../../../application/hooks/useAppSpin';
import { useMenuTranslation } from '../../../schema-component/antd/menu/locale';
import { Help } from '../../../user/Help';
import { VariablesProvider } from '../../../variables';
import { useMenuTranslation } from '../../../schema-component/antd/menu/locale';
const filterByACL = (schema, options) => {
const { allowAll, allowMenuItemIds = [] } = options;
@ -76,13 +77,13 @@ const useMenuProps = () => {
const MenuEditor = (props) => {
const { notification } = App.useApp();
const [hasNotice, setHasNotice] = useSessionStorageState('plugin-notice', { defaultValue: false });
const [, setHasNotice] = useSessionStorageState('plugin-notice', { defaultValue: false });
const { t } = useMenuTranslation();
const { setTitle: _setTitle } = useDocumentTitle();
const setTitle = useCallback((title) => _setTitle(t(title)), []);
const navigate = useNavigate();
const navigate = useNavigateNoUpdate();
const params = useParams<any>();
const location = useLocation();
const location = useLocationNoUpdate();
const isMatchAdmin = useMatch('/admin');
const isMatchAdminName = useMatch('/admin/:name');
const defaultSelectedUid = params.name;
@ -91,12 +92,12 @@ const MenuEditor = (props) => {
const ctx = useACLRoleContext();
const [current, setCurrent] = useState(null);
const onSelect = ({ item }) => {
const onSelect = useCallback(({ item }) => {
const schema = item.props.schema;
setTitle(schema.title);
setCurrent(schema);
navigate(`/admin/${schema['x-uid']}`);
};
}, []);
const { render } = useAppSpin();
const adminSchemaUid = useAdminSchemaUid();
const { data, loading } = useRequest<{
@ -154,7 +155,7 @@ const MenuEditor = (props) => {
sideMenuRef.current.style.display = 'block';
}
}
}, [data?.data, params.name, sideMenuRef]);
}, [data?.data, params.name, sideMenuRef, location?.pathname]);
const schema = useMemo(() => {
const s = filterByACL(data?.data, ctx);
@ -211,17 +212,16 @@ const MenuEditor = (props) => {
},
);
const scope = useMemo(() => {
return { useMenuProps, onSelect, sideMenuRef, defaultSelectedUid };
}, []);
if (loading) {
return render();
}
return (
<SchemaIdContext.Provider value={defaultSelectedUid}>
<SchemaComponent
distributed
memoized
scope={{ useMenuProps, onSelect, sideMenuRef, defaultSelectedUid }}
schema={schema}
/>
<SchemaComponent distributed memoized scope={scope} schema={schema} />
</SchemaIdContext.Provider>
);
};
@ -292,12 +292,7 @@ const SetThemeOfHeaderSubmenu = ({ children }) => {
);
};
const AdminSideBar = ({ sideMenuRef }) => {
const params = useParams<any>();
if (!params.name) return null;
return (
<Layout.Sider
className={css`
const sideClass = css`
height: 100%;
/* position: fixed; */
position: relative;
@ -311,16 +306,21 @@ const AdminSideBar = ({ sideMenuRef }) => {
width: 200px;
height: calc(100vh - var(--nb-header-height));
}
`}
theme={'light'}
ref={sideMenuRef}
></Layout.Sider>
);
`;
const InternalAdminSideBar: FC<{ pageUid: string; sideMenuRef: any }> = memo((props) => {
if (!props.pageUid) return null;
return <Layout.Sider className={sideClass} theme={'light'} ref={props.sideMenuRef}></Layout.Sider>;
});
InternalAdminSideBar.displayName = 'InternalAdminSideBar';
const AdminSideBar = ({ sideMenuRef }) => {
const params = useParams<any>();
return <InternalAdminSideBar pageUid={params.name} sideMenuRef={sideMenuRef} />;
};
export const AdminDynamicPage = () => {
const params = useParams<{ name?: string }>();
return <RouteSchemaComponent schema={params.name} />;
return <RouteSchemaComponent />;
};
export const InternalAdminLayout = () => {

View File

@ -11,7 +11,7 @@ import React from 'react';
import { useParams } from 'react-router-dom';
import { RemoteSchemaComponent } from '../../../';
export function RouteSchemaComponent(props: any) {
const params = useParams<any>();
export function RouteSchemaComponent() {
const params = useParams();
return <RemoteSchemaComponent onlyRenderProperties uid={params.name} />;
}

View File

@ -11,13 +11,12 @@ import { observer, RecursionField, useField, useFieldSchema } from '@formily/rea
import { Drawer } from 'antd';
import classNames from 'classnames';
import React from 'react';
import { ActionDrawerProps, OpenSize } from './types';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { ErrorFallback } from '../error-fallback';
import { useStyles } from './Action.Drawer.style';
import { useActionContext } from './hooks';
import { useSetAriaLabelForDrawer } from './hooks/useSetAriaLabelForDrawer';
import { ComposedActionDrawer } from './types';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { ErrorFallback } from '../error-fallback';
import { ActionDrawerProps, ComposedActionDrawer, OpenSize } from './types';
const DrawerErrorFallback: React.FC<FallbackProps> = (props) => {
const { visible, setVisible } = useActionContext();

View File

@ -11,7 +11,7 @@ import { observer, RecursionField, useField, useFieldSchema, useForm } from '@fo
import { isPortalInBody } from '@nocobase/utils/client';
import { App, Button } from 'antd';
import classnames from 'classnames';
import { default as lodash } from 'lodash';
import _, { default as lodash } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useTranslation } from 'react-i18next';
@ -28,6 +28,10 @@ import { useLocalVariables, useVariables } from '../../../variables';
import { SortableItem } from '../../common';
import { useCompile, useComponent, useDesigner } from '../../hooks';
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';
@ -36,11 +40,16 @@ import { ActionModal } from './Action.Modal';
import { ActionPage } from './Action.Page';
import useStyles from './Action.style';
import { ActionContextProvider } from './context';
import { useA } from './hooks';
import { useGetAriaLabelOfAction } from './hooks/useGetAriaLabelOfAction';
import { ActionProps, ComposedAction } from './types';
import { linkageAction, setInitialActionState } from './utils';
const useA = () => {
return {
async run() {},
};
};
const handleError = (err) => console.log(err);
export const Action: ComposedAction = withDynamicSchemaProps(
@ -48,7 +57,6 @@ export const Action: ComposedAction = withDynamicSchemaProps(
const {
popover,
confirm,
openMode: om,
containerRefKey,
component,
useAction = useA,
@ -70,11 +78,13 @@ export const Action: ComposedAction = withDynamicSchemaProps(
const aclCtx = useACLActionParamsContext();
const { wrapSSR, componentCls, hashId } = useStyles();
const { t } = useTranslation();
const { visibleWithURL, setVisibleWithURL } = usePagePopup();
const [visible, setVisible] = useState(false);
const [formValueChanged, setFormValueChanged] = useState(false);
const { setSubmitted: setParentSubmitted } = useActionContext();
const Designer = useDesigner();
const field = useField<any>();
const { run, element, disabled: disableAction } = useAction(actionCallback);
const { run, element, disabled: disableAction } = _.isFunction(useAction) ? useAction(actionCallback) : ({} as any);
const fieldSchema = useFieldSchema();
const compile = useCompile();
const form = useForm();
@ -120,47 +130,12 @@ export const Action: ComposedAction = withDynamicSchemaProps(
});
}, [field, linkageRules, localVariables, variables]);
const handleButtonClick = useCallback(
(e: React.MouseEvent, checkPortal = true) => {
if (checkPortal && isPortalInBody(e.target as Element)) {
return;
}
e.preventDefault();
e.stopPropagation();
if (!disabled && aclCtx) {
const onOk = () => {
if (onClick) {
onClick(e, () => {
if (refreshDataBlockRequest !== false) {
service?.refresh?.();
}
});
} else {
setVisible(true);
run();
}
};
if (confirm?.content) {
modal.confirm({
title: t(confirm.title, { title: actionTitle }),
content: t(confirm.content, { title: actionTitle }),
onOk,
});
} else {
onOk();
}
}
},
[confirm, disabled, modal, onClick, run],
);
const buttonStyle = useMemo(() => {
return {
...style,
opacity: designable && (field?.data?.hidden || !aclCtx) && 0.1,
};
}, [designable, field?.data?.hidden, style]);
}, [aclCtx, designable, field?.data?.hidden, style]);
const handleMouseEnter = useCallback(
(e) => {
@ -168,55 +143,66 @@ export const Action: ComposedAction = withDynamicSchemaProps(
},
[onMouseEnter],
);
const renderButton = () => {
if (!designable && (field?.data?.hidden || !aclCtx)) {
return null;
}
return (
<SortableItem
role="button"
aria-label={getAriaLabel()}
{...others}
onMouseEnter={handleMouseEnter}
loading={field?.data?.loading || loading}
icon={typeof icon === 'string' ? <Icon type={icon} /> : icon}
disabled={disabled}
style={buttonStyle}
onClick={handleButtonClick}
component={tarComponent || Button}
className={classnames(componentCls, hashId, className, 'nb-action')}
type={(props as any).type === 'danger' ? undefined : props.type}
>
{actionTitle}
<Designer {...designerProps} />
</SortableItem>
);
const buttonProps = {
designable,
field,
aclCtx,
actionTitle,
icon,
loading,
disabled,
buttonStyle,
handleMouseEnter,
tarComponent,
designerProps,
componentCls,
hashId,
className,
others,
getAriaLabel,
type: props.type,
Designer,
openMode,
onClick,
refreshDataBlockRequest,
service,
fieldSchema,
setVisible,
run,
confirm,
modal,
};
const buttonElement = renderButton();
const buttonElement = RenderButton(buttonProps);
// if (!btnHover) {
// return buttonElement;
// }
const result = (
<PopupVisibleProvider visible={false}>
<ActionContextProvider
button={buttonElement}
visible={visible}
setVisible={setVisible}
visible={visible || visibleWithURL}
setVisible={(value) => {
setVisible?.(value);
setVisibleWithURL?.(value);
}}
formValueChanged={formValueChanged}
setFormValueChanged={setFormValueChanged}
openMode={openMode}
openSize={openSize}
containerRefKey={containerRefKey}
fieldSchema={fieldSchema}
setSubmitted={setParentSubmitted}
>
{popover && <RecursionField basePath={field.address} onlyRenderProperties schema={fieldSchema} />}
{!popover && renderButton()}
{!popover && <RenderButton {...buttonProps} />}
<VariablePopupRecordProvider>{!popover && props.children}</VariablePopupRecordProvider>
{element}
</ActionContextProvider>
</PopupVisibleProvider>
);
// fix https://nocobase.height.app/T-3235/description
@ -284,3 +270,129 @@ Action.Container = ActionContainer;
Action.Page = ActionPage;
export default Action;
// TODO: Plugin-related code should not exist in the core. It would be better to implement it by modifying the schema, but it would cause incompatibility.
function isBulkEditAction(fieldSchema) {
return fieldSchema['x-action'] === 'customize:bulkEdit';
}
function RenderButton({
designable,
field,
aclCtx,
actionTitle,
icon,
loading,
disabled,
buttonStyle,
handleMouseEnter,
tarComponent,
designerProps,
componentCls,
hashId,
className,
others,
getAriaLabel,
type,
Designer,
openMode,
onClick,
refreshDataBlockRequest,
service,
fieldSchema,
setVisible,
run,
confirm,
modal,
}) {
const { t } = useTranslation();
const { navigateToSubPage } = useNavigateTOSubPage();
const { isPopupVisibleControlledByURL } = usePopupSettings();
const { openPopup } = usePagePopup();
const handleButtonClick = useCallback(
(e: React.MouseEvent, checkPortal = true) => {
if (checkPortal && isPortalInBody(e.target as Element)) {
return;
}
e.preventDefault();
e.stopPropagation();
if (!disabled && aclCtx) {
const onOk = () => {
if (openMode === 'page') {
return navigateToSubPage();
}
if (onClick) {
onClick(e, () => {
if (refreshDataBlockRequest !== false) {
service?.refresh?.();
}
});
} else if (isBulkEditAction(fieldSchema) || !isPopupVisibleControlledByURL) {
setVisible(true);
run?.();
} else {
if (
['view', 'update', 'create', 'customize:popup'].includes(fieldSchema['x-action']) &&
fieldSchema['x-uid']
) {
openPopup();
} else {
setVisible(true);
run?.();
}
}
};
if (confirm?.content) {
modal.confirm({
title: t(confirm.title, { title: actionTitle }),
content: t(confirm.content, { title: actionTitle }),
onOk,
});
} else {
onOk();
}
}
},
[
aclCtx,
actionTitle,
confirm?.content,
confirm?.title,
disabled,
modal,
onClick,
openPopup,
refreshDataBlockRequest,
run,
service,
setVisible,
t,
],
);
if (!designable && (field?.data?.hidden || !aclCtx)) {
return null;
}
return (
<SortableItem
role="button"
aria-label={getAriaLabel()}
{...others}
onMouseEnter={handleMouseEnter}
loading={field?.data?.loading || loading}
icon={typeof icon === 'string' ? <Icon type={icon} /> : icon}
disabled={disabled}
style={buttonStyle}
onClick={handleButtonClick}
component={tarComponent || Button}
className={classnames(componentCls, hashId, className, 'nb-action')}
type={type === 'danger' ? undefined : type}
>
{actionTitle}
<Designer {...designerProps} />
</SortableItem>
);
}

View File

@ -82,7 +82,6 @@ describe('Action', () => {
expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
});
await waitFor(async () => {
await userEvent.click(getByText('Close'));
// page
await userEvent.click(getByText('Page'));
await userEvent.click(getByText('Open'));

View File

@ -8,7 +8,6 @@
*/
import React, { createContext, useEffect, useRef, useState } from 'react';
import { useActionContext } from './hooks';
import { useDataBlockRequest } from '../../../data-source';
import { ActionContextProps } from './types';
@ -17,11 +16,10 @@ ActionContext.displayName = 'ActionContext';
export const ActionContextProvider: React.FC<ActionContextProps & { value?: ActionContextProps }> = (props) => {
const [submitted, setSubmitted] = useState(false); //是否有提交记录
const contextProps = useActionContext();
const { visible } = { ...props, ...props.value } || {};
const isFirstRender = useRef(true); // 使用ref跟踪是否为首次渲染
const service = useDataBlockRequest();
const { setSubmitted: setParentSubmitted } = { ...props, ...props.value, ...contextProps };
const { setSubmitted: setParentSubmitted } = { ...props, ...props.value };
useEffect(() => {
if (visible !== undefined) {
if (isFirstRender.current) {
@ -39,7 +37,7 @@ export const ActionContextProvider: React.FC<ActionContextProps & { value?: Acti
}, [visible]);
return (
<ActionContext.Provider value={{ ...contextProps, ...props, ...props?.value, submitted, setSubmitted }}>
<ActionContext.Provider value={{ ...props, ...props?.value, submitted, setSubmitted }}>
{props.children}
</ActionContext.Provider>
);

View File

@ -14,12 +14,6 @@ import { useTranslation } from 'react-i18next';
import { useIsDetailBlock } from '../../../block-provider/FormBlockProvider';
import { ActionContext } from './context';
export const useA = () => {
return {
async run() {},
};
};
export const useActionContext = () => {
const ctx = useContext(ActionContext);
const { t } = useTranslation();

View File

@ -10,7 +10,7 @@
import { Field } from '@formily/core';
import { observer, useField, useFieldSchema } from '@formily/react';
import React, { useEffect, useMemo, useState } from 'react';
import { useCollection, useCollectionManager } from '../../../data-source/collection';
import { useCollectionManager } from '../../../data-source/collection';
import { markRecordAsNew } from '../../../data-source/collection-record/isNewRecord';
import { useSchemaComponentContext } from '../../hooks';
import { AssociationFieldContext } from './context';
@ -18,8 +18,7 @@ import { AssociationFieldContext } from './context';
export const AssociationFieldProvider = observer(
(props) => {
const field = useField<Field>();
const collection = useCollection();
const dm = useCollectionManager();
const cm = useCollectionManager();
const fieldSchema = useFieldSchema();
// 这里有点奇怪,在 Table 切换显示的组件时,这个组件并不会触发重新渲染,所以增加这个 Hooks 让其重新渲染
@ -29,12 +28,12 @@ export const AssociationFieldProvider = observer(
const allowDissociate = fieldSchema['x-component-props']?.allowDissociate !== false;
const collectionField = useMemo(
() => collection.getField(fieldSchema['x-collection-field']),
() => cm.getCollectionField(fieldSchema['x-collection-field']),
// eslint-disable-next-line react-hooks/exhaustive-deps
[fieldSchema['x-collection-field'], fieldSchema.name],
);
const isFileCollection = useMemo(
() => dm.getCollection(collectionField?.target)?.template === 'file',
() => cm.getCollection(collectionField?.target)?.template === 'file',
// eslint-disable-next-line react-hooks/exhaustive-deps
[fieldSchema['x-collection-field']],
);

View File

@ -7,22 +7,20 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { observer, RecursionField, useField, useFieldSchema } from '@formily/react';
import { observer, useFieldSchema } from '@formily/react';
import { toArr } from '@formily/shared';
import React, { Fragment, useRef, useState } from 'react';
import React, { Fragment, useRef } from 'react';
import { useDesignable } from '../../';
import { BlockAssociationContext, WithoutTableFieldResource } from '../../../block-provider';
import { CollectionProvider_deprecated, useCollectionManager_deprecated } from '../../../collection-manager';
import { RecordProvider, useRecord } from '../../../record-provider';
import { FormProvider } from '../../core';
import { useCollectionManager_deprecated } from '../../../collection-manager';
import { useCollectionRecordData } from '../../../data-source/collection-record/CollectionRecordProvider';
import { useCompile } from '../../hooks';
import { ActionContextProvider, useActionContext } from '../action';
import { EllipsisWithTooltip } from '../input/EllipsisWithTooltip';
import { useActionContext } from '../action';
import { usePagePopup } from '../page/pagePopupUtils';
import { transformNestedData } from './InternalCascadeSelect';
import { ButtonListProps, ReadPrettyInternalViewer, isObject } from './InternalViewer';
import { useAssociationFieldContext, useFieldNames, useInsertSchema } from './hooks';
import schema from './schema';
import { getTabFormatValue, useLabelUiSchema } from './util';
import { transformNestedData } from './InternalCascadeSelect';
import { isObject } from './InternalViewer';
interface IEllipsisWithTooltipRef {
setPopoverVisible: (boolean) => void;
@ -34,18 +32,13 @@ const toValue = (value, placeholder) => {
}
return value;
};
export const ReadPrettyInternalTag: React.FC = observer(
(props: any) => {
const ButtonTabList: React.FC<ButtonListProps> = (props) => {
const fieldSchema = useFieldSchema();
const recordCtx = useRecord();
const { enableLink, tagColorField } = fieldSchema['x-component-props'];
// value 做了转换,但 props.value 和原来 useField().value 的值不一致
const field = useField();
const fieldNames = useFieldNames(props);
const [visible, setVisible] = useState(false);
const fieldNames = useFieldNames({ fieldNames: props.fieldNames });
const insertViewer = useInsertSchema('Viewer');
const { options: collectionField } = useAssociationFieldContext();
const [record, setRecord] = useState({});
const compile = useCompile();
const { designable } = useDesignable();
const labelUiSchema = useLabelUiSchema(collectionField, fieldNames?.label || 'label');
@ -54,7 +47,8 @@ export const ReadPrettyInternalTag: React.FC = observer(
const { getCollection } = useCollectionManager_deprecated();
const targetCollection = getCollection(collectionField?.target);
const isTreeCollection = targetCollection?.template === 'tree';
const [btnHover, setBtnHover] = useState(false);
const { openPopup } = usePagePopup();
const recordData = useCollectionRecordData();
const renderRecords = () =>
toArr(props.value).map((record, index, arr) => {
@ -76,17 +70,19 @@ export const ReadPrettyInternalTag: React.FC = observer(
) : enableLink !== false ? (
<a
onMouseEnter={() => {
setBtnHover(true);
props.setBtnHover(true);
}}
onClick={(e) => {
setBtnHover(true);
props.setBtnHover(true);
e.stopPropagation();
e.preventDefault();
if (designable) {
insertViewer(schema.Viewer);
}
setVisible(true);
setRecord(record);
openPopup({
recordData: record,
parentRecordData: recordData,
});
ellipsisWithTooltipRef?.current?.setPopoverVisible(false);
}}
>
@ -101,59 +97,12 @@ export const ReadPrettyInternalTag: React.FC = observer(
);
});
const btnElement = (
<EllipsisWithTooltip ellipsis={true} ref={ellipsisWithTooltipRef}>
{renderRecords()}
</EllipsisWithTooltip>
);
return <>{renderRecords()}</>;
};
if (enableLink === false || !btnHover) {
return btnElement;
}
const renderWithoutTableFieldResourceProvider = () => (
<WithoutTableFieldResource.Provider value={true}>
<FormProvider>
<RecursionField
schema={fieldSchema}
onlyRenderProperties
basePath={field.address}
filterProperties={(s) => {
return s['x-component'] === 'AssociationField.Viewer';
}}
/>
</FormProvider>
</WithoutTableFieldResource.Provider>
);
const renderRecordProvider = () => {
const collectionFieldNames = fieldSchema?.['x-collection-field']?.split('.');
return collectionFieldNames && collectionFieldNames.length > 2 ? (
<RecordProvider record={record} parent={recordCtx[collectionFieldNames[1]]}>
{renderWithoutTableFieldResourceProvider()}
</RecordProvider>
) : (
<RecordProvider record={record} parent={recordCtx}>
{renderWithoutTableFieldResourceProvider()}
</RecordProvider>
);
};
return (
<div>
<BlockAssociationContext.Provider value={`${collectionField?.collectionName}.${collectionField?.name}`}>
<CollectionProvider_deprecated name={collectionField?.target ?? collectionField?.targetCollection}>
{btnElement}
<ActionContextProvider
value={{ visible, setVisible, openMode: 'drawer', snapshot: collectionField?.interface === 'snapshot' }}
>
{renderRecordProvider()}
</ActionContextProvider>
</CollectionProvider_deprecated>
</BlockAssociationContext.Provider>
</div>
);
export const ReadPrettyInternalTag: React.FC = observer(
(props: any) => {
return <ReadPrettyInternalViewer {...props} ButtonList={ButtonTabList} />;
},
{ displayName: 'ReadPrettyInternalTag' },
);

View File

@ -9,18 +9,16 @@
import { observer, RecursionField, useField, useFieldSchema } from '@formily/react';
import { toArr } from '@formily/shared';
import React, { Fragment, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import React, { FC, Fragment, useRef, useState } from 'react';
import { useDesignable } from '../../';
import { BlockAssociationContext, WithoutTableFieldResource } from '../../../block-provider';
import { CollectionProvider_deprecated, useCollectionManager_deprecated } from '../../../collection-manager';
import { Collection } from '../../../data-source';
import { WithoutTableFieldResource } from '../../../block-provider';
import { useCollectionManager, useCollectionRecordData } from '../../../data-source';
import { VariablePopupRecordProvider } from '../../../modules/variable/variablesProvider/VariablePopupRecordProvider';
import { RecordProvider, useRecord } from '../../../record-provider';
import { FormProvider } from '../../core';
import { useCompile } from '../../hooks';
import { ActionContextProvider, useActionContext } from '../action';
import { EllipsisWithTooltip } from '../input/EllipsisWithTooltip';
import { PopupVisibleProvider } from '../page/PagePopups';
import { usePagePopup } from '../page/pagePopupUtils';
import { useAssociationFieldContext, useFieldNames, useInsertSchema } from './hooks';
import { transformNestedData } from './InternalCascadeSelect';
import schema from './schema';
@ -39,28 +37,32 @@ const toValue = (value, placeholder) => {
export function isObject(value) {
return typeof value === 'object' && value !== null;
}
export const ReadPrettyInternalViewer: React.FC = observer(
(props: any) => {
export interface ButtonListProps {
value: any;
setBtnHover: any;
fieldNames?: {
label: string;
value: string;
};
}
const ButtonLinkList: FC<ButtonListProps> = (props) => {
const fieldSchema = useFieldSchema();
const recordCtx = useRecord();
const { getCollection } = useCollectionManager_deprecated();
const cm = useCollectionManager();
const { enableLink } = fieldSchema['x-component-props'] || {};
// value 做了转换,但 props.value 和原来 useField().value 的值不一致
const field = useField();
const fieldNames = useFieldNames(props);
const [visible, setVisible] = useState(false);
const fieldNames = useFieldNames({ fieldNames: props.fieldNames });
const insertViewer = useInsertSchema('Viewer');
const { options: collectionField } = useAssociationFieldContext();
const [record, setRecord] = useState({});
const compile = useCompile();
const { designable } = useDesignable();
const { snapshot } = useActionContext();
const targetCollection = getCollection(collectionField?.target);
const targetCollection = cm.getCollection(collectionField?.target);
const isTreeCollection = targetCollection?.template === 'tree';
const ellipsisWithTooltipRef = useRef<IEllipsisWithTooltipRef>();
const getLabelUiSchema = useLabelUiSchemaV2();
const [btnHover, setBtnHover] = useState(false);
const { t } = useTranslation();
const { openPopup } = usePagePopup();
const recordData = useCollectionRecordData();
const renderRecords = () =>
toArr(props.value).map((record, index, arr) => {
@ -86,17 +88,19 @@ export const ReadPrettyInternalViewer: React.FC = observer(
) : enableLink !== false ? (
<a
onMouseEnter={() => {
setBtnHover(true);
props.setBtnHover(true);
}}
onClick={(e) => {
setBtnHover(true);
props.setBtnHover(true);
e.stopPropagation();
e.preventDefault();
if (designable) {
insertViewer(schema.Viewer);
}
setVisible(true);
setRecord(record);
openPopup({
recordData: record,
parentRecordData: recordData,
});
ellipsisWithTooltipRef?.current?.setPopoverVisible(false);
}}
>
@ -111,19 +115,45 @@ export const ReadPrettyInternalViewer: React.FC = observer(
);
});
return <>{renderRecords()}</>;
};
interface ReadPrettyInternalViewerProps {
ButtonList: FC<ButtonListProps>;
value: any;
fieldNames?: {
label: string;
value: string;
};
}
export const ReadPrettyInternalViewer: React.FC = observer(
(props: ReadPrettyInternalViewerProps) => {
const { value, ButtonList = ButtonLinkList } = props;
const fieldSchema = useFieldSchema();
const { enableLink } = fieldSchema['x-component-props'] || {};
// value 做了转换,但 props.value 和原来 useField().value 的值不一致
const field = useField();
const [visible, setVisible] = useState(false);
const { options: collectionField } = useAssociationFieldContext();
const ellipsisWithTooltipRef = useRef<IEllipsisWithTooltipRef>();
const { visibleWithURL, setVisibleWithURL } = usePagePopup();
const [btnHover, setBtnHover] = useState(!!visibleWithURL);
const btnElement = (
<EllipsisWithTooltip ellipsis={true} ref={ellipsisWithTooltipRef}>
{renderRecords()}
<ButtonList setBtnHover={setBtnHover} value={value} fieldNames={props.fieldNames} />
</EllipsisWithTooltip>
);
if (enableLink === false || !btnHover) {
return btnElement;
}
const renderWithoutTableFieldResourceProvider = () => (
<VariablePopupRecordProvider recordData={record} collection={targetCollection as Collection}>
// The recordData here is only provided when the popup is opened, not the current row record
<VariablePopupRecordProvider>
<WithoutTableFieldResource.Provider value={true}>
<FormProvider>
<RecursionField
schema={fieldSchema}
onlyRenderProperties
@ -132,44 +162,28 @@ export const ReadPrettyInternalViewer: React.FC = observer(
return s['x-component'] === 'AssociationField.Viewer';
}}
/>
</FormProvider>
</WithoutTableFieldResource.Provider>
</VariablePopupRecordProvider>
);
const renderRecordProvider = () => {
const collectionFieldNames = fieldSchema?.['x-collection-field']?.split('.');
return collectionFieldNames && collectionFieldNames.length > 2 ? (
<RecordProvider record={record} parent={recordCtx[collectionFieldNames[1]]}>
{renderWithoutTableFieldResourceProvider()}
</RecordProvider>
) : (
<RecordProvider record={record} parent={recordCtx}>
{renderWithoutTableFieldResourceProvider()}
</RecordProvider>
);
};
return (
<div>
<BlockAssociationContext.Provider value={`${collectionField?.collectionName}.${collectionField?.name}`}>
<CollectionProvider_deprecated name={collectionField?.target ?? collectionField?.targetCollection}>
{btnElement}
<PopupVisibleProvider visible={false}>
<ActionContextProvider
value={{
visible,
setVisible,
visible: visible || visibleWithURL,
setVisible: (value) => {
setVisible?.(value);
setVisibleWithURL?.(value);
},
openMode: 'drawer',
snapshot: collectionField?.interface === 'snapshot',
fieldSchema: fieldSchema,
}}
>
{renderRecordProvider()}
{btnElement}
{renderWithoutTableFieldResourceProvider()}
</ActionContextProvider>
</CollectionProvider_deprecated>
</BlockAssociationContext.Provider>
</div>
</PopupVisibleProvider>
);
},
{ displayName: 'ReadPrettyInternalViewer' },

View File

@ -185,7 +185,14 @@ export default function useServiceOptions(props) {
}, [collectionField?.target, action, filter, service]);
}
export const useFieldNames = (props) => {
export const useFieldNames = (
props: {
fieldNames?: {
label: string;
value: string;
};
} = {},
) => {
const fieldSchema = useFieldSchema();
const fieldNames =
fieldSchema['x-component-props']?.['field']?.['uiSchema']?.['x-component-props']?.['fieldNames'] ||

View File

@ -7,19 +7,19 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useFieldSchema } from '@formily/react';
import { Button, Result, Typography } from 'antd';
import React, { FC } from 'react';
import { FallbackProps } from 'react-error-boundary';
import { Trans, useTranslation } from 'react-i18next';
import { ErrorFallbackModal } from './ErrorFallbackModal';
import { useAPIClient } from '../../../api-client';
import { useFieldSchema } from '@formily/react';
import { useLocation } from 'react-router-dom';
import { useLocationNoUpdate } from '../../../application';
import { ErrorFallbackModal } from './ErrorFallbackModal';
const { Paragraph, Text, Link } = Typography;
export const useDownloadLogs = (error: any, data: Record<string, any> = {}) => {
const location = useLocation();
const location = useLocationNoUpdate();
const [loading, setLoading] = React.useState(false);
const api = useAPIClient();
return {

View File

@ -32,7 +32,8 @@ const InternalGridCardBlockProvider = (props) => {
useEffect(() => {
if (!service?.loading) {
form.setValuesIn(field.address.concat('list').toString(), service?.data?.data);
// @ts-ignore
form.fields[field.address.concat('list').toString()]?.setValue(service?.data?.data);
}
}, [field.address, form, service?.data?.data, service?.loading]);

View File

@ -32,7 +32,10 @@ const InternalListBlockProvider = (props) => {
useEffect(() => {
if (!service?.loading) {
form.setValuesIn(field.address.concat('list').toString(), service?.data?.data);
form.query(/\.list$/).forEach((field) => {
// @ts-ignore
field.setValue?.(service?.data?.data);
});
}
}, [field.address, form, service?.data?.data, service?.loading]);

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { cx, css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import { ArrayField } from '@formily/core';
import { RecursionField, Schema, useField, useFieldSchema } from '@formily/react';
import { List as AntdList, PaginationProps, theme } from 'antd';

View File

@ -10,7 +10,7 @@
import { TreeSelect } from '@formily/antd-v5';
import { Field, onFieldChange } from '@formily/core';
import { ISchema, Schema, useField, useFieldSchema } from '@formily/react';
import React from 'react';
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { findByUid } from '.';
import { createDesignable, useCompile } from '../..';
@ -210,6 +210,8 @@ const InsertMenuItems = (props) => {
);
};
const components = { TreeSelect };
export const MenuDesigner = () => {
const field = useField();
const fieldSchema = useFieldSchema();
@ -218,9 +220,13 @@ export const MenuDesigner = () => {
const { t } = useTranslation();
const menuSchema = findMenuSchema(fieldSchema);
const compile = useCompile();
const onSelect = compile(menuSchema?.['x-component-props']?.['onSelect']);
const items = toItems(menuSchema?.properties);
const effects = (form) => {
const onSelect = useMemo(
() => compile(menuSchema?.['x-component-props']?.['onSelect']),
[menuSchema?.['x-component-props']?.['onSelect']],
);
const items = useMemo(() => toItems(menuSchema?.properties), [menuSchema?.properties]);
const effects = useCallback(
(form) => {
onFieldChange('target', (field: Field) => {
const [, component] = field?.value?.split?.('||') || [];
field.query('position').take((f: Field) => {
@ -237,8 +243,11 @@ export const MenuDesigner = () => {
];
});
});
};
const schema = {
},
[t],
);
const schema = useMemo(() => {
return {
type: 'object',
title: t('Edit menu item'),
properties: {
@ -256,10 +265,13 @@ export const MenuDesigner = () => {
},
},
};
const initialValues = {
}, [t]);
const initialValues = useMemo(() => {
return {
title: field.title,
icon: field.componentProps.icon,
};
}, [field]);
if (fieldSchema['x-component'] === 'Menu.URL') {
schema.properties['href'] = {
title: t('Link'),
@ -268,14 +280,8 @@ export const MenuDesigner = () => {
};
initialValues['href'] = field.componentProps.href;
}
return (
<GeneralSchemaDesigner>
<SchemaSettingsModalItem
title={t('Edit')}
eventKey="edit"
schema={schema as ISchema}
initialValues={initialValues}
onSubmit={({ title, icon, href }) => {
const onEditSubmit: (values: any) => void = useCallback(
({ title, icon, href }) => {
const schema = {
['x-uid']: fieldSchema['x-uid'],
'x-server-hooks': [
@ -301,15 +307,12 @@ export const MenuDesigner = () => {
dn.emit('patch', {
schema,
});
}}
/>
<SchemaSettingsModalItem
title={t('Move to')}
eventKey="move-to"
components={{ TreeSelect }}
effects={effects}
schema={
{
},
[fieldSchema, field, dn, refresh, onSelect],
);
const modalSchema = useMemo(() => {
return {
type: 'object',
title: t('Move to'),
properties: {
@ -333,9 +336,11 @@ export const MenuDesigner = () => {
'x-decorator': 'FormItem',
},
},
} as ISchema
}
onSubmit={({ target, position }) => {
} as ISchema;
}, [items, t]);
const onMoveToSubmit: (values: any) => void = useCallback(
({ target, position }) => {
const [uid] = target?.split?.('||') || [];
if (!uid) {
return;
@ -349,18 +354,39 @@ export const MenuDesigner = () => {
});
dn.loadAPIClientEvents();
dn.insertAdjacent(position, fieldSchema);
}}
},
[fieldSchema, menuSchema, t, api, refresh],
);
const removeConfirmTitle = useMemo(() => {
return {
title: t('Delete menu item'),
};
}, [t]);
return (
<GeneralSchemaDesigner>
<SchemaSettingsModalItem
title={t('Edit')}
eventKey="edit"
schema={schema as ISchema}
initialValues={initialValues}
onSubmit={onEditSubmit}
/>
<SchemaSettingsModalItem
title={t('Move to')}
eventKey="move-to"
components={components}
effects={effects}
schema={modalSchema}
onSubmit={onMoveToSubmit}
/>
<SchemaSettingsDivider />
<InsertMenuItems eventKey={'insertbeforeBegin'} title={t('Insert before')} insertPosition={'beforeBegin'} />
<InsertMenuItems eventKey={'insertafterEnd'} title={t('Insert after')} insertPosition={'afterEnd'} />
<InsertMenuItems eventKey={'insertbeforeEnd'} title={t('Insert inner')} insertPosition={'beforeEnd'} />
<SchemaSettingsDivider />
<SchemaSettingsRemove
confirm={{
title: t('Delete menu item'),
}}
/>
<SchemaSettingsRemove confirm={removeConfirmTitle} />
</GeneralSchemaDesigner>
);
};

View File

@ -20,7 +20,7 @@ import {
import { uid } from '@formily/shared';
import { error } from '@nocobase/utils/client';
import { Menu as AntdMenu, MenuProps } from 'antd';
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { createDesignable, DndContext, SortableItem, useDesignable, useDesigner } from '../..';
@ -229,13 +229,8 @@ const HeaderMenu = ({
return result;
}, [children, designable]);
return (
<>
<Component />
<AntdMenu
{...others}
className={headerMenuClass}
onSelect={(info: any) => {
const handleSelect = useCallback(
(info: any) => {
const s = schema.properties?.[info.key];
if (!s) {
@ -269,7 +264,17 @@ const HeaderMenu = ({
} else {
onSelect?.(info);
}
}}
},
[schema, mode, onSelect, setLoading, setDefaultSelectedKeys],
);
return (
<>
<Component />
<AntdMenu
{...others}
className={headerMenuClass}
onSelect={handleSelect}
mode={mode === 'mix' ? 'horizontal' : mode}
defaultOpenKeys={defaultOpenKeys}
defaultSelectedKeys={defaultSelectedKeys}
@ -347,12 +352,8 @@ const SideMenu = ({
mode={'inline'}
openKeys={openKeys}
selectedKeys={selectedKeys}
onSelect={(info) => {
onSelect?.(info);
}}
onOpenChange={(openKeys) => {
setOpenKeys(openKeys);
}}
onSelect={onSelect}
onOpenChange={setOpenKeys}
className={sideMenuClass}
items={items as MenuProps['items']}
/>
@ -496,6 +497,14 @@ export const Menu: ComposedMenu = observer(
{ displayName: 'Menu' },
);
const menuItemTitleStyle = {
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'inline-block',
width: '100%',
verticalAlign: 'middle',
};
Menu.Item = observer(
(props) => {
const { t } = useMenuTranslation();
@ -521,17 +530,7 @@ Menu.Item = observer(
removeParentsIfNoChildren={false}
>
<Icon type={icon} />
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'inline-block',
width: '100%',
verticalAlign: 'middle',
}}
>
{t(field.title)}
</span>
<span style={menuItemTitleStyle}>{t(field.title)}</span>
<Designer />
</SortableItem>
</FieldContext.Provider>

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { genStyleHook } from './../__builtins__';
import { genStyleHook } from '../__builtins__';
export const useStyles = genStyleHook('nb-page', (token) => {
const { componentCls } = token;

View File

@ -13,13 +13,14 @@ import { FormLayout } from '@formily/antd-v5';
import { Schema, SchemaOptionsContext, useFieldSchema } from '@formily/react';
import { Button, Tabs } from 'antd';
import classNames from 'classnames';
import React, { useContext, useEffect, useMemo, useState } from 'react';
import React, { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import { Outlet, useOutletContext, useParams, useSearchParams } from 'react-router-dom';
import { FormDialog } from '..';
import { useStyles as useAClStyles } from '../../../acl/style';
import { useRequest } from '../../../api-client';
import { useNavigateNoUpdate } from '../../../application/CustomRouterContextProvider';
import { useAppSpin } from '../../../application/hooks/useAppSpin';
import { useDocumentTitle } from '../../../document-title';
import { useGlobalTheme } from '../../../global-theme';
@ -32,8 +33,8 @@ import { useCompile, useDesignable } from '../../hooks';
import { useToken } from '../__builtins__';
import { ErrorFallback } from '../error-fallback';
import FixedBlock from './FixedBlock';
import { useStyles } from './Page.style';
import { PageDesigner, PageTabDesigner } from './PageTabDesigner';
import { useStyles } from './style';
export const Page = (props) => {
const { children, ...others } = props;
@ -44,6 +45,7 @@ export const Page = (props) => {
const dn = useDesignable();
const { theme } = useGlobalTheme();
const { getAriaLabel } = useGetAriaLabelOfSchemaInitializer();
const { tabUid, name: pageUid } = useParams();
// react18 tab 动画会卡顿,所以第一个 tab 时,动画禁用,后面的 tab 才启用
const [hasMounted, setHasMounted] = useState(false);
@ -62,11 +64,13 @@ export const Page = (props) => {
const enablePageTabs = fieldSchema['x-component-props']?.enablePageTabs;
const hidePageTitle = fieldSchema['x-component-props']?.hidePageTitle;
const options = useContext(SchemaOptionsContext);
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigateNoUpdate();
const [searchParams] = useSearchParams();
const [loading, setLoading] = useState(false);
const activeKey = useMemo(
() => searchParams.get('tab') || Object.keys(fieldSchema.properties || {}).shift(),
[fieldSchema.properties, searchParams],
// 处理 searchParams 是为了兼容旧版的 tab 参数
() => tabUid || searchParams.get('tab') || Object.keys(fieldSchema.properties || {}).shift(),
[fieldSchema.properties, searchParams, tabUid],
);
const [height, setHeight] = useState(0);
const { wrapSSR, hashId, componentCls } = useStyles();
@ -87,27 +91,12 @@ export const Page = (props) => {
},
);
const handleErrors = (error) => {
const handleErrors = useCallback((error) => {
console.error(error);
};
}, []);
return wrapSSR(
<div className={`${componentCls} ${hashId} ${aclStyles.styles}`}>
<PageDesigner title={fieldSchema.title || title} />
<div
ref={(ref) => {
setHeight(Math.floor(ref?.getBoundingClientRect().height || 0) + 1);
}}
>
{!disablePageHeader && (
<AntdPageHeader
className={classNames('pageHeaderCss', pageHeaderTitle || enablePageTabs ? '' : 'height0')}
ghost={false}
// 如果标题为空的时候会导致 PageHeader 不渲染,所以这里设置一个空白字符,然后再设置高度为 0
title={pageHeaderTitle || ' '}
{...others}
footer={
enablePageTabs && (
const footer = useMemo(() => {
return enablePageTabs ? (
<DndContext>
<Tabs
size={'small'}
@ -122,7 +111,7 @@ export const Page = (props) => {
}}
onTabClick={(activeKey) => {
setLoading(true);
setSearchParams([['tab', activeKey]]);
navigate(`/admin/${pageUid}/tabs/${activeKey}`);
setTimeout(() => {
setLoading(false);
}, 50);
@ -199,31 +188,85 @@ export const Page = (props) => {
})}
/>
</DndContext>
)
}
) : null;
}, [
hasMounted,
activeKey,
fieldSchema,
dn.designable,
options.scope,
options.components,
pageUid,
fieldSchema.mapProperties((schema) => schema.title || t('Unnamed')).join(),
enablePageTabs,
]);
return wrapSSR(
<div className={`${componentCls} ${hashId} ${aclStyles.styles}`}>
<PageDesigner title={fieldSchema.title || title} />
<div
ref={(ref) => {
setHeight(Math.floor(ref?.getBoundingClientRect().height || 0) + 1);
}}
>
{!disablePageHeader && (
<AntdPageHeader
className={classNames('pageHeaderCss', pageHeaderTitle || enablePageTabs ? '' : 'height0')}
ghost={false}
// 如果标题为空的时候会导致 PageHeader 不渲染,所以这里设置一个空白字符,然后再设置高度为 0
title={pageHeaderTitle || ' '}
{...others}
footer={footer}
/>
)}
</div>
<div className="nb-page-wrapper">
<ErrorBoundary FallbackComponent={ErrorFallback} onError={handleErrors}>
{PageContent(loading, disablePageHeader, enablePageTabs, fieldSchema, activeKey, height, props)}
{tabUid ? (
// used to match the rout with name "admin.page.tab"
<Outlet context={{ loading, disablePageHeader, enablePageTabs, fieldSchema, height, tabUid }} />
) : (
<>
<PageContent {...{ loading, disablePageHeader, enablePageTabs, fieldSchema, height, activeKey }} />
{/* Used to match the route with name "admin.page.popup" */}
<Outlet />
</>
)}
</ErrorBoundary>
</div>
</div>,
);
};
export const PageTabs = () => {
const { loading, disablePageHeader, enablePageTabs, fieldSchema, height, tabUid } = useOutletContext<any>();
return (
<>
<PageContent {...{ loading, disablePageHeader, enablePageTabs, fieldSchema, activeKey: tabUid, height }} />
{/* used to match the route with name "admin.page.tab.popup" */}
<Outlet />
</>
);
};
Page.displayName = 'Page';
function PageContent(
loading: boolean,
disablePageHeader: any,
enablePageTabs: any,
fieldSchema: Schema<any, any, any, any, any, any, any, any, any>,
activeKey: string,
height: number,
props: any,
): React.ReactNode {
const PageContent = memo(
({
loading,
disablePageHeader,
enablePageTabs,
fieldSchema,
activeKey,
height,
}: {
loading: boolean;
disablePageHeader: any;
enablePageTabs: any;
fieldSchema: Schema<any, any, any, any, any, any, any, any, any>;
activeKey: string;
height: number;
}) => {
const { token } = useToken();
const { render } = useAppSpin();
@ -231,7 +274,9 @@ function PageContent(
return render();
}
return !disablePageHeader && enablePageTabs ? (
return (
<>
{!disablePageHeader && enablePageTabs ? (
fieldSchema.mapProperties((schema) => {
if (schema.name !== activeKey) return null;
@ -256,5 +301,9 @@ function PageContent(
<SchemaComponent schema={fieldSchema} distributed />
</div>
</FixedBlock>
)}
</>
);
}
},
);
PageContent.displayName = 'PageContent';

View File

@ -0,0 +1,245 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { ISchema } from '@formily/json-schema';
import _ from 'lodash';
import { FC, default as React, useCallback, useEffect, useMemo, useRef, useState } from 'react';
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 { usePopupSettings } from './PopupSettingsProvider';
import { deleteRandomNestedSchemaKey, getRandomNestedSchemaKey } from './nestedSchemaKeyStorage';
import { PopupParams, getPopupParamsFromPath, getStoredPopupContext, usePagePopup } from './pagePopupUtils';
import {
PopupContext,
getPopupContextFromActionOrAssociationFieldSchema,
} from './usePopupContextInActionOrAssociationField';
interface PopupsVisibleProviderProps {
visible: boolean;
setVisible?: (value: boolean) => void;
}
interface PopupProps {
params: PopupParams;
context: PopupContext;
}
export const PopupVisibleProviderContext = React.createContext<PopupsVisibleProviderProps>(null);
export const PopupParamsProviderContext = React.createContext<PopupProps>(null);
PopupVisibleProviderContext.displayName = 'PopupVisibleProviderContext';
PopupParamsProviderContext.displayName = 'PopupParamsProviderContext';
export const usePopupContextAndParams = () => {
const context = React.useContext(PopupParamsProviderContext);
return (context || {}) as PopupProps;
};
/**
* The difference between this component and ActionContextProvider is that
* this component is only used to control the popups in the PagePopupsItem component (excluding the nested popups within it).
* @param param0
* @returns
*/
export const PopupVisibleProvider: FC<PopupsVisibleProviderProps> = ({ children, visible, setVisible }) => {
return (
<PopupVisibleProviderContext.Provider value={{ visible, setVisible }}>
{children}
</PopupVisibleProviderContext.Provider>
);
};
const PopupParamsProvider: FC<PopupProps> = (props) => {
const value = useMemo(() => {
return { params: props.params, context: props.context };
}, [props.params, props.context]);
return <PopupParamsProviderContext.Provider value={value}>{props.children}</PopupParamsProviderContext.Provider>;
};
const PopupTabsPropsProvider: FC<{ params: PopupParams }> = ({ children, params }) => {
const { changeTab } = usePagePopup();
const onTabClick = useCallback(
(key: string) => {
changeTab(key);
},
[changeTab],
);
const { isPopupVisibleControlledByURL } = usePopupSettings();
if (!isPopupVisibleControlledByURL) {
return <>{children}</>;
}
return (
<TabsContextProvider activeKey={params.tab} onTabClick={onTabClick}>
{children}
</TabsContextProvider>
);
};
const PagePopupsItemProvider: FC<{ params: PopupParams; context: PopupContext }> = ({ params, context, children }) => {
const { closePopup } = usePagePopup();
const [visible, _setVisible] = useState(true);
const setVisible = (visible: boolean) => {
if (!visible) {
_setVisible(false);
if (process.env.__E2E__) {
setTimeout(() => {
closePopup();
// Deleting here ensures that the next time the same popup is opened, it will generate another random key.
deleteRandomNestedSchemaKey(params.popupuid);
});
return;
}
// Leave some time to refresh the block data
setTimeout(() => {
closePopup();
// Deleting here ensures that the next time the same popup is opened, it will generate another random key.
deleteRandomNestedSchemaKey(params.popupuid);
}, 300);
}
};
const storedContext = { ...getStoredPopupContext(params.popupuid) };
if (!context) {
context = storedContext;
}
return (
<PopupParamsProvider params={params} context={context}>
<PopupVisibleProvider visible={visible} setVisible={setVisible}>
<DataBlockProvider
dataSource={context.dataSource}
collection={context.collection}
association={context.association}
sourceId={context.sourceId}
filterByTk={params.filterbytk}
// @ts-ignore
record={storedContext.record}
parentRecord={storedContext.parentRecord}
action="get"
>
{/* Pass the service of the block where the button is located down, to refresh the block's data when the popup is closed */}
<BlockRequestContext.Provider value={storedContext.service}>
<PopupTabsPropsProvider params={params}>
<div style={{ display: 'none' }}>{children}</div>
</PopupTabsPropsProvider>
</BlockRequestContext.Provider>
</DataBlockProvider>
</PopupVisibleProvider>
</PopupParamsProvider>
);
};
/**
* insert childSchema to parentSchema to render the nested popups
* @param childSchema
* @param props
* @param parentSchema
*/
export const insertChildToParentSchema = (childSchema: ISchema, props: PopupProps, parentSchema: ISchema) => {
const { params, context } = props;
const componentSchema = {
type: 'void',
'x-component': 'PagePopupsItemProvider',
'x-component-props': {
params,
context,
},
properties: {
popupAction: childSchema,
},
};
// If we don't use a random name, it will cause the component's parameters not to be updated when reopening the popup
const nestedPopupKey = getRandomNestedSchemaKey(params.popupuid);
if (parentSchema.properties) {
const popupSchema = _.get(parentSchema.properties, Object.keys(parentSchema.properties)[0]);
if (_.isEmpty(_.get(popupSchema, `properties.${nestedPopupKey}`))) {
_.set(popupSchema, `properties.${nestedPopupKey}`, componentSchema);
}
}
};
export const PagePopups = (props: { paramsList?: PopupParams[] }) => {
const location = useLocation();
const popupParams = props.paramsList || getPopupParamsFromPath(getPopupPath(location));
const { requestSchema } = useRequestSchema();
const [rootSchema, setRootSchema] = useState<ISchema>(null);
const popupPropsRef = useRef<PopupProps[]>([]);
useEffect(() => {
const run = async () => {
const waitList = popupParams.map(
(params) => getStoredPopupContext(params.popupuid)?.schema || requestSchema(params.popupuid),
);
const schemas = await Promise.all(waitList);
const clonedSchemas = schemas.map((schema) => {
const result = _.cloneDeep(_.omit(schema, 'parent'));
result['x-read-pretty'] = true;
return result;
});
popupPropsRef.current = clonedSchemas.map((schema, index) => {
const schemaContext = getPopupContextFromActionOrAssociationFieldSchema(schema);
return {
params: popupParams[index],
context: schemaContext,
};
});
const rootSchema = clonedSchemas[0];
for (let i = 1; i < clonedSchemas.length; i++) {
insertChildToParentSchema(clonedSchemas[i], popupPropsRef.current[i], clonedSchemas[i - 1]);
}
setRootSchema(rootSchema);
};
run();
}, [popupParams, requestSchema]);
const components = useMemo(() => ({ PagePopupsItemProvider }), []);
if (!rootSchema) {
return null;
}
return (
<PagePopupsItemProvider params={popupPropsRef.current[0].params} context={popupPropsRef.current[0].context}>
<SchemaComponent components={components} schema={rootSchema} onlyRenderProperties />;
</PagePopupsItemProvider>
);
};
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;
}, []);
return { requestSchema };
};
/**
* 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 const getPopupPath = (location: Location) => {
const [, ...popupsPath] = location.pathname.split('/popups/');
return popupsPath.join('/popups/');
};

View File

@ -0,0 +1,45 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { 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>;
};
/**
* Hook for accessing the popup settings.
* @returns The popup settings.
*/
export const usePopupSettings = () => {
return (
React.useContext(PopupSettingsContext) || {
isPopupVisibleControlledByURL: true,
}
);
};

View File

@ -0,0 +1,25 @@
/**
* 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 { createStyles } from 'antd-style';
export const useSubPagesStyle = createStyles(({ css, token }: any) => {
return {
container: css`
.ant-tabs-nav {
background: ${token.colorBgContainer};
padding: 0 ${token.paddingPageVertical}px;
margin-bottom: 0;
}
.ant-tabs-content-holder {
padding: ${token.paddingPageVertical}px;
}
`,
};
});

View File

@ -0,0 +1,279 @@
/**
* 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={context.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 } = params;
const popupPath = [subpageuid, filterbytk && 'filterbytk', filterbytk, 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 = (record?.data || treeParentRecord)?.[collection.getPrimaryKey()];
const sourceId = parentRecord?.data?.[cm.getCollection(association?.split('.')[0])?.getPrimaryKey()];
const params = {
subpageuid: fieldSchema['x-uid'],
filterbytk: filterByTK,
};
storePopupContext(fieldSchema['x-uid'], {
schema: fieldSchema,
record,
parentRecord,
service,
dataSource: dataSourceKey,
collection: collection.name,
association,
sourceId,
parentPopupRecord: parentPopupRecordData
? {
collection: parentPopupRecordCollection?.name,
filterByTk: parentPopupRecordData[parentPopupRecordCollection.getPrimaryKey()],
}
: undefined,
});
updatePopupContext({
dataSource: dataSourceKey,
collection: association ? undefined : collection.name,
association: association,
sourceId,
parentPopupRecord: parentPopupRecordData
? {
collection: parentPopupRecordCollection?.name,
filterByTk: parentPopupRecordData[parentPopupRecordCollection.getPrimaryKey()],
}
: 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

@ -0,0 +1,101 @@
/**
* 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 { getPopupPath, insertChildToParentSchema } from '../PagePopups';
vi.mock('@formily/shared', async (importOriginal) => {
const actual: any = await importOriginal();
return {
...actual,
uid() {
return 'nestedPopup';
},
};
});
describe('insertToPopupSchema', () => {
it('should insert childSchema to parentSchema', () => {
const childSchema = {
type: 'string',
'x-component': 'Input',
};
const params: any = {
foo: 'bar',
};
const context: any = {
bar: 'foo',
};
const parentSchema = {
type: 'object',
properties: {
popup: {
type: 'void',
properties: {},
},
},
};
insertChildToParentSchema(childSchema, { params, context }, parentSchema);
expect(parentSchema).toEqual({
type: 'object',
properties: {
popup: {
type: 'void',
properties: {
nestedPopup: {
type: 'void',
'x-component': 'PagePopupsItemProvider',
'x-component-props': {
params,
context,
},
properties: {
popupAction: childSchema,
},
},
},
},
},
});
});
});
describe('getPopupPath', () => {
it('should return the popup path', () => {
const location: any = {
pathname: '/Users/Apple/Projects/nocobase/packages/core/client/src/schema-component/antd/page/popups/nestedPopup',
};
const result = getPopupPath(location);
expect(result).toEqual('nestedPopup');
});
it('should return the nested popup path', () => {
const location: any = {
pathname:
'/Users/Apple/Projects/nocobase/packages/core/client/src/schema-component/antd/page/popups/nestedPopup/abc/def/popups/nestedPopup2/abc2/def2',
};
const result = getPopupPath(location);
expect(result).toEqual('nestedPopup/abc/def/popups/nestedPopup2/abc2/def2');
});
it('should return an empty string if there is no popup path', () => {
const location: any = {
pathname: '/Users/Apple/Projects/nocobase/packages/core/client/src/schema-component/antd/page',
};
const result = getPopupPath(location);
expect(result).toEqual('');
});
});

View File

@ -0,0 +1,120 @@
/**
* 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',
};
const expectedPath = '/subpages/subPage1/filterbytk/filterbytk1/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';
const expectedSubPageParams = {
subpageuid: 'subPage1',
datasource: 'datasource1',
filterbytk: 'filterbytk1',
};
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

@ -0,0 +1,130 @@
/**
* 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 { getPopupParamsFromPath, getPopupPathFromParams, removeLastPopupPath } from '../pagePopupUtils';
describe('getPopupParamsFromPath', () => {
it('should parse the path and return the popup parameters', () => {
const path = 'popupUid/filterbytk/filterByTKValue/tab/tabValue';
const result = getPopupParamsFromPath(path);
expect(result).toEqual([
{
popupuid: 'popupUid',
filterbytk: 'filterByTKValue',
tab: 'tabValue',
},
]);
});
it('should handle multiple popups in the path', () => {
const path =
'popupUid1/filterbytk/filterByTKValue1/tab/tabValue1/popups/popupUid2/filterbytk/filterByTKValue2/tab/tabValue2';
const result = getPopupParamsFromPath(path);
expect(result).toEqual([
{
popupuid: 'popupUid1',
filterbytk: 'filterByTKValue1',
tab: 'tabValue1',
},
{
popupuid: 'popupUid2',
filterbytk: 'filterByTKValue2',
tab: 'tabValue2',
},
]);
});
it('when exist popups in path', () => {
const path = `popupUid1/filterbytk/${window.btoa('popups')}/tab/${window.btoa(
'popups',
)}/popups/popupUid2/filterbytk/filterByTKValue2/tab/tabValue2`;
const result = getPopupParamsFromPath(path);
expect(result).toEqual([
{
popupuid: 'popupUid1',
filterbytk: 'popups',
tab: 'popups',
},
{
popupuid: 'popupUid2',
filterbytk: 'filterByTKValue2',
tab: 'tabValue2',
},
]);
});
});
describe('getPopupPathFromParams', () => {
it('should generate the popup path from the parameters', () => {
const params = {
popupuid: 'popupUid',
filterbytk: 'filterByTKValue',
tab: 'tabValue',
};
const result = getPopupPathFromParams(params);
expect(result).toBe('/popups/popupUid/filterbytk/filterByTKValue/tab/tabValue');
});
it('should handle optional parameters', () => {
const params = {
popupuid: 'popupUid',
filterbytk: 'filterByTKValue',
tab: 'tabValue',
empty: undefined,
};
const result = getPopupPathFromParams(params);
expect(result).toBe('/popups/popupUid/filterbytk/filterByTKValue/tab/tabValue');
});
it('when exist popups in path', () => {
const params = {
popupuid: 'popupUid',
filterbytk: 'popups',
tab: 'popups',
};
const result = getPopupPathFromParams(params);
expect(result).toBe(`/popups/popupUid/filterbytk/${window.btoa('popups')}/tab/${window.btoa('popups')}`);
});
});
describe('removeLastPopupPath', () => {
it('should remove the last popup path from the given path', () => {
const path1 = '/admin/page/popups/popupUid/popups/popupUid2';
const result1 = removeLastPopupPath(path1);
expect(result1).toBe('/admin/page/popups/popupUid/');
const path2 = '/admin/page/popups/popupUid';
const result2 = removeLastPopupPath(path2);
expect(result2).toBe('/admin/page/');
});
it('should handle paths without popups', () => {
const path = '/admin/page';
const result = removeLastPopupPath(path);
expect(result).toBe(path);
});
it('should handle empty paths', () => {
const path = '';
const result = removeLastPopupPath(path);
expect(result).toBe('');
});
});

View File

@ -0,0 +1,100 @@
/**
* 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 { renderHook } from '@testing-library/react-hooks';
import { PopupContext, usePopupContextInActionOrAssociationField } from '../usePopupContextInActionOrAssociationField';
vi.mock('../../../hooks/useDesignable', async (importOriginal) => {
const actual: any = await importOriginal();
return {
...actual,
useDesignable() {
return { dn: dnMock };
},
};
});
vi.mock('@formily/react', async (importOriginal) => {
const actual: any = await importOriginal();
return {
...actual,
useFieldSchema() {
return fieldSchemaMock;
},
};
});
let fieldSchemaMock = null;
let dnMock = null;
describe('usePopupContextInActionOrAssociationField', () => {
test('updatePopupContext should update the x-action-context field in the popup schema', () => {
fieldSchemaMock = {
properties: {
drawer: {
'x-uid': 'drawer',
},
},
'x-uid': 'fieldSchemaMock',
};
dnMock = {
emit: vi.fn(),
};
const { result } = renderHook(() => usePopupContextInActionOrAssociationField());
const context: PopupContext = {
dataSource: 'dataSource',
collection: 'collection',
association: 'association',
sourceId: 'sourceId',
};
result.current.updatePopupContext(context);
expect(dnMock.emit).toHaveBeenCalledWith('patch', {
schema: {
'x-uid': fieldSchemaMock['x-uid'],
'x-action-context': context,
},
});
expect(dnMock.emit).toHaveBeenCalledTimes(1);
expect(fieldSchemaMock).toEqual({
properties: {
drawer: {
'x-uid': 'drawer',
},
},
'x-action-context': context,
'x-uid': 'fieldSchemaMock',
});
// Updating with the same values again should not trigger emit
result.current.updatePopupContext(context);
expect(dnMock.emit).toHaveBeenCalledTimes(1);
// It will filter out null and undefined values
result.current.updatePopupContext({
...context,
collection: undefined,
sourceId: null,
});
expect(dnMock.emit).toHaveBeenCalledWith('patch', {
schema: {
'x-uid': fieldSchemaMock['x-uid'],
'x-action-context': {
dataSource: 'dataSource',
association: 'association',
},
},
});
expect(dnMock.emit).toHaveBeenCalledTimes(2);
});
});

View File

@ -7,8 +7,9 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export * from './Page';
export * from './FixedBlock';
export * from './PageTab.Settings';
export * from './Page.Settings';
export * from './FixedBlockDesignerItem';
export * from './Page';
export * from './Page.Settings';
export * from './PageTab.Settings';
export { PopupSettingsProvider } from './PopupSettingsProvider';

View File

@ -0,0 +1,20 @@
/**
* 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 { uid } from '@formily/shared';
const randomNestedSchemaKeyStorage: Record<string, string> = {};
export const getRandomNestedSchemaKey = (popupUid: string) => {
return randomNestedSchemaKeyStorage[popupUid] || (randomNestedSchemaKeyStorage[popupUid] = uid());
};
export const deleteRandomNestedSchemaKey = (popupUid: string) => {
return delete randomNestedSchemaKeyStorage[popupUid];
};

View File

@ -0,0 +1,273 @@
/**
* 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, useFieldSchema } from '@formily/react';
import _ from 'lodash';
import { useCallback, useContext } from 'react';
import { useLocationNoUpdate, useNavigateNoUpdate } from '../../../application';
import {
CollectionRecord,
useAssociationName,
useCollection,
useCollectionManager,
useCollectionParentRecord,
useCollectionRecord,
useDataBlockRequest,
useDataSourceKey,
} from '../../../data-source';
import { useCurrentPopupRecord } from '../../../modules/variable/variablesProvider/VariablePopupRecordProvider';
import { ActionContext } from '../action/context';
import { PopupVisibleProviderContext, usePopupContextAndParams } from './PagePopups';
import { usePopupSettings } from './PopupSettingsProvider';
import { PopupContext, usePopupContextInActionOrAssociationField } from './usePopupContextInActionOrAssociationField';
export interface PopupParams {
/** popup uid */
popupuid: string;
/** record id */
filterbytk?: string;
/** tab uid */
tab?: string;
}
export interface PopupContextStorage extends PopupContext {
schema?: ISchema;
record?: CollectionRecord;
parentRecord?: CollectionRecord;
/** used to refresh data for block */
service?: any;
}
const popupsContextStorage: Record<string, PopupContextStorage> = {};
export const getStoredPopupContext = (popupUid: string) => {
return popupsContextStorage[popupUid];
};
export const storePopupContext = (popupUid: string, params: PopupContextStorage) => {
popupsContextStorage[popupUid] = params;
};
export const getPopupParamsFromPath = _.memoize((path: string) => {
const popupPaths = path.split('/popups/');
return popupPaths.filter(Boolean).map((popupPath) => {
const [popupUid, ...popupParams] = popupPath.split('/').filter(Boolean);
const obj = {};
for (let i = 0; i < popupParams.length; i += 2) {
obj[popupParams[i]] = decodePathValue(popupParams[i + 1]);
}
return {
popupuid: popupUid,
...obj,
} as PopupParams;
});
});
export const getPopupPathFromParams = (params: PopupParams) => {
const { popupuid: popupUid, tab, filterbytk } = params;
const popupPath = [popupUid, filterbytk && 'filterbytk', filterbytk, tab && 'tab', tab].filter(Boolean);
return `/popups/${popupPath.map((item) => encodePathValue(item)).join('/')}`;
};
export const usePagePopup = () => {
const navigate = useNavigateNoUpdate();
const location = useLocationNoUpdate();
const fieldSchema = useFieldSchema();
const dataSourceKey = useDataSourceKey();
const record = useCollectionRecord();
const parentRecord = useCollectionParentRecord();
const collection = useCollection();
const cm = useCollectionManager();
const association = useAssociationName();
const { visible, setVisible } = useContext(PopupVisibleProviderContext) || { visible: false, setVisible: () => {} };
const { params: popupParams } = usePopupContextAndParams();
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.getCollection(association?.split('.')[0])?.getPrimaryKey()],
[parentRecord, association],
);
const getNewPathname = useCallback(
({ tabKey, popupUid, recordData }: { tabKey?: string; popupUid: string; recordData: Record<string, any> }) => {
let _collection = collection;
if (association) {
_collection = cm.getCollection(association);
}
const filterByTK = recordData?.[_collection.getPrimaryKey()];
return getPopupPathFromParams({
popupuid: popupUid,
filterbytk: filterByTK,
tab: tabKey,
});
},
[association, cm, collection, dataSourceKey, parentRecord?.data, association],
);
const getPopupContext = useCallback(
(sourceId?: string) => {
const context = {
dataSource: dataSourceKey,
collection: association ? undefined : collection.name,
association,
sourceId: sourceId || getSourceId(),
parentPopupRecord: !_.isEmpty(parentPopupRecordData)
? {
collection: parentPopupRecordCollection?.name,
filterByTk: parentPopupRecordData[parentPopupRecordCollection.getPrimaryKey()],
}
: undefined,
};
return _.omitBy(context, _.isNil) as PopupContext;
},
[dataSourceKey, collection, association, getSourceId, parentPopupRecordData, parentPopupRecordCollection],
);
const openPopup = useCallback(
({
recordData,
parentRecordData,
}: {
recordData?: Record<string, any>;
parentRecordData?: Record<string, any>;
} = {}) => {
if (!isPopupVisibleControlledByURL) {
return setVisibleFromAction?.(true);
}
recordData = recordData || record?.data;
const pathname = getNewPathname({ popupUid: fieldSchema['x-uid'], recordData });
let url = location.pathname;
if (_.last(url) === '/') {
url = url.slice(0, -1);
}
const sourceId = getSourceId(parentRecordData);
storePopupContext(fieldSchema['x-uid'], {
schema: fieldSchema,
record: new CollectionRecord({ isNew: false, data: recordData }),
parentRecord: parentRecordData ? new CollectionRecord({ isNew: false, data: parentRecordData }) : parentRecord,
service,
dataSource: dataSourceKey,
collection: collection.name,
association,
sourceId,
parentPopupRecord: parentPopupRecordData
? {
collection: parentPopupRecordCollection?.name,
filterByTk: parentPopupRecordData[parentPopupRecordCollection.getPrimaryKey()],
}
: undefined,
});
updatePopupContext(getPopupContext(sourceId));
navigate(withSearchParams(`${url}${pathname}`));
},
[
association,
cm,
collection,
dataSourceKey,
fieldSchema,
getNewPathname,
navigate,
parentRecord,
record,
service,
location,
isPopupVisibleControlledByURL,
parentPopupRecordData,
getSourceId,
getPopupContext,
],
);
const closePopup = useCallback(() => {
if (!isPopupVisibleControlledByURL) {
return setVisibleFromAction?.(false);
}
navigate(withSearchParams(removeLastPopupPath(location.pathname)));
}, [navigate, location, isPopupVisibleControlledByURL]);
const changeTab = useCallback(
(key: string) => {
const pathname = getNewPathname({ tabKey: key, popupUid: popupParams?.popupuid, recordData: record?.data });
let url = removeLastPopupPath(location.pathname);
if (_.last(url) === '/') {
url = url.slice(0, -1);
}
navigate(`${url}${pathname}`);
},
[getNewPathname, navigate, popupParams?.popupuid, record?.data, location],
);
return {
/**
* used to open popup by changing the url
*/
openPopup,
/**
* used to close popup by changing the url
*/
closePopup,
visibleWithURL: visible,
setVisibleWithURL: setVisible,
popupParams,
changeTab,
getPopupContext,
};
};
// 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');
}
export function withSearchParams(path: string) {
return `${path}${window.location.search}`;
}
/**
* Prevent problems when "popups" appears in the path
* @param value
* @returns
*/
export function encodePathValue(value: string) {
const encodedValue = encodeURIComponent(value);
if (encodedValue === 'popups') {
return window.btoa(value);
}
return encodedValue;
}
/**
* Prevent problems when "popups" appears in the path
* @param value
* @returns
*/
export function decodePathValue(value: string) {
if (value === window.btoa('popups')) {
return 'popups';
}
return decodeURIComponent(value);
}

View File

@ -0,0 +1,85 @@
/**
* 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, useFieldSchema } from '@formily/react';
import _ from 'lodash';
import { useCallback } from 'react';
import { useDesignable } from '../../hooks/useDesignable';
export interface PopupContext {
dataSource: string;
collection?: string;
association?: string;
sourceId?: 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';
/**
* support only in Action or AssociationField, because it depends on a specific schema structure
* @returns
*/
export const usePopupContextInActionOrAssociationField = () => {
const fieldSchema = useFieldSchema();
const { dn } = useDesignable();
const updatePopupContext = useCallback(
(context: PopupContext) => {
context = _.omitBy(context, _.isNil) as PopupContext;
if (_.isEqual(context, getPopupContextFromActionOrAssociationFieldSchema(fieldSchema))) {
return;
}
fieldSchema[CONTEXT_SCHEMA_KEY] = context;
return dn.emit('patch', {
schema: {
'x-uid': fieldSchema['x-uid'],
[CONTEXT_SCHEMA_KEY]: context,
},
});
},
[fieldSchema, dn],
);
return {
/**
* update the value of the x-nb-popup-context field in the popup schema
*/
updatePopupContext,
};
};
/**
* @param fieldSchema support only schema of Action or AssociationField, because it depends on a specific schema structure
* @returns
*/
export function getPopupContextFromActionOrAssociationFieldSchema(fieldSchema: ISchema) {
return fieldSchema[CONTEXT_SCHEMA_KEY] as PopupContext;
}

View File

@ -10,11 +10,11 @@
import { createForm } from '@formily/core';
import { Schema } from '@formily/react';
import { Spin } from 'antd';
import React, { useMemo } from 'react';
import { useRequest } from '../../api-client';
import React, { memo, useMemo } from 'react';
import { useSchemaComponentContext } from '../hooks';
import { FormProvider } from './FormProvider';
import { SchemaComponent } from './SchemaComponent';
import { useRequestSchema } from './useRequestSchema';
export interface RemoteSchemaComponentProps {
scope?: any;
@ -42,15 +42,15 @@ const RequestSchemaComponent: React.FC<RemoteSchemaComponentProps> = (props) =>
schemaTransform = defaultTransform,
} = props;
const { reset } = useSchemaComponentContext();
const type = onlyRenderProperties ? 'getProperties' : 'getJsonSchema';
const conf = {
url: `/uiSchemas:${onlyRenderProperties ? 'getProperties' : 'getJsonSchema'}/${uid}`,
url: `/uiSchemas:${type}/${uid}`,
};
const form = useMemo(() => createForm(), [uid]);
const { data, loading } = useRequest<{
data: any;
}>(conf, {
refreshDeps: [uid],
onSuccess(data) {
const { schema, loading } = useRequestSchema({
uid,
type,
onSuccess: (data) => {
onSuccess && onSuccess(data);
reset && reset();
},
@ -62,14 +62,15 @@ const RequestSchemaComponent: React.FC<RemoteSchemaComponentProps> = (props) =>
return <Spin />;
}
return noForm ? (
<SchemaComponent memoized components={components} scope={scope} schema={schemaTransform(data?.data || {})} />
<SchemaComponent memoized components={components} scope={scope} schema={schemaTransform(schema || {})} />
) : (
<FormProvider form={form}>
<SchemaComponent memoized components={components} scope={scope} schema={schemaTransform(data?.data || {})} />
<SchemaComponent memoized components={components} scope={scope} schema={schemaTransform(schema || {})} />
</FormProvider>
);
};
export const RemoteSchemaComponent: React.FC<RemoteSchemaComponentProps> = (props) => {
export const RemoteSchemaComponent: React.FC<RemoteSchemaComponentProps> = memo((props) => {
return props.uid ? <RequestSchemaComponent {...props} /> : null;
};
});
RemoteSchemaComponent.displayName = 'RemoteSchemaComponent';

View File

@ -8,10 +8,10 @@
*/
import { IRecursionFieldProps, ISchemaFieldProps, RecursionField, Schema } from '@formily/react';
import React, { useContext, useMemo } from 'react';
import { useUpdate } from 'ahooks';
import React, { memo, useContext, useMemo } from 'react';
import { SchemaComponentContext } from '../context';
import { SchemaComponentOptions } from './SchemaComponentOptions';
import { useUpdate } from 'ahooks';
type SchemaComponentOnChange = {
onChange?: (s: Schema) => void;
@ -44,10 +44,10 @@ interface DistributedProps {
distributed?: boolean;
}
const RecursionSchemaComponent = (props: ISchemaFieldProps & SchemaComponentOnChange & DistributedProps) => {
const { components, scope, schema, distributed, ...others } = props;
const RecursionSchemaComponent = memo((props: ISchemaFieldProps & SchemaComponentOnChange & DistributedProps) => {
const { components, scope, schema: _schema, distributed, ...others } = props;
const ctx = useContext(SchemaComponentContext);
const s = useMemo(() => toSchema(schema), [schema]);
const schema = useMemo(() => toSchema(_schema), [_schema]);
const refresh = useUpdate();
return (
@ -60,30 +60,32 @@ const RecursionSchemaComponent = (props: ISchemaFieldProps & SchemaComponentOnCh
if (ctx.distributed === false || distributed === false) {
ctx.refresh?.();
}
props.onChange?.(s);
props.onChange?.(schema);
},
}}
>
<SchemaComponentOptions inherit components={components} scope={scope}>
<RecursionField {...others} schema={s} />
<RecursionField {...others} schema={schema} />
</SchemaComponentOptions>
</SchemaComponentContext.Provider>
);
};
});
const MemoizedSchemaComponent = (props: ISchemaFieldProps & SchemaComponentOnChange & DistributedProps) => {
const MemoizedSchemaComponent = memo((props: ISchemaFieldProps & SchemaComponentOnChange & DistributedProps) => {
const { schema, ...others } = props;
const s = useMemoizedSchema(schema);
return <RecursionSchemaComponent {...others} schema={s} />;
};
});
export const SchemaComponent = (
export const SchemaComponent = memo(
(
props: (ISchemaFieldProps | IRecursionFieldProps) & { memoized?: boolean } & SchemaComponentOnChange &
DistributedProps,
) => {
) => {
const { memoized, ...others } = props;
if (memoized) {
return <MemoizedSchemaComponent {...others} />;
}
return <RecursionSchemaComponent {...others} />;
};
},
);

View File

@ -0,0 +1,34 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useRequest } from '../../api-client';
export const useRequestSchema = ({
uid,
type = 'getJsonSchema',
onSuccess,
}: {
uid: string;
type?: 'getProperties' | 'getJsonSchema';
onSuccess?: (data: any) => void;
}) => {
const conf = {
url: `/uiSchemas:${type}/${uid}`,
};
const { data, loading } = useRequest<{
data: any;
}>(conf, {
refreshDeps: [uid],
onSuccess(data) {
onSuccess && onSuccess(data);
},
});
return { schema: data?.data, loading };
};

View File

@ -8,20 +8,19 @@
*/
import { DownOutlined } from '@ant-design/icons';
import { observer, RecursionField, useField, useFieldSchema, useForm } from '@formily/react';
import { observer, useField, useFieldSchema, useForm } from '@formily/react';
import { Button, Dropdown, MenuProps } from 'antd';
import React, { useEffect, useMemo, useState, forwardRef, createRef } from 'react';
import { composeRef } from 'rc-util/lib/ref';
import { useDesignable } from '../../';
import { useACLRolesCheck, useRecordPkValue, useACLActionParamsContext } from '../../acl/ACLProvider';
import {
CollectionProvider_deprecated,
useCollection_deprecated,
useCollectionManager_deprecated,
} from '../../collection-manager';
import React, { createRef, forwardRef, useEffect, useMemo } from 'react';
import { Collection, useDesignable } from '../../';
import { useACLActionParamsContext, useACLRolesCheck, useRecordPkValue } from '../../acl/ACLProvider';
import { useCollectionManager_deprecated, useCollection_deprecated } from '../../collection-manager';
import { useTreeParentRecord } from '../../modules/blocks/data-blocks/table/TreeRecordProvider';
import { useRecord } from '../../record-provider';
import { ActionContextProvider, useActionContext, useCompile } from '../../schema-component';
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';
@ -66,17 +65,17 @@ function useAclCheckFn() {
}
const InternalCreateRecordAction = (props: any, ref) => {
const [visible, setVisible] = useState(false);
const collection = useCollection_deprecated();
const fieldSchema = useFieldSchema();
const openMode = fieldSchema?.['x-component-props']?.['openMode'];
const field: any = useField();
const [currentCollection, setCurrentCollection] = useState(collection.name);
const [currentCollectionDataSource, setCurrentCollectionDataSource] = useState(collection.dataSource);
const linkageRules: any[] = fieldSchema?.['x-linkage-rules'] || [];
const values = useRecord();
const ctx = useActionContext();
const variables = useVariables();
const localVariables = useLocalVariables({ currentForm: { values } as any });
const { openPopup } = usePagePopup();
const { navigateToSubPage } = useNavigateTOSubPage();
const treeRecordData = useTreeParentRecord();
useEffect(() => {
field.stateOfLinkageRules = {};
linkageRules
@ -95,26 +94,26 @@ const InternalCreateRecordAction = (props: any, ref) => {
}, [field, linkageRules, localVariables, variables]);
const internalRef = createRef<HTMLButtonElement | HTMLAnchorElement>();
const buttonRef = composeRef(ref, internalRef);
return (
//@ts-ignore
<div ref={buttonRef as React.Ref<HTMLButtonElement>}>
<CreateAction
{...props}
onClick={(collectionData) => {
if (collectionData.name === collection.name) {
ctx?.setVisible(true);
} else {
setVisible(true);
onClick={(collection: Collection) => {
if (openMode === 'page') {
return navigateToSubPage();
}
if (treeRecordData) {
openPopup({
recordData: treeRecordData,
});
} else {
openPopup();
}
setCurrentCollection(collectionData.name);
setCurrentCollectionDataSource(collectionData.dataSource);
}}
/>
<ActionContextProvider value={{ ...ctx, fieldSchema, visible, setVisible }}>
<CollectionProvider_deprecated name={currentCollection} dataSource={currentCollectionDataSource}>
<RecursionField schema={fieldSchema} basePath={field.address} onlyRenderProperties />
</CollectionProvider_deprecated>
</ActionContextProvider>
</div>
);
};

View File

@ -9,15 +9,17 @@
import { useField, useFieldSchema } from '@formily/react';
import { Select } from 'antd';
import React from 'react';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SchemaInitializerItem, SchemaInitializerSelect } from '../application';
import { useDesignable } from '../schema-component';
import { usePopupSettings } from '../schema-component/antd/page/PopupSettingsProvider';
import { SchemaSettingsSelectItem } from '../schema-settings';
interface Options {
openMode?: boolean;
openSize?: boolean;
modeOptions?: { label: string; value: string }[];
}
export const SchemaInitializerOpenModeSchemaItems: React.FC<Options> = (options) => {
const { openMode = true, openSize = true } = options;
@ -25,17 +27,29 @@ export const SchemaInitializerOpenModeSchemaItems: React.FC<Options> = (options)
const field = useField();
const { t } = useTranslation();
const { dn } = useDesignable();
const { isPopupVisibleControlledByURL } = usePopupSettings();
const openModeValue = fieldSchema?.['x-component-props']?.['openMode'] || 'drawer';
const modeOptions = useMemo(() => {
if (isPopupVisibleControlledByURL) {
return [
{ label: t('Drawer'), value: 'drawer' },
{ label: t('Dialog'), value: 'modal' },
{ label: t('Page'), value: 'page' },
];
}
return [
{ label: t('Drawer'), value: 'drawer' },
{ label: t('Dialog'), value: 'modal' },
];
}, [t, isPopupVisibleControlledByURL]);
return (
<>
{openMode ? (
<SchemaInitializerSelect
title={t('Open mode')}
options={[
{ label: t('Drawer'), value: 'drawer' },
{ label: t('Dialog'), value: 'modal' },
]}
options={modeOptions}
value={openModeValue}
onChange={(value) => {
field.componentProps.openMode = value;
@ -92,23 +106,40 @@ export const SchemaInitializerOpenModeSchemaItems: React.FC<Options> = (options)
);
};
export const SchemaSettingOpenModeSchemaItems: React.FC<Options> = (options) => {
const { openMode = true, openSize = true } = options;
export const SchemaSettingOpenModeSchemaItems: React.FC<Options> = (props) => {
const { openMode = true, openSize = true, modeOptions } = props;
const fieldSchema = useFieldSchema();
const field = useField();
const { t } = useTranslation();
const { dn } = useDesignable();
const { isPopupVisibleControlledByURL } = usePopupSettings();
const openModeValue = fieldSchema?.['x-component-props']?.['openMode'] || 'drawer';
const _modeOptions = useMemo(() => {
if (modeOptions) {
return modeOptions;
}
if (isPopupVisibleControlledByURL) {
return [
{ label: t('Drawer'), value: 'drawer' },
{ label: t('Dialog'), value: 'modal' },
{ label: t('Page'), value: 'page' },
];
}
return [
{ label: t('Drawer'), value: 'drawer' },
{ label: t('Dialog'), value: 'modal' },
];
}, [modeOptions, t]);
return (
<>
{openMode ? (
<SchemaSettingsSelectItem
title={t('Open mode')}
options={[
{ label: t('Drawer'), value: 'drawer' },
{ label: t('Dialog'), value: 'modal' },
]}
options={_modeOptions}
value={openModeValue}
onChange={(value) => {
field.componentProps.openMode = value;

View File

@ -43,10 +43,9 @@ import React, {
} from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Router } from 'react-router-dom';
import { APIClientProvider } from '../api-client/APIClientProvider';
import { useAPIClient } from '../api-client/hooks/useAPIClient';
import { ApplicationContext, useApp } from '../application';
import { ApplicationContext, LocationSearchContext, useApp, useLocationSearch } from '../application';
import {
BlockContext,
BlockRequestContext_deprecated,
@ -746,6 +745,7 @@ export const SchemaSettingsModalItem: FC<SchemaSettingsModalItemProps> = (props)
const { association } = useDataBlockProps() || {};
const formCtx = useFormBlockContext();
const blockOptions = useBlockContext();
const locationSearch = useLocationSearch();
// 解决变量`当前对象`值在弹窗中丢失的问题
const { formValue: subFormValue, collection: subFormCollection } = useSubFormValue();
@ -784,7 +784,7 @@ export const SchemaSettingsModalItem: FC<SchemaSettingsModalItemProps> = (props)
name="form"
getActiveFieldsName={upLevelActiveFields?.getActiveFieldsName}
>
<Router location={location} navigator={null}>
<LocationSearchContext.Provider value={locationSearch}>
<BlockRequestContext_deprecated.Provider value={ctx}>
<DataSourceApplicationProvider dataSourceManager={dm} dataSource={dataSourceKey}>
<AssociationOrCollectionProvider
@ -819,7 +819,7 @@ export const SchemaSettingsModalItem: FC<SchemaSettingsModalItemProps> = (props)
</AssociationOrCollectionProvider>
</DataSourceApplicationProvider>
</BlockRequestContext_deprecated.Provider>
</Router>
</LocationSearchContext.Provider>
</FormActiveFieldsProvider>
</SubFormProvider>
</FormBlockContext.Provider>

View File

@ -40,5 +40,6 @@ export const useParentPopupVariable = (props: any = {}) => {
/** 当前记录对应的 collection name */
collectionName: collection?.name,
dataSource: collection?.dataSource,
defaultValue: undefined,
};
};

View File

@ -40,5 +40,6 @@ export const usePopupVariable = (props: any = {}) => {
/** 当前记录对应的 collection name */
collectionName: collection?.name,
dataSource: collection?.dataSource,
defaultValue: undefined,
};
};

View File

@ -12,7 +12,7 @@ import _ from 'lodash';
import qs from 'qs';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { useLocationSearch } from '../../../application/CustomRouterContextProvider';
import { useFlag } from '../../../flag-provider/hooks/useFlag';
import { Option } from '../type';
import { getLabelWithTooltip } from './useBaseVariable';
@ -64,9 +64,9 @@ export const useURLSearchParamsCtx = (search: string) => {
export const useURLSearchParamsVariable = (props: any = {}) => {
const variableName = '$nURLSearchParams';
const { t } = useTranslation();
const location = useLocation();
const searchString = useLocationSearch();
const { isVariableParsedInOtherContext } = useFlag();
const urlSearchParamsCtx = useURLSearchParamsCtx(location.search);
const urlSearchParamsCtx = useURLSearchParamsCtx(searchString);
const disabled = useMemo(() => _.isEmpty(urlSearchParamsCtx), [urlSearchParamsCtx]);
const urlSearchParamsSettings: Option = useMemo(() => {
return {
@ -100,8 +100,7 @@ export const useURLSearchParamsVariable = (props: any = {}) => {
/** 变量值 */
urlSearchParamsCtx,
/**
* undefined
* null filter URL search params filter
* null filter URL search params filter
* defaultValue undefined undefined undefined filter
*/
defaultValue: undefined,

View File

@ -10,8 +10,9 @@
import { PageHeader as AntdPageHeader } from '@ant-design/pro-layout';
import { Input, Spin } from 'antd';
import React, { useContext, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { useAPIClient, useRequest, useSchemaTemplateManager } from '..';
import { useNavigateNoUpdate } from '../application/CustomRouterContextProvider';
import { RemoteSchemaComponent, SchemaComponentContext } from '../schema-component';
const EditableTitle = (props) => {
@ -68,7 +69,7 @@ const EditableTitle = (props) => {
};
export const BlockTemplateDetails = () => {
const navigate = useNavigate();
const navigate = useNavigateNoUpdate();
const params = useParams<any>();
const key = params?.key;
const value = useContext(SchemaComponentContext);

View File

@ -13,8 +13,8 @@ import { error } from '@nocobase/utils/client';
import { App, Dropdown, Menu, MenuProps } from 'antd';
import React, { createContext, useCallback, useMemo as useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useACLRoleContext, useAPIClient, useCurrentUserContext, useToken } from '..';
import { useNavigateNoUpdate } from '../application/CustomRouterContextProvider';
import { useChangePassword } from './ChangePassword';
import { useCurrentUserSettingsMenu } from './CurrentUserSettingsMenuProvider';
import { useEditProfile } from './EditProfile';
@ -48,7 +48,7 @@ export const SettingsMenu: React.FC<{
const { redirectUrl = '' } = props;
const { allowAll, snippets } = useACLRoleContext();
const appAllowed = allowAll || snippets?.includes('app');
const navigate = useNavigate();
const navigate = useNavigateNoUpdate();
const api = useAPIClient();
const { t } = useTranslation();
const silenceApi = useAPIClient();

View File

@ -8,9 +8,10 @@
*/
import React, { createContext, useContext, useMemo } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { Navigate } from 'react-router-dom';
import { useACLRoleContext } from '../acl';
import { ReturnTypeOfUseRequest, useRequest } from '../api-client';
import { useLocationNoUpdate } from '../application';
import { useAppSpin } from '../application/hooks/useAppSpin';
import { useCompile } from '../schema-component';
@ -53,7 +54,7 @@ export const CurrentUserProvider = (props) => {
export const NavigateIfNotSignIn = ({ children }) => {
const result = useCurrentUserContext();
const { pathname, search } = useLocation();
const { pathname, search } = useLocationNoUpdate();
const redirect = `?redirect=${pathname}${search}`;
if (!result?.data?.data?.id) {
return <Navigate replace to={`/signin${redirect}`} />;

View File

@ -37,11 +37,13 @@ const useLocalVariables = (props?: Props) => {
popupRecordCtx,
collectionName: collectionNameOfPopupRecord,
dataSource: popupDataSource,
defaultValue: defaultValueOfPopupRecord,
} = usePopupVariable();
const {
parentPopupRecordCtx,
collectionName: collectionNameOfParentPopupRecord,
dataSource: parentPopupDataSource,
defaultValue: defaultValueOfParentPopupRecord,
} = useParentPopupVariable();
const { datetimeCtx } = useDatetimeVariable();
const { currentFormCtx } = useCurrentFormVariable({ form: props?.currentForm });
@ -98,12 +100,14 @@ const useLocalVariables = (props?: Props) => {
ctx: popupRecordCtx,
collectionName: collectionNameOfPopupRecord,
dataSource: popupDataSource,
defaultValue: defaultValueOfPopupRecord,
},
{
name: '$nParentPopupRecord',
ctx: parentPopupRecordCtx,
collectionName: collectionNameOfParentPopupRecord,
dataSource: parentPopupDataSource,
defaultValue: defaultValueOfParentPopupRecord,
},
{
name: '$nForm',
@ -138,12 +142,15 @@ const useLocalVariables = (props?: Props) => {
collectionNameOfParentRecord,
currentParentRecordDataSource,
popupRecordCtx,
parentPopupRecordCtx,
collectionNameOfPopupRecord,
popupDataSource,
datetimeCtx,
shouldDisplayCurrentObject,
currentObjectCtx,
currentCollectionName,
defaultValueOfPopupRecord,
defaultValueOfParentPopupRecord,
]); // 尽量保持返回的值不变,这样可以减少接口的请求次数,因为关系字段会缓存到变量的 ctx 中
};

View File

@ -347,10 +347,14 @@ export class APIClient {
return this.axios.request<T, R, D>(config);
}
resource(name: string, of?: any, headers?: AxiosRequestHeaders): IResource {
resource(name: string, of?: any, headers?: AxiosRequestHeaders, cancel?: boolean): IResource {
const target = {};
const handler = {
get: (_: any, actionName: string) => {
if (cancel) {
return;
}
let url = name.split('.').join(`/${encodeURIComponent(of) || '_'}/`);
url += `:${actionName}`;
const config: AxiosRequestConfig = { url };

View File

@ -16,6 +16,7 @@ import {
useCollectionManager_deprecated,
useCollection_deprecated,
useCompile,
useNavigateNoUpdate,
useRemoveGridFormItem,
useTableBlockContext,
} from '@nocobase/client';
@ -23,7 +24,6 @@ import { isURL } from '@nocobase/utils/client';
import { App, message } from 'antd';
import { useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
export const useCustomBulkEditFormItemInitializerFields = (options?: any) => {
const { name, fields } = useCollection_deprecated();
@ -83,7 +83,7 @@ export const useCustomizeBulkEditActionProps = () => {
const { field, resource, __parent } = useBlockRequestContext();
const expressionScope = useContext(SchemaExpressionScopeContext);
const actionContext = useActionContext();
const navigate = useNavigate();
const navigate = useNavigateNoUpdate();
const compile = useCompile();
const actionField = useField();
const tableBlockContext = useTableBlockContext();

View File

@ -16,13 +16,13 @@ import {
useCollection_deprecated,
useCompile,
useLocalVariables,
useNavigateNoUpdate,
useTableBlockContext,
useVariables,
} from '@nocobase/client';
import { isURL } from '@nocobase/utils/client';
import { App, message } from 'antd';
import { useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { useBulkUpdateTranslation } from './locale';
export const useCustomizeBulkUpdateActionProps = () => {
@ -32,7 +32,7 @@ export const useCustomizeBulkUpdateActionProps = () => {
const tableBlockContext = useTableBlockContext();
const { rowKey } = tableBlockContext;
const navigate = useNavigate();
const navigate = useNavigateNoUpdate();
const compile = useCompile();
const { t } = useBulkUpdateTranslation();
const actionField: any = useField();

View File

@ -8,15 +8,21 @@
*/
import { useField, useFieldSchema, useForm } from '@formily/react';
import { useAPIClient, useActionContext, useCompile, useDataSourceKey, useRecord } from '@nocobase/client';
import {
useAPIClient,
useActionContext,
useCompile,
useDataSourceKey,
useNavigateNoUpdate,
useRecord,
} from '@nocobase/client';
import { isURL } from '@nocobase/utils/client';
import { App } from 'antd';
import { saveAs } from 'file-saver';
import { useNavigate } from 'react-router-dom';
export const useCustomizeRequestActionProps = () => {
const apiClient = useAPIClient();
const navigate = useNavigate();
const navigate = useNavigateNoUpdate();
const actionSchema = useFieldSchema();
const compile = useCompile();
const form = useForm();

View File

@ -12,16 +12,16 @@ import { ISchema, connect, mapProps, useField, useFieldSchema, useForm } from '@
import {
ActionDesigner,
SchemaSettingOpenModeSchemaItems,
useCollection_deprecated,
useRecord,
SchemaSettingsModalItem,
SchemaSettings,
SchemaSettingsItemType,
SchemaSettingsLinkageRules,
SchemaSettingsModalItem,
useCollectionState,
useCollection_deprecated,
useDesignable,
useRecord,
useSchemaToolbar,
useSyncFromForm,
SchemaSettings,
} from '@nocobase/client';
import { Tree as AntdTree } from 'antd';
import { cloneDeep } from 'lodash';
@ -357,19 +357,19 @@ const schemaSettingsItems: SchemaSettingsItemType[] = [
name: 'openMode',
Component: SchemaSettingOpenModeSchemaItems,
useComponentProps() {
const fieldSchema = useFieldSchema();
const isPopupAction = [
'create',
'update',
'view',
'customize:popup',
'duplicate',
'customize:create',
].includes(fieldSchema['x-action'] || '');
const { t } = useTranslation();
const modeOptions = useMemo(() => {
return [
{ label: t('Drawer'), value: 'drawer' },
{ label: t('Dialog'), value: 'modal' },
];
}, [t]);
return {
openMode: isPopupAction,
openSize: isPopupAction,
openMode: true,
openSize: true,
modeOptions,
};
},
},

View File

@ -7,16 +7,15 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useApp } from '@nocobase/client';
import { useApp, useLocationSearch } from '@nocobase/client';
import React, { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export const AuthProvider: React.FC = (props) => {
const location = useLocation();
const searchString = useLocationSearch();
const app = useApp();
useEffect(() => {
const params = new URLSearchParams(location.search);
const params = new URLSearchParams(searchString);
const authenticator = params.get('authenticator');
const token = params.get('token');
if (token) {

View File

@ -6,10 +6,10 @@
* 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 { LeftOutlined, FileImageOutlined } from '@ant-design/icons';
import { Html5Qrcode } from 'html5-qrcode';
import React, { useState, useEffect, useRef } from 'react';
import { FileImageOutlined, LeftOutlined } from '@ant-design/icons';
import { useActionContext } from '@nocobase/client';
import { Html5Qrcode } from 'html5-qrcode';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScanBox } from './ScanBox';
import { useScanner } from './useScanner';

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