jack zhang 61e9dd5cc1
feat: plugin mobile v2 (#4777)
* feat: init

* fix: mobile layout

* feat: more code

* feat: improve navigate bar

* fix: mobile title

* feat: improve code

* fix: add settings and initailzer

* fix: settings

* fix: tabbar items settings

* feat: tabbar initializer

* fix: api

* fix: styles

* feat: navbar

* feat: navigate bar tabs initializer

* feat: navigate bar tab settings

* feat: navigation bar actions

* fix: bug

* fix: bug

* fix: bug

* fix: tabbar active

* fix: bug

* fix: mobile login and layout

* fix: update version

* fix: build error

* feat: plugin settings support link

* fix: add mobile meta

* fix: desktop mode

* fix: remove old code and change collection name and mobile path

* fix: tabbar and tabs initialer layout

* fix: initializer style

* fix: adjust schema position

* fix: mobile style

* fix: delete relation resource and home page bug

* fix: support multi app

* fix: not found page

* fix: js bridge

* fix: bug

* fix: navigation bar schema flat

* fix: navigation bar action style

* fix: change version

* fix: mobile meta and real mobile test

* refactor: folder and name

* fix: navigation bar sticky and zIndex

* fix: full mobile schema

* fix: mobile readme and package.json

* fix: e2e bug

* fix: bug

* fix: tabbar style on productino

* fix: bug

* fix: rename MobileTabBar.Page

* fix: support tabbar sort

* fix: support  page tabs sort

* fix: i18n

* fix: settings utils import bug

* docs: api doc

* fix: qrcode refresh

* test: unit tests

* fix: bug

* fix: unit test

* fix: build bug

* fix: e2e test

* fix: overflow scroll

* fix: bug

* fix: scroll and overflow

* fix: bug

* fix: e2e expect await

* fix: e2e bug

* fix: bug

* fix: change name

* fix: add more e2e

* fix: page header

* fix: tab support icon

* fix: bug

* fix: bug

* fix: docs

* fix(T-4811): scroll bar too long

* fix(T-4810): desktop mode

* fix: e2e

* fix(T-4812): title empty

* fix: unit test

* feat: hide Open mode option in mobile mode

* feat: change default value of Open mode on mobile

* feat: add OpenModeProvider

* feat: support page mode

* fix: fix build

* test: update unit tests

* chore: remove pro-plugins

* fix: bug

* fix(T-4812): title is required

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* refactor: remove z-index

* refactor: make better for subpages

* fix: drag bug

* fix: bug

* fix: theme bug

* fix(T-4859): create tab bar title empty

* fix(T-4857): action too long

* fix: e2e bug

* fix: remove comment

* fix: bug

* fix: theme bug

* fix: should provider modal component

* fix: bug

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
Co-authored-by: Zeke Zhang <958414905@qq.com>
2024-07-22 14:06:36 +08:00

1405 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 { faker } from '@faker-js/faker';
import { uid } from '@formily/shared';
import { Browser, Page, test as base, expect, request } from '@playwright/test';
import _ from 'lodash';
import { defineConfig } from './defineConfig';
export * from '@playwright/test';
export { defineConfig };
export interface CollectionSetting {
name: string;
title?: string;
titleField?: string;
/**
* @default 'general'
*/
template?: string;
/**
* @default true
*/
logging?: boolean;
/**
* Generate ID field automatically
* @default true
*/
autoGenId?: boolean;
/**
* Store the creation user of each record
* @default true
*/
createdBy?: boolean;
/**
* Store the last update user of each record
* @default true
*/
updatedBy?: boolean;
/**
* Store the creation time of each record
* @default true
*/
createdAt?: boolean;
/**
* Store the last update time of each record
* @default true
*/
updatedAt?: boolean;
/**
* Records can be sorted
* @default true
*/
sortable?: boolean | string;
/**
* @default false
*/
inherit?: boolean;
inherits?: string[];
category?: any[];
hidden?: boolean;
description?: string;
view?: boolean;
key?: string;
fields?: Array<{
interface: string;
type?: string;
name?: string;
unique?: boolean;
uiSchema?: {
type?: string;
title?: string;
required?: boolean;
'x-component'?: string;
'x-read-pretty'?: boolean;
'x-validator'?: any;
'x-component-props'?: Record<string, any>;
[key: string]: any;
};
field?: string;
target?: string;
targetKey?: string;
foreignKey?: string;
allowNull?: boolean;
autoIncrement?: boolean;
primaryKey?: boolean;
key?: string;
description?: string;
collectionName?: string;
parentKey?: any;
reverseKey?: any;
[key: string]: any;
}>;
}
interface AclActionsSetting {
name: string; //操作标识如cretae
fields?: any[]; //有该操作权限的字段
scope?: any; // 数据范围
}
interface AclResourcesSetting {
name: string; //数据表标识
usingActionsConfig: boolean; //是否开启单独配置
actions?: AclActionsSetting[];
}
interface AclRoleSetting {
name?: string;
title?: string;
/**
* @default true
*/
allowNewMenu?: boolean;
//配置权限,如 ["app", "pm", "pm.*", "ui.*"]
snippets?: string[];
//操作权限策略
strategy?: any;
//数据表单独操作权限配置
resources?: AclResourcesSetting[];
/**
* @default false
*/
default?: boolean;
key?: string;
//菜单权限配置
menuUiSchemas?: string[];
dataSourceKey?: string;
}
interface DatabaseSetting {
database: string;
host: string;
port: string;
schema?: string;
username?: string;
password?: string;
}
interface DataSourceSetting {
key: string;
displayName: string;
type: string;
options: DatabaseSetting;
enabled?: boolean;
}
export interface PageConfig {
/**
* 页面类型
* @default 'page'
*/
type?: 'group' | 'page' | 'link';
/**
* type 为 link 时,表示跳转的链接
*/
url?: string;
/**
* 用户可见的页面名称
* @default uid()
*/
name?: string;
/**
* 页面的基础路径
* @default '/admin/'
*/
basePath?: string;
/**
* 页面数据表的配置
* @default undefined
*/
collections?: CollectionSetting[];
/**
* 页面整体的 Schema
* @default undefined
*/
pageSchema?: any;
/** 如果为 true 则表示不会更改 PageSchema 的 uid */
keepUid?: boolean;
}
export interface MobilePageConfig extends Omit<PageConfig, 'type'> {
type?: 'page' | 'link';
/**
* 页面的基础路径
* @default '/m/'
*/
basePath?: string;
}
interface CreatePageOptions {
type?: PageConfig['type'];
url?: PageConfig['url'];
name?: string;
pageSchema?: any;
/** 如果为 true 则表示不会更改 PageSchema 的 uid */
keepUid?: boolean;
}
interface CreateMobilePageOptions extends Omit<CreatePageOptions, 'type'> {
type?: Omit<PageConfig['type'], 'group'>;
}
interface ExtendUtils {
page?: Page;
/**
* 根据配置,生成一个 NocoBase 的页面
* @param pageConfig 页面配置
* @returns
*/
mockPage: (pageConfig?: PageConfig) => NocoPage;
/**
* 根据配置,生成一个移动端 NocoBase 的页面
* @param pageConfig 页面配置
* @returns
*/
mockMobilePage: (pageConfig?: MobilePageConfig) => NocoMobilePage;
/**
* 根据配置,生成一个需要手动销毁的 NocoPage 页面
* @param pageConfig
* @returns
*/
mockManualDestroyPage: (pageConfig?: PageConfig) => NocoPage;
/**
* 根据配置,生成多个 collections
* @param collectionSettings
* @returns 返回一个 destroy 方法,用于销毁创建的 collections
*/
mockCollections: (collectionSettings: CollectionSetting[]) => Promise<any>;
/**
* 根据配置,生成一个 collection
* @param collectionSetting
* @returns 返回一个 destroy 方法,用于销毁创建的 collection
*/
mockCollection: (collectionSetting: CollectionSetting) => Promise<any>;
/**
* 自动生成一条对应 collection 的数据
* @returns 返回一条生成的数据
*/
mockRecord: {
/**
* @param collectionName 数据表名称
* @param data 自定义的数据,缺失时会生成随机数据
* @param maxDepth - 生成的数据的最大深度,默认为 1当想生成多层级数据时可以设置一个较高的值
*/
<T = any>(collectionName: string, data?: any, maxDepth?: number): Promise<T>;
/**
* @param collectionName 数据表名称
* @param maxDepth - 生成的数据的最大深度,默认为 1当想生成多层级数据时可以设置一个较高的值
*/
<T = any>(collectionName: string, maxDepth?: number): Promise<T>;
};
/**
* 自动生成多条对应 collection 的数据
*/
mockRecords: {
/**
* @param collectionName - 数据表名称
* @param count - 生成的数据条数
* @param maxDepth - 生成的数据的最大深度,默认为 1当想生成多层级数据时可以设置一个较高的值
*/
<T = any>(collectionName: string, count?: number, maxDepth?: number): Promise<T[]>;
/**
* @param collectionName - 数据表名称
* @param data - 指定生成的数据
* @param maxDepth - 生成的数据的最大深度,默认为 1当想生成多层级数据时可以设置一个较高的值
*/
<T = any>(collectionName: string, data?: any[], maxDepth?: number): Promise<T[]>;
};
/**
* 该方法已弃用,请使用 mockCollections
* @deprecated
* @param collectionSettings
* @returns
*/
createCollections: (collectionSettings: CollectionSetting | CollectionSetting[]) => Promise<void>;
/**
* 根据页面 title 删除对应的页面
* @param pageName 显示在页面菜单中的名称
* @returns
*/
deletePage: (pageName: string) => Promise<void>;
/**
* 生成一个新的角色并和admin关联上
*/
mockRole: <T = any>(roleSetting: AclRoleSetting) => Promise<T>;
/**
* 更新角色权限配置
*/
updateRole: <T = any>(roleSetting: AclRoleSetting) => Promise<T>;
/**
* 创建一个外部数据源pg
*/
mockExternalDataSource: <T = any>(DataSourceSetting: DataSourceSetting) => Promise<T>;
/**
* 删除外部数据源
* @param key 外部数据源key
*/
destoryExternalDataSource: <T = any>(key: string) => Promise<T>;
/**
* 清空区块模板,该方法应该放到测试用例开始的位置(放在末尾的话,如果测试报错会导致模板不会被清空)
*/
clearBlockTemplates: ({
immediate,
}?: {
/**
* 是否立即清空,默认为 false。如果为 true则会立即清空否则会等到测试用例结束后再清空
*/
immediate: boolean;
}) => Promise<void>;
}
const PORT = process.env.APP_PORT || 20000;
const APP_BASE_URL = process.env.APP_BASE_URL || `http://localhost:${PORT}`;
export class NocoPage {
protected url: string;
protected uid: string | undefined;
protected collectionsName: string[] | undefined;
protected _waitForInit: Promise<void>;
constructor(
protected options?: PageConfig,
protected page?: Page,
) {
this._waitForInit = this.init();
}
async init() {
const waitList = [];
if (this.options?.collections?.length) {
const collections: any = omitSomeFields(this.options.collections);
this.collectionsName = collections.map((item) => item.name);
waitList.push(createCollections(collections));
}
waitList.push(
createPage({
type: this.options?.type,
name: this.options?.name,
pageSchema: this.options?.pageSchema,
url: this.options?.url,
keepUid: this.options?.keepUid,
}),
);
const result = await Promise.all(waitList);
this.uid = result[result.length - 1];
this.url = `${this.options?.basePath || '/admin/'}${this.uid}`;
}
async goto() {
await this._waitForInit;
await this.page?.goto(this.url);
}
async getUrl() {
await this._waitForInit;
return this.url;
}
async getUid() {
await this._waitForInit;
return this.uid;
}
/**
* If you are using mockRecords, then you need to use this method.
* Wait until the mockRecords create the records successfully before navigating to the page.
* @param this
* @returns
*/
async waitForInit(this: NocoPage) {
await this._waitForInit;
return this;
}
async destroy() {
const waitList: any[] = [];
if (this.uid) {
waitList.push(deletePage(this.uid));
this.uid = undefined;
}
if (this.collectionsName?.length) {
waitList.push(deleteCollections(this.collectionsName));
this.collectionsName = undefined;
}
await Promise.all(waitList);
}
}
export class NocoMobilePage extends NocoPage {
protected routeId: number;
protected title: string;
constructor(
protected options?: MobilePageConfig,
protected page?: Page,
) {
super(options, page);
}
getTitle() {
return this.title;
}
async init() {
const waitList = [];
if (this.options?.collections?.length) {
const collections: any = omitSomeFields(this.options.collections);
this.collectionsName = collections.map((item) => item.name);
waitList.push(createCollections(collections));
}
waitList.push(createMobilePage(this.options));
const result = await Promise.all(waitList);
const { url, pageSchemaUid, routeId, title } = result[result.length - 1];
this.title = title;
this.routeId = routeId;
this.uid = pageSchemaUid;
if (this.options?.type == 'link') {
// 内部 URL 和外部 URL
if (url?.startsWith('/')) {
this.url = `${this.options?.basePath || '/m'}${url}`;
} else {
this.url = url;
}
} else {
this.url = `${this.options?.basePath || '/m'}${url}`;
}
}
async mobileDestroy() {
// 移除 mobile routes
await deleteMobileRoutes(this.routeId);
// 移除 schema
await this.destroy();
}
}
let _page: Page;
const getPage = async (browser: Browser) => {
if (!_page) {
_page = await browser.newPage();
}
return _page;
};
const _test = base.extend<ExtendUtils>({
page: async ({ browser }, use) => {
await use(await getPage(browser));
},
mockPage: async ({ browser }, use) => {
// 保证每个测试运行时 faker 的随机值都是一样的
// faker.seed(1);
const page = await getPage(browser);
const nocoPages: NocoPage[] = [];
const mockPage = (config?: PageConfig) => {
const nocoPage = new NocoPage(config, page);
nocoPages.push(nocoPage);
return nocoPage;
};
await use(mockPage);
const waitList = [];
// 测试运行完自动销毁页面
for (const nocoPage of nocoPages) {
// 这里之所以不加入 waitList 是因为会导致 acl 的测试报错
await nocoPage.destroy();
}
waitList.push(setDefaultRole('root'));
// 删除掉 id 不是 1 的 users 和 name 不是 root admin member 的 roles
waitList.push(removeRedundantUserAndRoles());
await Promise.all(waitList);
},
mockMobilePage: async ({ browser }, use) => {
// 保证每个测试运行时 faker 的随机值都是一样的
// faker.seed(1);
const page = await getPage(browser);
const nocoPages: NocoMobilePage[] = [];
const mockPage = (config?: MobilePageConfig) => {
const nocoPage = new NocoMobilePage(config, page);
nocoPages.push(nocoPage);
return nocoPage;
};
await use(mockPage);
const waitList = [];
// 测试运行完自动销毁页面
for (const nocoPage of nocoPages) {
// 这里之所以不加入 waitList 是因为会导致 acl 的测试报错
await nocoPage.mobileDestroy();
}
waitList.push(setDefaultRole('root'));
// 删除掉 id 不是 1 的 users 和 name 不是 root admin member 的 roles
waitList.push(removeRedundantUserAndRoles());
await Promise.all(waitList);
},
mockManualDestroyPage: async ({ browser }, use) => {
const mockManualDestroyPage = (config?: PageConfig) => {
const nocoPage = new NocoPage(config);
return nocoPage;
};
await use(mockManualDestroyPage);
},
createCollections: async ({ browser }, use) => {
let collectionsName: string[] = [];
const _createCollections = async (collectionSettings: CollectionSetting | CollectionSetting[]) => {
collectionSettings = omitSomeFields(
Array.isArray(collectionSettings) ? collectionSettings : [collectionSettings],
);
collectionsName = [...collectionsName, ...collectionSettings.map((item) => item.name)];
await createCollections(collectionSettings);
};
await use(_createCollections);
if (collectionsName.length) {
await deleteCollections(_.uniq(collectionsName));
}
},
mockCollections: async ({ browser }, use) => {
let collectionsName: string[] = [];
const destroy = async () => {
if (collectionsName.length) {
await deleteCollections(_.uniq(collectionsName));
}
};
const mockCollections = async (collectionSettings: CollectionSetting[]) => {
collectionSettings = omitSomeFields(collectionSettings);
collectionsName = [...collectionsName, ...collectionSettings.map((item) => item.name)];
return createCollections(collectionSettings);
};
await use(mockCollections);
await destroy();
},
mockCollection: async ({ browser }, use) => {
let collectionsName: string[] = [];
const destroy = async () => {
if (collectionsName.length) {
await deleteCollections(_.uniq(collectionsName));
}
};
const mockCollection = async (collectionSetting: CollectionSetting, options?: { manualDestroy: boolean }) => {
const collectionSettings = omitSomeFields([collectionSetting]);
collectionsName = [...collectionsName, ...collectionSettings.map((item) => item.name)];
return createCollections(collectionSettings);
};
await use(mockCollection);
await destroy();
},
mockRecords: async ({ browser }, use) => {
const mockRecords = async (collectionName: string, count: any = 3, data?: any) => {
let maxDepth: number;
if (_.isNumber(data)) {
maxDepth = data;
data = undefined;
}
if (_.isArray(count)) {
data = count;
count = data.length;
}
return createRandomData(collectionName, count, data, maxDepth);
};
await use(mockRecords);
},
mockRecord: async ({ browser }, use) => {
const mockRecord = async (collectionName: string, data?: any, maxDepth?: any) => {
if (_.isNumber(data)) {
maxDepth = data;
data = undefined;
}
const result = await createRandomData(collectionName, 1, data, maxDepth);
return result[0];
};
await use(mockRecord);
},
deletePage: async ({ browser }, use) => {
const page = await getPage(browser);
const deletePage = async (pageName: string) => {
await page.getByText(pageName, { exact: true }).hover();
await page.getByRole('button', { name: 'designer-schema-settings-' }).hover();
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click();
};
await use(deletePage);
},
mockRole: async ({ browser }, use) => {
const mockRole = async (roleSetting: AclRoleSetting) => {
return createRole(roleSetting);
};
await use(mockRole);
},
updateRole: async ({ browser }, use) => {
await use(updateRole);
},
mockExternalDataSource: async ({ browser }, use) => {
const mockExternalDataSource = async (DataSourceSetting: DataSourceSetting) => {
return createExternalDataSource(DataSourceSetting);
};
await use(mockExternalDataSource);
},
destoryExternalDataSource: async ({ browser }, use) => {
const destoryDataSource = async (key: string) => {
return destoryExternalDataSource(key);
};
await use(destoryDataSource);
},
clearBlockTemplates: async ({ browser }, use) => {
// 用来标记当前测试用例是否已经结束,只有结束了才会清空区块模板
let ended = false;
let isImmediate = false;
const clearBlockTemplates = async ({ immediate } = { immediate: false }) => {
isImmediate = immediate;
if (!ended && !immediate) {
return;
}
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const filter = {
key: { $exists: true },
};
const result = await api.post(`/api/uiSchemaTemplates:destroy?filter=${JSON.stringify(filter)}`, {
headers,
});
if (!result.ok()) {
throw new Error(await result.text());
}
};
await use(clearBlockTemplates);
ended = true;
if (!isImmediate) {
await clearBlockTemplates();
}
},
});
export const test = Object.assign(_test, {
/** 只运行在 postgres 数据库中 */
pgOnly: process.env.DB_DIALECT == 'postgres' ? _test : _test.skip,
});
const getStorageItem = (key: string, storageState: any) => {
return storageState.origins
.find((item) => item.origin === APP_BASE_URL)
?.localStorage.find((item) => item.name === key)?.value;
};
/**
* 更新直接从浏览器中复制过来的 Schema 中的 uid
*/
const updateUidOfPageSchema = (uiSchema: any) => {
if (!uiSchema) {
return;
}
if (uiSchema['x-uid']) {
uiSchema['x-uid'] = uid();
}
if (uiSchema.properties) {
Object.keys(uiSchema.properties).forEach((key) => {
updateUidOfPageSchema(uiSchema.properties[key]);
});
}
return uiSchema;
};
/**
* 在 NocoBase 中创建一个页面
*/
const createPage = async (options?: CreatePageOptions) => {
const { type = 'page', url, name, pageSchema, keepUid } = options || {};
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const typeToSchema = {
group: {
'x-component': 'Menu.SubMenu',
'x-component-props': {},
},
page: {
'x-component': 'Menu.Item',
'x-component-props': {},
},
link: {
'x-component': 'Menu.URL',
'x-component-props': {
href: url,
},
},
};
const state = await api.storageState();
const headers = getHeaders(state);
const pageUid = uid();
const gridName = uid();
const result = await api.post(`/api/uiSchemas:insertAdjacent/nocobase-admin-menu?position=beforeEnd`, {
headers,
data: {
schema: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: name || pageUid,
...typeToSchema[type],
'x-decorator': 'ACLMenuItemProvider',
'x-server-hooks': [
{ type: 'onSelfCreate', method: 'bindMenuToRole' },
{ type: 'onSelfSave', method: 'extractTextToLocale' },
],
properties: {
page: (keepUid ? pageSchema : updateUidOfPageSchema(pageSchema)) || {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Page',
'x-async': true,
properties: {
[gridName]: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'page:addBlock',
'x-uid': uid(),
name: gridName,
},
},
'x-uid': uid(),
name: 'page',
},
},
name: uid(),
'x-uid': pageUid,
},
wrap: null,
},
});
if (!result.ok()) {
throw new Error(await result.text());
}
return pageUid;
};
/**
* 在 NocoBase 中创建一个移动端页面
*/
const createMobilePage = async (options?: CreateMobilePageOptions) => {
const { type = 'page', url, name, pageSchema, keepUid } = options || {};
function randomStr() {
return Math.random().toString(36).substring(2);
}
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const pageSchemaUid = name || uid();
const schemaUrl = `/page/${pageSchemaUid}`;
const firstTabUid = uid();
const title = name || randomStr();
// 创建路由
const routerResponse: any = await api.post(`/api/mobileRoutes:create`, {
headers,
data: {
type: type,
schemaUid: pageSchemaUid,
title: title,
icon: 'appstoreoutlined',
options: {
url,
},
},
});
const responseData = await routerResponse.json();
const routeId = responseData.data.id;
if (!routerResponse.ok()) {
throw new Error(await routerResponse.text());
}
if (type === 'link') return { url, routeId, title };
// 创建空页面
const createSchemaResult = await api.post(`/api/uiSchemas:insertAdjacent?resourceIndex=mobile&position=beforeEnd`, {
headers,
data: {
schema: {
type: 'void',
name: pageSchemaUid,
'x-uid': pageSchemaUid,
'x-component': 'MobilePageProvider',
'x-settings': 'mobile:page',
'x-decorator': 'BlockItem',
'x-toolbar-props': {
draggable: false,
spaceWrapperStyle: {
right: -15,
top: -15,
},
spaceClassName: 'css-m1q7xw',
toolbarStyle: {
overflowX: 'hidden',
},
},
properties: {
header: {
type: 'void',
'x-component': 'MobilePageHeader',
properties: {
pageNavigationBar: {
type: 'void',
'x-component': 'MobilePageNavigationBar',
properties: {
actionBar: {
type: 'void',
'x-component': 'MobileNavigationActionBar',
'x-initializer': 'mobile:navigation-bar:actions',
'x-component-props': {
spaceProps: {
style: {
flexWrap: 'nowrap',
},
},
},
name: 'actionBar',
},
},
name: 'pageNavigationBar',
},
pageTabs: {
type: 'void',
'x-component': 'MobilePageTabs',
name: 'pageTabs',
},
},
name: 'header',
},
content: {
type: 'void',
'x-component': 'MobilePageContent',
properties: {
[firstTabUid]: {
...((keepUid ? pageSchema : updateUidOfPageSchema(pageSchema)) || {
type: 'void',
'x-uid': firstTabUid,
'x-async': true,
'x-component': 'Grid',
'x-initializer': 'mobile:addBlock',
}),
name: firstTabUid,
'x-uid': firstTabUid,
},
},
},
},
},
},
});
if (!createSchemaResult.ok()) {
throw new Error(await createSchemaResult.text());
}
// 创建第一个 tab
const createTabResponse = await api.post(`/api/mobileRoutes:create`, {
headers,
data: {
parentId: routeId,
type: 'tabs',
title: 'Unnamed',
schemaUid: firstTabUid,
},
});
if (!createTabResponse.ok()) {
throw new Error(await createTabResponse.text());
}
return { url: schemaUrl, pageSchemaUid, routeId, title };
};
export const removeAllMobileRoutes = async () => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const result = await api.post(
`/api/mobileRoutes:destroy?filter=%7B%22%24and%22%3A%5B%7B%22id%22%3A%7B%22%24ne%22%3A0%7D%7D%5D%7D`,
{
headers,
},
);
if (!result.ok()) {
throw new Error(await result.text());
}
};
/**
* 根据页面 id 删除一个 Mobile Routes 的页面
*/
const deleteMobileRoutes = async (mobileRouteId: number) => {
if (!mobileRouteId) return;
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const result = await api.post(`/api/mobileRoutes:destroy?filterByTk=${mobileRouteId}`, {
headers,
});
if (!result.ok()) {
throw new Error(await result.text());
}
const result2 = await api.post(
`/api/mobileRoutes:destroy?filter=${encodeURIComponent(JSON.stringify({ parentId: mobileRouteId }))}`,
{
headers,
},
);
if (!result2.ok()) {
throw new Error(await result2.text());
}
};
/**
* 根据页面 uid 删除一个 NocoBase 的页面
*/
const deletePage = async (pageUid: string) => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const result = await api.post(`/api/uiSchemas:remove/${pageUid}`, {
headers,
});
if (!result.ok()) {
throw new Error(await result.text());
}
};
const deleteCollections = async (collectionNames: string[]) => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const params = collectionNames.map((name) => `filterByTk[]=${name}`).join('&');
const result = await api.post(`/api/collections:destroy?${params}`, {
headers,
params: {
cascade: true,
},
});
if (!result.ok()) {
throw new Error(await result.text());
}
};
/**
* 将数据表中 mock 出来的 records 删除掉
* @param collectionName
* @param records
*/
export const deleteRecords = async (collectionName: string, filter: any) => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const result = await api.post(`/api/${collectionName}:destroy?filter=${JSON.stringify(filter)}`, {
headers,
});
if (!result.ok()) {
throw new Error(await result.text());
}
};
/**
* 删除一些不需要的字段,如 key
* @param collectionSettings
* @returns
*/
export const omitSomeFields = (collectionSettings: CollectionSetting[]): any[] => {
return collectionSettings.map((collection) => {
return {
..._.omit(collection, ['key']),
fields: collection.fields?.map((field) => _.omit(field, ['key', 'collectionName'])),
};
});
};
/**
* 根据配置创建一个或多个 collection
* @param page 运行测试的 page 实例
* @param collectionSettings
* @returns
*/
const createCollections = async (collectionSettings: CollectionSetting | CollectionSetting[]) => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
collectionSettings = Array.isArray(collectionSettings) ? collectionSettings : [collectionSettings];
const result = await api.post(`/api/collections:mock`, {
headers,
data: collectionSettings,
});
if (!result.ok()) {
throw new Error(await result.text());
}
return (await result.json()).data;
};
/**
* 根据配置创建一个角色并将角色关联给superAdmin且切换到新角色
* @param page 运行测试的 page 实例
* @param AclRoleSetting
* @returns
*/
const createRole = async (roleSetting: AclRoleSetting) => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const name = roleSetting.name || uid();
const result = await api.post(`/api/users/1/roles:create`, {
headers,
data: { ...roleSetting, name, title: name },
});
if (!result.ok()) {
throw new Error(await result.text());
}
const roleData = (await result.json()).data;
await setDefaultRole(name);
return roleData;
};
/**
* 根据配置更新角色权限
* @param page 运行测试的 page 实例
* @param AclRoleSetting
* @returns
*/
const updateRole = async (roleSetting: AclRoleSetting) => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const name = roleSetting.name;
const dataSourceKey = roleSetting.dataSourceKey;
const url = !dataSourceKey
? `/api/roles:update?filterByTk=${name}`
: `/api/dataSources/${dataSourceKey}/roles:update?filterByTk=${name}`;
const result = await api.post(url, {
headers,
data: { ...roleSetting },
});
if (!result.ok()) {
throw new Error(await result.text());
}
const roleData = (await result.json()).data;
return roleData;
};
/**
* 设置默认角色
* @param name
*/
const setDefaultRole = async (name) => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const result = await api.post(`/api/users:setDefaultRole`, {
headers,
data: { roleName: name },
});
if (!result.ok()) {
throw new Error(await result.text());
}
};
/**
* 创建外部数据源
* @paramn
*/
const createExternalDataSource = async (dataSourceSetting: DataSourceSetting) => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const result = await api.post(`/api/dataSources:create`, {
headers,
data: { ...dataSourceSetting },
});
if (!result.ok()) {
throw new Error(await result.text());
}
return (await result.json()).data;
};
/**
* 删除外部数据源
* @paramn
*/
const destoryExternalDataSource = async (key) => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const result = await api.post(`/api/dataSources:destroy?filterByTk=${key}`, {
headers,
});
if (!result.ok()) {
throw new Error(await result.text());
}
return (await result.json()).data;
};
/**
* 根据 collection 的配置生成 Faker 数据
* @param collectionSetting
* @param all
* @returns
*/
const generateFakerData = (collectionSetting: CollectionSetting) => {
const excludeField = ['id', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'];
const basicInterfaceToData = {
input: () => faker.lorem.words(),
textarea: () => faker.lorem.paragraph(),
richText: () => faker.lorem.paragraph(),
phone: () => faker.phone.number(),
email: () => faker.internet.email(),
url: () => faker.internet.url(),
integer: () => faker.number.int(),
number: () => faker.number.int(),
percent: () => faker.number.float(),
password: () => faker.internet.password(),
color: () => faker.internet.color(),
icon: () => 'checkcircleoutlined',
datetime: () => faker.date.anytime({ refDate: '2023-09-21T00:00:00.000Z' }),
time: () => '00:00:00',
};
const result = {};
collectionSetting.fields?.forEach((field) => {
if (field.name && excludeField.includes(field.name)) {
return;
}
if (basicInterfaceToData[field.interface] && field.name) {
result[field.name] = basicInterfaceToData[field.interface]();
}
});
return result;
};
const createRandomData = async (collectionName: string, count = 10, data?: any, maxDepth?: number) => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const result = await api.post(
`/api/${collectionName}:mock?count=${count}&maxDepth=${_.isNumber(maxDepth) ? maxDepth : 1}`,
{
headers,
data,
},
);
if (!result.ok()) {
throw new Error(await result.text());
}
return (await result.json()).data;
};
// 删除掉 id 不是 1 的 users 和 name 不是 root admin member 的 roles
async function removeRedundantUserAndRoles() {
const deletePromises = [
deleteRecords('users', { id: { $ne: 1 } }),
deleteRecords('roles', { name: { $ne: ['root', 'admin', 'member'] } }),
];
await Promise.all(deletePromises);
}
function getHeaders(storageState: any) {
const headers: any = {};
const token = getStorageItem('NOCOBASE_TOKEN', storageState);
const auth = getStorageItem('NOCOBASE_AUTH', storageState);
const subAppName = new URL(APP_BASE_URL).pathname.match(/^\/apps\/([^/]*)\/*/)?.[1];
const hostName = new URL(APP_BASE_URL).host;
const locale = getStorageItem('NOCOBASE_LOCALE', storageState);
const timezone = '+08:00';
const withAclMeta = 'true';
const role = getStorageItem('NOCOBASE_ROLE', storageState);
if (token) {
headers.Authorization = `Bearer ${token}`;
}
if (auth) {
headers['X-Authenticator'] = auth;
}
if (subAppName) {
headers['X-App'] = subAppName;
}
if (hostName) {
headers['X-Hostname'] = hostName;
}
if (locale) {
headers['X-Locale'] = locale;
}
if (timezone) {
headers['X-Timezone'] = timezone;
}
if (withAclMeta) {
headers['X-With-Acl-Meta'] = withAclMeta;
}
if (role) {
headers['X-Role'] = role;
}
return headers;
}
interface ExpectSettingsMenuParams {
showMenu: () => Promise<void>;
supportedOptions: string[];
page: Page;
unsupportedOptions?: string[];
}
/**
* 辅助断言 SchemaSettings 的菜单项是否存在
* @param param0
*/
export async function expectSettingsMenu({
showMenu,
supportedOptions,
page,
unsupportedOptions,
}: ExpectSettingsMenuParams) {
await showMenu();
for (const option of supportedOptions) {
await expect(page.getByRole('menuitem', { name: option })).toBeVisible();
}
if (unsupportedOptions) {
for (const option of unsupportedOptions) {
await expect(page.getByRole('menuitem', { name: option })).not.toBeVisible();
}
}
}
/**
* 辅助断言 Initializer 的菜单项是否存在
* @param param0
*/
export async function expectInitializerMenu({
showMenu,
supportedOptions,
page,
expectValue,
}: {
showMenu: () => Promise<void>;
supportedOptions: string[];
page: Page;
expectValue?: () => Promise<void>;
}) {
await showMenu();
for (const option of supportedOptions) {
// 使用 first 方法避免有重名的导致报错
await expect(page.getByRole('menuitem', { name: option }).first()).toBeVisible();
}
await page.mouse.move(300, 0);
if (expectValue) {
await expectValue();
}
}
/**
* 用于辅助在 page 中创建区块
* @param page
* @param name
*/
export const createBlockInPage = async (page: Page, name: string) => {
await page.getByLabel('schema-initializer-Grid-page:addBlock').hover();
if (name === 'Form') {
await page.getByText('Form', { exact: true }).first().hover();
} else if (name === 'Filter form') {
await page.getByText('Form', { exact: true }).nth(1).hover();
} else {
await page.getByText(name, { exact: true }).hover();
}
if (name === 'Markdown') {
await page.getByRole('menuitem', { name: 'Markdown' }).click();
} else {
await page.getByRole('menuitem', { name: 'Users' }).click();
}
await page.mouse.move(300, 0);
};
export const mockUserRecordsWithoutDepartments = (mockRecords: ExtendUtils['mockRecords'], count: number) => {
return mockRecords(
'users',
Array.from({ length: count }).map(() => ({
departments: null,
mainDepartment: null,
})),
);
};
/**
* 用来辅助断言是否支持某些变量
* @param page
* @param variables
*/
export async function expectSupportedVariables(page: Page, variables: string[]) {
for (const name of variables) {
await expect(page.getByRole('menuitemcheckbox', { name })).toBeVisible();
}
}