mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-07-01 18:52:20 +08:00
feat(acl): add support for configuring mobile menu permissions (#5174)
* refactor(plugin-acl): extensible support for role permissions configuration UI * feat: complete the configuration UI * feat: complete the backend section * chore: update unit tests * chore: add translation * chore: change 'Menu' to 'Desktop menu' * refactor: use 'extendCollection' instead of 'this.db.extendCollection' * chore: update acl e2e test * test: add e2e tests * fix: should refresh data when changing tab * fix(menu): should hide children when children only have one * feat: show tip when no pages find * feat(tabBar): supports left and right swiping * refactor: improve code * chore: make e2e test pass * chore: add migration * fix: should use tk instead of values * chore: nothing * fix: improve * refactor: rename mobileMenuUiSchemas to mobileRoutes * refactor: add onDelete * fix: change snippet to 'pm.mobile' from 'pm.mobile.roles' * refactor: extract nested loop to outside * refactor: use db.on('mobileRoutes:afterCreate') * refactor: simplify code logic * chore: fix build * fix: improve code * chore: fix build * feat: hide menu configuration UI when no permission --------- Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
parent
c83b578905
commit
ffc2982380
@ -115,7 +115,7 @@ export abstract class RelationRepository {
|
|||||||
|
|
||||||
convertTk(options: any) {
|
convertTk(options: any) {
|
||||||
let tk = options;
|
let tk = options;
|
||||||
if (typeof options === 'object' && options['tk']) {
|
if (typeof options === 'object' && 'tk' in options) {
|
||||||
tk = options['tk'];
|
tk = options['tk'];
|
||||||
}
|
}
|
||||||
return tk;
|
return tk;
|
||||||
@ -126,7 +126,12 @@ export abstract class RelationRepository {
|
|||||||
if (typeof tk === 'string') {
|
if (typeof tk === 'string') {
|
||||||
tk = tk.split(',');
|
tk = tk.split(',');
|
||||||
}
|
}
|
||||||
return lodash.castArray(tk);
|
|
||||||
|
if (tk) {
|
||||||
|
return lodash.castArray(tk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
targetKey() {
|
targetKey() {
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
import { TabsProps } from 'antd/es/tabs/index';
|
import { TabsProps } from 'antd/es/tabs/index';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { TFunction } from 'react-i18next';
|
||||||
import { GeneralPermissions } from './permissions/GeneralPermissions';
|
import { GeneralPermissions } from './permissions/GeneralPermissions';
|
||||||
import { MenuItemsProvider } from './permissions/MenuItemsProvider';
|
import { MenuItemsProvider } from './permissions/MenuItemsProvider';
|
||||||
import { MenuPermissions } from './permissions/MenuPermissions';
|
import { MenuPermissions } from './permissions/MenuPermissions';
|
||||||
@ -22,11 +23,15 @@ interface PermissionsTabsProps {
|
|||||||
/**
|
/**
|
||||||
* the currently selected role
|
* the currently selected role
|
||||||
*/
|
*/
|
||||||
role: Role;
|
activeRole: null | Role;
|
||||||
|
/**
|
||||||
|
* the current user's role
|
||||||
|
*/
|
||||||
|
currentUserRole: null | Role;
|
||||||
/**
|
/**
|
||||||
* translation function
|
* translation function
|
||||||
*/
|
*/
|
||||||
t: (key: string) => string;
|
t: TFunction;
|
||||||
/**
|
/**
|
||||||
* used to constrain the size of the container in the Tab
|
* used to constrain the size of the container in the Tab
|
||||||
*/
|
*/
|
||||||
@ -53,7 +58,7 @@ export class ACLSettingsUI {
|
|||||||
}),
|
}),
|
||||||
({ activeKey, t, TabLayout }) => ({
|
({ activeKey, t, TabLayout }) => ({
|
||||||
key: 'menu',
|
key: 'menu',
|
||||||
label: t('Menu'),
|
label: t('Desktop menu'),
|
||||||
children: (
|
children: (
|
||||||
<TabLayout>
|
<TabLayout>
|
||||||
<MenuItemsProvider>
|
<MenuItemsProvider>
|
||||||
|
@ -37,7 +37,7 @@ test('menu permission ', async ({ page, mockPage, mockRole, updateRole }) => {
|
|||||||
.locator('span')
|
.locator('span')
|
||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole('tab').getByText('Menu').click();
|
await page.getByRole('tab').getByText('Desktop menu').click();
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await expect(page.getByRole('row', { name: 'page1' }).locator('.ant-checkbox-input').last()).toBeChecked({
|
await expect(page.getByRole('row', { name: 'page1' }).locator('.ant-checkbox-input').last()).toBeChecked({
|
||||||
checked: true,
|
checked: true,
|
||||||
@ -57,7 +57,7 @@ test('menu permission ', async ({ page, mockPage, mockRole, updateRole }) => {
|
|||||||
.locator('span')
|
.locator('span')
|
||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole('tab').getByText('Menu').click();
|
await page.getByRole('tab').getByText('Desktop menu').click();
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await expect(page.getByRole('row', { name: 'page1' }).locator('.ant-checkbox-input').last()).toBeChecked({
|
await expect(page.getByRole('row', { name: 'page1' }).locator('.ant-checkbox-input').last()).toBeChecked({
|
||||||
checked: false,
|
checked: false,
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useAPIClient, usePlugin, useRequest } from '@nocobase/client';
|
import { useACLRoleContext, useAPIClient, usePlugin, useRequest } from '@nocobase/client';
|
||||||
import { Tabs } from 'antd';
|
import { Tabs } from 'antd';
|
||||||
import React, { useContext, useEffect, useMemo } from 'react';
|
import React, { useContext, useEffect, useMemo } from 'react';
|
||||||
import PluginACLClient from '..';
|
import PluginACLClient from '..';
|
||||||
@ -24,10 +24,13 @@ export const Permissions: React.FC<{ active: boolean }> = ({ active }) => {
|
|||||||
const [activeKey, setActiveKey] = React.useState('general');
|
const [activeKey, setActiveKey] = React.useState('general');
|
||||||
const { role, setRole } = useContext(RolesManagerContext);
|
const { role, setRole } = useContext(RolesManagerContext);
|
||||||
const pluginACLClient = usePlugin(PluginACLClient);
|
const pluginACLClient = usePlugin(PluginACLClient);
|
||||||
|
const currentUserRole = useACLRoleContext();
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() => pluginACLClient.settingsUI.getPermissionsTabs({ t, activeKey, TabLayout, role }),
|
() =>
|
||||||
[activeKey, pluginACLClient.settingsUI, role, t],
|
pluginACLClient.settingsUI
|
||||||
|
.getPermissionsTabs({ t, activeKey, TabLayout, activeRole: role, currentUserRole })
|
||||||
|
.filter(Boolean),
|
||||||
|
[activeKey, pluginACLClient.settingsUI, role, t, currentUserRole],
|
||||||
);
|
);
|
||||||
|
|
||||||
const api = useAPIClient();
|
const api = useAPIClient();
|
||||||
|
@ -2,5 +2,6 @@
|
|||||||
"The current user has no roles. Please try another account.": "The current user has no roles. Please try another account.",
|
"The current user has no roles. Please try another account.": "The current user has no roles. Please try another account.",
|
||||||
"The user role does not exist. Please try signing in again": "The user role does not exist. Please try signing in again",
|
"The user role does not exist. Please try signing in again": "The user role does not exist. Please try signing in again",
|
||||||
"New role": "New role",
|
"New role": "New role",
|
||||||
"Permissions": "Permissions"
|
"Permissions": "Permissions",
|
||||||
|
"Desktop menu": "Desktop menu"
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"The current user has no roles. Please try another account.": "현재 사용자에게 역할이 없습니다. 다른 계정을 시도해주세요.",
|
"The current user has no roles. Please try another account.": "현재 사용자에게 역할이 없습니다. 다른 계정을 시도해주세요.",
|
||||||
"The user role does not exist. Please try signing in again": "사용자 역할이 존재하지 않습니다. 다시 로그인을 시도해주세요."
|
"The user role does not exist. Please try signing in again": "사용자 역할이 존재하지 않습니다. 다시 로그인을 시도해주세요.",
|
||||||
|
"Desktop menu": "데스크톱 메뉴"
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
"Permissions": "权限",
|
"Permissions": "权限",
|
||||||
"Roles & Permissions": "角色和权限",
|
"Roles & Permissions": "角色和权限",
|
||||||
"General": "通用",
|
"General": "通用",
|
||||||
"Menu": "菜单",
|
"Desktop menu": "桌面端菜单",
|
||||||
"Plugin settings": "插件设置",
|
"Plugin settings": "插件设置",
|
||||||
"Data sources": "数据源"
|
"Data sources": "数据源"
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,8 @@
|
|||||||
"@nocobase/client": "1.x",
|
"@nocobase/client": "1.x",
|
||||||
"@nocobase/plugin-acl": "1.x",
|
"@nocobase/plugin-acl": "1.x",
|
||||||
"@nocobase/server": "1.x",
|
"@nocobase/server": "1.x",
|
||||||
"@nocobase/test": "1.x"
|
"@nocobase/test": "1.x",
|
||||||
|
"@nocobase/plugin-acl": "1.x"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"Data model tools"
|
"Data model tools"
|
||||||
|
@ -35,12 +35,12 @@ export class PluginDataSourceManagerClient extends Plugin {
|
|||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
// register a configuration item in the Users & Permissions management page
|
// register a configuration item in the Users & Permissions management page
|
||||||
this.app.pm.get(PluginACLClient).settingsUI.addPermissionsTab(({ t, TabLayout, role }) => ({
|
this.app.pm.get(PluginACLClient).settingsUI.addPermissionsTab(({ t, TabLayout, activeRole }) => ({
|
||||||
key: 'dataSource',
|
key: 'dataSource',
|
||||||
label: t('Data sources'),
|
label: t('Data sources'),
|
||||||
children: (
|
children: (
|
||||||
<TabLayout>
|
<TabLayout>
|
||||||
<DataSourcePermissionManager role={role} />
|
<DataSourcePermissionManager role={activeRole} />
|
||||||
</TabLayout>
|
</TabLayout>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
@ -12,7 +12,8 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@nocobase/client": "1.x",
|
"@nocobase/client": "1.x",
|
||||||
"@nocobase/server": "1.x",
|
"@nocobase/server": "1.x",
|
||||||
"@nocobase/test": "1.x"
|
"@nocobase/test": "1.x",
|
||||||
|
"@nocobase/plugin-acl": "1.x"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ant-design/icons": "5.x",
|
"@ant-design/icons": "5.x",
|
||||||
@ -23,6 +24,12 @@
|
|||||||
"@types/react-dom": "17.x",
|
"@types/react-dom": "17.x",
|
||||||
"antd-mobile": "^5.36.1",
|
"antd-mobile": "^5.36.1",
|
||||||
"re-resizable": "6.6.0",
|
"re-resizable": "6.6.0",
|
||||||
"react-device-detect": "2.2.3"
|
"react-device-detect": "2.2.3",
|
||||||
|
"@emotion/css": "11.x",
|
||||||
|
"ahooks": "3.x",
|
||||||
|
"lodash": "4.x",
|
||||||
|
"@formily/core": "2.x",
|
||||||
|
"antd": "5.x",
|
||||||
|
"react-i18next": "11.x"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* 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 { css } from '@emotion/css';
|
||||||
|
import { createForm, Form, onFormValuesChange } from '@formily/core';
|
||||||
|
import { uid } from '@formily/shared';
|
||||||
|
import { SchemaComponent, useAPIClient, useRequest } from '@nocobase/client';
|
||||||
|
import { RolesManagerContext } from '@nocobase/plugin-acl/client';
|
||||||
|
import { useMemoizedFn } from 'ahooks';
|
||||||
|
import { Checkbox, message, Table } from 'antd';
|
||||||
|
import _, { uniq } from 'lodash';
|
||||||
|
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { MobileRoutesProvider, useMobileRoutes } from './mobile-providers';
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
title: string;
|
||||||
|
id: number;
|
||||||
|
children?: MenuItem[];
|
||||||
|
parent?: MenuItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = css`
|
||||||
|
.ant-table-cell {
|
||||||
|
> .ant-space-horizontal {
|
||||||
|
.ant-space-item-split:has(+ .ant-space-item:empty) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const translateTitle = (menus: any[], t) => {
|
||||||
|
return menus.map((menu) => {
|
||||||
|
const title = t(menu.title);
|
||||||
|
if (menu.children) {
|
||||||
|
return {
|
||||||
|
...menu,
|
||||||
|
title,
|
||||||
|
children: translateTitle(menu.children, t),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...menu,
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const findIDList = (items) => {
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const IDList = [];
|
||||||
|
for (const item of items) {
|
||||||
|
IDList.push(item.id);
|
||||||
|
if (item.hideChildren && !_.isNil(item.firstTabId)) {
|
||||||
|
IDList.push(item.firstTabId);
|
||||||
|
}
|
||||||
|
IDList.push(...findIDList(item.children));
|
||||||
|
}
|
||||||
|
return IDList;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toItems = (items, parent?: MenuItem): MenuItem[] => {
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map((item) => {
|
||||||
|
const children = toItems(item.children, item);
|
||||||
|
const hideChildren = children.length <= 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: item.title,
|
||||||
|
id: item.id,
|
||||||
|
children: hideChildren ? null : children,
|
||||||
|
hideChildren,
|
||||||
|
firstTabId: children[0]?.id,
|
||||||
|
parent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MenuPermissions: React.FC<{
|
||||||
|
active: boolean;
|
||||||
|
}> = ({ active }) => {
|
||||||
|
const { routeList } = useMobileRoutes();
|
||||||
|
const items = toItems(routeList);
|
||||||
|
const { role, setRole } = useContext(RolesManagerContext);
|
||||||
|
const api = useAPIClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const allIDList = findIDList(items);
|
||||||
|
const [IDList, setIDList] = useState([]);
|
||||||
|
const { loading, refresh } = useRequest(
|
||||||
|
{
|
||||||
|
resource: 'roles.mobileRoutes',
|
||||||
|
resourceOf: role.name,
|
||||||
|
action: 'list',
|
||||||
|
params: {
|
||||||
|
paginate: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ready: !!role && active,
|
||||||
|
refreshDeps: [role?.name],
|
||||||
|
onSuccess(data) {
|
||||||
|
setIDList(data?.data?.map((item) => item['id']) || []);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const resource = api.resource('roles.mobileRoutes', role.name);
|
||||||
|
const allChecked = allIDList.length === IDList.length;
|
||||||
|
|
||||||
|
const handleChange = async (checked, menuItem) => {
|
||||||
|
if (checked) {
|
||||||
|
let newIDList = IDList.filter((id) => id !== menuItem.id);
|
||||||
|
const shouldRemove = [menuItem.id];
|
||||||
|
|
||||||
|
if (menuItem.parent) {
|
||||||
|
const selectedChildren = menuItem.parent.children.filter((item) => newIDList.includes(item.id));
|
||||||
|
if (selectedChildren.length === 0) {
|
||||||
|
newIDList = newIDList.filter((id) => id !== menuItem.parent.id);
|
||||||
|
shouldRemove.push(menuItem.parent.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menuItem.children) {
|
||||||
|
newIDList = newIDList.filter((id) => !menuItem.children.map((item) => item.id).includes(id));
|
||||||
|
shouldRemove.push(...menuItem.children.map((item) => item.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menuItem.hideChildren && !_.isNil(menuItem.firstTabId)) {
|
||||||
|
shouldRemove.push(menuItem.firstTabId);
|
||||||
|
newIDList = newIDList.filter((id) => id !== menuItem.firstTabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIDList(newIDList);
|
||||||
|
await resource.remove({
|
||||||
|
values: shouldRemove,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const newIDList = [...IDList, menuItem.id];
|
||||||
|
const shouldAdd = [menuItem.id];
|
||||||
|
|
||||||
|
if (menuItem.parent) {
|
||||||
|
if (!newIDList.includes(menuItem.parent.id)) {
|
||||||
|
newIDList.push(menuItem.parent.id);
|
||||||
|
shouldAdd.push(menuItem.parent.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menuItem.children) {
|
||||||
|
const childrenIDList = menuItem.children.map((item) => item.id);
|
||||||
|
newIDList.push(...childrenIDList);
|
||||||
|
shouldAdd.push(...childrenIDList);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menuItem.hideChildren && !_.isNil(menuItem.firstTabId)) {
|
||||||
|
shouldAdd.push(menuItem.firstTabId);
|
||||||
|
newIDList.push(menuItem.firstTabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIDList(uniq(newIDList));
|
||||||
|
await resource.add({
|
||||||
|
values: shouldAdd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
message.success(t('Saved successfully'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = useMemoizedFn(async (form: Form) => {
|
||||||
|
await api.resource('roles').update({
|
||||||
|
filterByTk: role.name,
|
||||||
|
values: form.values,
|
||||||
|
});
|
||||||
|
setRole({ ...role, ...form.values });
|
||||||
|
message.success(t('Saved successfully'));
|
||||||
|
});
|
||||||
|
const form = useMemo(() => {
|
||||||
|
return createForm({
|
||||||
|
values: role,
|
||||||
|
effects() {
|
||||||
|
onFormValuesChange(async (form) => {
|
||||||
|
await update(form);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [role, update]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SchemaComponent
|
||||||
|
schema={{
|
||||||
|
type: 'void',
|
||||||
|
name: uid(),
|
||||||
|
'x-component': 'FormV2',
|
||||||
|
'x-component-props': {
|
||||||
|
form,
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
allowNewMobileMenu: {
|
||||||
|
title: t('Menu permissions'),
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Checkbox',
|
||||||
|
'x-content': t('New menu items are allowed to be accessed by default.'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
className={style}
|
||||||
|
loading={loading}
|
||||||
|
rowKey={'id'}
|
||||||
|
pagination={false}
|
||||||
|
expandable={{
|
||||||
|
defaultExpandAllRows: true,
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
dataIndex: 'title',
|
||||||
|
title: t('Menu item title'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataIndex: 'accessible',
|
||||||
|
title: (
|
||||||
|
<>
|
||||||
|
<Checkbox
|
||||||
|
checked={allChecked}
|
||||||
|
onChange={async (value) => {
|
||||||
|
if (allChecked) {
|
||||||
|
await resource.set({
|
||||||
|
values: [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await resource.set({
|
||||||
|
values: allIDList,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
message.success(t('Saved successfully'));
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
{t('Accessible')}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
render: (_, schema) => {
|
||||||
|
const checked = IDList.includes(schema.id);
|
||||||
|
return <Checkbox checked={checked} onChange={() => handleChange(checked, schema)} />;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={translateTitle(items, t)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MobileAllRoutesProvider: React.FC<{ active: boolean }> = ({ children, active }) => {
|
||||||
|
const refreshRef = React.useRef(() => {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) {
|
||||||
|
refreshRef.current?.();
|
||||||
|
}
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileRoutesProvider action="list" refreshRef={refreshRef} manual>
|
||||||
|
{children}
|
||||||
|
</MobileRoutesProvider>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 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 { useDesignable } from '@nocobase/client';
|
||||||
|
import { ErrorBlock } from 'antd-mobile';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { isMobile } from 'react-device-detect';
|
||||||
|
import { usePluginTranslation } from './locale';
|
||||||
|
import { useMobileRoutes } from './mobile-providers/context';
|
||||||
|
|
||||||
|
export const ShowTipWhenNoPages: FC = ({ children }) => {
|
||||||
|
const { designable } = useDesignable();
|
||||||
|
const { routeList } = useMobileRoutes();
|
||||||
|
const { t } = usePluginTranslation();
|
||||||
|
|
||||||
|
if ((!designable || isMobile) && _.isEmpty(routeList)) {
|
||||||
|
return (
|
||||||
|
<ErrorBlock
|
||||||
|
status="empty"
|
||||||
|
fullPage
|
||||||
|
title={t('No accessible pages found')}
|
||||||
|
description={t('This might be due to permission configuration issues')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* 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, Page, test } from '@nocobase/test/e2e';
|
||||||
|
|
||||||
|
const createNewPage = async (title: string, page: Page) => {
|
||||||
|
await page.getByTestId('schema-initializer-MobileTabBar').hover();
|
||||||
|
await page.getByRole('menuitem', { name: 'Page' }).click();
|
||||||
|
await page.getByLabel('block-item-Input-Title').getByRole('textbox').fill(title);
|
||||||
|
await page.getByRole('button', { name: 'Select icon' }).click();
|
||||||
|
await page.getByRole('tooltip').getByLabel('account-book').locator('svg').click();
|
||||||
|
await page.getByLabel('action-Action-Submit').click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewTab = async (title: string, page: Page) => {
|
||||||
|
await page.getByLabel('action-Action-undefined').click();
|
||||||
|
await page.getByLabel('block-item-Input-Title').getByRole('textbox').fill(title);
|
||||||
|
await page.getByLabel('action-Action-Submit').click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletePage = async (title: string, page: Page) => {
|
||||||
|
await page.getByLabel('block-item-MobileTabBar.Page').filter({ hasText: title }).hover();
|
||||||
|
await page.getByTestId('mobile-tab-bar').getByRole('button', { name: 'designer-schema-settings-' }).hover();
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'OK', exact: true }).click();
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('mobile permissions', () => {
|
||||||
|
test('menu permission ', async ({ page }) => {
|
||||||
|
await page.goto('/m');
|
||||||
|
|
||||||
|
// by default, the user's role is root, which has all permissions
|
||||||
|
// create a new page
|
||||||
|
await createNewPage('root', page);
|
||||||
|
|
||||||
|
// expect: the new page should be visible
|
||||||
|
await expect(page.getByLabel('block-item-MobileTabBar.Page').filter({ hasText: 'root' })).toBeVisible();
|
||||||
|
|
||||||
|
// change the user's role to admin
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.localStorage.setItem('NOCOBASE_ROLE', 'admin');
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
await createNewPage('admin', page);
|
||||||
|
|
||||||
|
await page.getByLabel('block-item-MobilePageProvider').hover();
|
||||||
|
await page.getByLabel('designer-schema-settings-MobilePageProvider-mobile:page').hover();
|
||||||
|
await page.getByRole('menuitem', { name: 'Display tabs' }).getByRole('switch').check();
|
||||||
|
|
||||||
|
// create a new tab
|
||||||
|
await createNewTab('tab456', page);
|
||||||
|
await expect(page.getByRole('tab', { name: 'tab456', exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// change tab title 'Unnamed' to 'tab123'
|
||||||
|
await page.getByRole('tab', { name: 'Unnamed', exact: true }).hover();
|
||||||
|
await page.getByTestId('mobile-page-tabs-Unnamed').getByLabel('designer-schema-settings-').hover();
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
await page.getByLabel('block-item-Input-Title').getByRole('textbox').fill('tab123');
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
|
// expect: the new page should be visible
|
||||||
|
await expect(page.getByLabel('block-item-MobileTabBar.Page').filter({ hasText: 'admin' })).toBeVisible();
|
||||||
|
|
||||||
|
// change the user's role to admin, and then change the menu permission
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.localStorage.setItem('NOCOBASE_ROLE', 'admin');
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
await page.goto('/admin/settings/users-permissions/roles');
|
||||||
|
await page.getByRole('tab', { name: 'Mobile menu' }).click();
|
||||||
|
await page.getByRole('row', { name: 'Collapse row admin' }).getByLabel('', { exact: true }).uncheck();
|
||||||
|
// the children of the admin tabs should be unchecked
|
||||||
|
await expect(page.getByRole('row', { name: 'tab123' }).getByLabel('', { exact: true })).toBeChecked({
|
||||||
|
checked: false,
|
||||||
|
});
|
||||||
|
await expect(page.getByRole('row', { name: 'tab456' }).getByLabel('', { exact: true })).toBeChecked({
|
||||||
|
checked: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// the admin tab should be checked when the child is checked
|
||||||
|
await page.getByRole('row', { name: 'tab123' }).getByLabel('', { exact: true }).check();
|
||||||
|
await expect(page.getByRole('row', { name: 'Collapse row admin' }).getByLabel('', { exact: true })).toBeChecked({
|
||||||
|
checked: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// the admin tab should be unchecked when the children is all unchecked
|
||||||
|
await page.getByRole('row', { name: 'tab123' }).getByLabel('', { exact: true }).uncheck();
|
||||||
|
await expect(page.getByRole('row', { name: 'Collapse row admin' }).getByLabel('', { exact: true })).toBeChecked({
|
||||||
|
checked: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// to the mobile, the admin page should be hidden
|
||||||
|
await page.goto('/m');
|
||||||
|
await expect(page.getByLabel('block-item-MobileTabBar.Page').filter({ hasText: 'admin' })).not.toBeVisible();
|
||||||
|
|
||||||
|
// go back to the configuration page, and check one child of the admin
|
||||||
|
await page.goto('/admin/settings/users-permissions/roles');
|
||||||
|
await page.getByRole('tab', { name: 'Mobile menu' }).click();
|
||||||
|
await page.getByRole('row', { name: 'tab123' }).getByLabel('', { exact: true }).check();
|
||||||
|
|
||||||
|
// to the mobile, the admin page should be visible, and the tab123 should be visible, and the tab456 should be hidden
|
||||||
|
await page.goto('/m');
|
||||||
|
await expect(page.getByLabel('block-item-MobileTabBar.Page').filter({ hasText: 'admin' })).toBeVisible();
|
||||||
|
await page.getByLabel('block-item-MobileTabBar.Page').filter({ hasText: 'admin' }).click();
|
||||||
|
await expect(page.getByRole('tab', { name: 'tab123', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'tab456', exact: true })).not.toBeVisible();
|
||||||
|
|
||||||
|
// delete the pages
|
||||||
|
await deletePage('root', page);
|
||||||
|
await deletePage('admin', page);
|
||||||
|
});
|
||||||
|
});
|
@ -28,7 +28,7 @@ const app = mockApp({
|
|||||||
},
|
},
|
||||||
plugins: [DemoPlugin],
|
plugins: [DemoPlugin],
|
||||||
apis: {
|
apis: {
|
||||||
'mobileRoutes:list': {
|
'mobileRoutes:listAccessible': {
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
id: 10,
|
id: 10,
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Plugin } from '@nocobase/client';
|
import { Plugin } from '@nocobase/client';
|
||||||
import { mockApp } from '@nocobase/client/demo-utils';
|
import { mockApp } from '@nocobase/client/demo-utils';
|
||||||
import {
|
import {
|
||||||
MobileTitleProvider,
|
|
||||||
useMobileTitle,
|
|
||||||
MobileRoutesProvider,
|
MobileRoutesProvider,
|
||||||
|
MobileTitleProvider,
|
||||||
useMobileRoutes,
|
useMobileRoutes,
|
||||||
|
useMobileTitle,
|
||||||
} from '@nocobase/plugin-mobile/client';
|
} from '@nocobase/plugin-mobile/client';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
const InnerPage = () => {
|
const InnerPage = () => {
|
||||||
const { routeList } = useMobileRoutes();
|
const { routeList } = useMobileRoutes();
|
||||||
@ -43,39 +43,37 @@ const app = mockApp({
|
|||||||
},
|
},
|
||||||
plugins: [DemoPlugin],
|
plugins: [DemoPlugin],
|
||||||
apis: {
|
apis: {
|
||||||
'mobileRoutes:list': {
|
'mobileRoutes:listAccessible': {
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
"id": 10,
|
id: 10,
|
||||||
"createdAt": "2024-07-08T13:22:33.763Z",
|
createdAt: '2024-07-08T13:22:33.763Z',
|
||||||
"updatedAt": "2024-07-08T13:22:33.763Z",
|
updatedAt: '2024-07-08T13:22:33.763Z',
|
||||||
"parentId": null,
|
parentId: null,
|
||||||
"title": "Test1",
|
title: 'Test1',
|
||||||
"icon": "AppstoreOutlined",
|
icon: 'AppstoreOutlined',
|
||||||
"schemaUid": "test",
|
schemaUid: 'test',
|
||||||
"type": "page",
|
type: 'page',
|
||||||
"options": null,
|
options: null,
|
||||||
"sort": 1,
|
sort: 1,
|
||||||
"createdById": 1,
|
createdById: 1,
|
||||||
"updatedById": 1,
|
updatedById: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 13,
|
id: 13,
|
||||||
"createdAt": "2024-07-08T13:23:01.929Z",
|
createdAt: '2024-07-08T13:23:01.929Z',
|
||||||
"updatedAt": "2024-07-08T13:23:12.433Z",
|
updatedAt: '2024-07-08T13:23:12.433Z',
|
||||||
"parentId": null,
|
parentId: null,
|
||||||
"title": "Test2",
|
title: 'Test2',
|
||||||
"icon": "aliwangwangoutlined",
|
icon: 'aliwangwangoutlined',
|
||||||
"schemaUid": null,
|
schemaUid: null,
|
||||||
"type": "link",
|
type: 'link',
|
||||||
"options": {
|
options: {
|
||||||
"schemaUid": null,
|
schemaUid: null,
|
||||||
"url": "https://github.com",
|
url: 'https://github.com',
|
||||||
"params": [
|
params: [{}],
|
||||||
{}
|
},
|
||||||
]
|
},
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -38,7 +38,7 @@ const app = mockApp({
|
|||||||
'applicationPlugins:update': {
|
'applicationPlugins:update': {
|
||||||
data: {},
|
data: {},
|
||||||
},
|
},
|
||||||
'mobileRoutes:list': {
|
'mobileRoutes:listAccessible': {
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
id: 10,
|
id: 10,
|
||||||
|
@ -38,7 +38,7 @@ const app = mockApp({
|
|||||||
'applicationPlugins:update': {
|
'applicationPlugins:update': {
|
||||||
data: {},
|
data: {},
|
||||||
},
|
},
|
||||||
'mobileRoutes:list': {
|
'mobileRoutes:listAccessible': {
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
id: 10,
|
id: 10,
|
||||||
|
@ -43,7 +43,7 @@ const app = mockApp({
|
|||||||
'applicationPlugins:update': {
|
'applicationPlugins:update': {
|
||||||
data: {},
|
data: {},
|
||||||
},
|
},
|
||||||
'mobileRoutes:list': {
|
'mobileRoutes:listAccessible': {
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
id: 10,
|
id: 10,
|
||||||
|
@ -48,7 +48,7 @@ const app = mockApp({
|
|||||||
schemaSettings: [mobileTabBarLinkSettings],
|
schemaSettings: [mobileTabBarLinkSettings],
|
||||||
designable: true,
|
designable: true,
|
||||||
apis: {
|
apis: {
|
||||||
'mobileRoutes:list': {
|
'mobileRoutes:listAccessible': {
|
||||||
data: [],
|
data: [],
|
||||||
},
|
},
|
||||||
'mobileRoutes:update': {
|
'mobileRoutes:update': {
|
||||||
|
@ -46,7 +46,7 @@ const app = mockApp({
|
|||||||
schemaSettings: [mobileTabBarPageSettings],
|
schemaSettings: [mobileTabBarPageSettings],
|
||||||
designable: true,
|
designable: true,
|
||||||
apis: {
|
apis: {
|
||||||
'mobileRoutes:list': {
|
'mobileRoutes:listAccessible': {
|
||||||
data: [],
|
data: [],
|
||||||
},
|
},
|
||||||
'mobileRoutes:update': {
|
'mobileRoutes:update': {
|
||||||
|
@ -45,7 +45,7 @@ const app = mockApp({
|
|||||||
},
|
},
|
||||||
designable: true,
|
designable: true,
|
||||||
apis: {
|
apis: {
|
||||||
'mobileRoutes:list': {
|
'mobileRoutes:listAccessible': {
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
@ -28,7 +28,7 @@ const app = mockApp({
|
|||||||
},
|
},
|
||||||
plugins: [DemoPlugin],
|
plugins: [DemoPlugin],
|
||||||
apis: {
|
apis: {
|
||||||
'mobileRoutes:list': {
|
'mobileRoutes:listAccessible': {
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
id: 28,
|
id: 28,
|
||||||
|
@ -2,8 +2,8 @@
|
|||||||
* iframe: true
|
* iframe: true
|
||||||
*/
|
*/
|
||||||
import { Plugin } from '@nocobase/client';
|
import { Plugin } from '@nocobase/client';
|
||||||
import PluginMobileClient, { Mobile } from '@nocobase/plugin-mobile/client';
|
|
||||||
import { mockApp } from '@nocobase/client/demo-utils';
|
import { mockApp } from '@nocobase/client/demo-utils';
|
||||||
|
import PluginMobileClient, { Mobile } from '@nocobase/plugin-mobile/client';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
class DemoPlugin extends Plugin {
|
class DemoPlugin extends Plugin {
|
||||||
@ -40,7 +40,7 @@ const app = mockApp({
|
|||||||
DemoPlugin,
|
DemoPlugin,
|
||||||
],
|
],
|
||||||
apis: {
|
apis: {
|
||||||
'mobileRoutes:list': {
|
'mobileRoutes:listAccessible': {
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
|
@ -25,7 +25,7 @@ const app = mockApp({
|
|||||||
},
|
},
|
||||||
plugins: [DemoPlugin],
|
plugins: [DemoPlugin],
|
||||||
apis: {
|
apis: {
|
||||||
'mobileRoutes:list': {
|
'mobileRoutes:listAccessible': {
|
||||||
data: [],
|
data: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Plugin } from '@nocobase/client';
|
import { Plugin } from '@nocobase/client';
|
||||||
import { MobileNotFoundPage, MobilePageContent, MobileRoutesProvider } from '@nocobase/plugin-mobile/client';
|
import { MobileNotFoundPage, MobilePageContent, MobileRoutesProvider } from '@nocobase/plugin-mobile/client';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { mockApp } from '@nocobase/client/demo-utils';
|
import { mockApp } from '@nocobase/client/demo-utils';
|
||||||
|
|
||||||
@ -40,7 +40,7 @@ const app = mockApp({
|
|||||||
},
|
},
|
||||||
plugins: [DemoPlugin],
|
plugins: [DemoPlugin],
|
||||||
apis: {
|
apis: {
|
||||||
'mobileRoutes:list': {
|
'mobileRoutes:listAccessible': {
|
||||||
data: [],
|
data: [],
|
||||||
},
|
},
|
||||||
'uiSchemas:getJsonSchema/tab1': {
|
'uiSchemas:getJsonSchema/tab1': {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Plugin } from '@nocobase/client';
|
import { Plugin } from '@nocobase/client';
|
||||||
import { MobilePageContent, MobileRoutesProvider } from '@nocobase/plugin-mobile/client';
|
import { MobilePageContent, MobileRoutesProvider } from '@nocobase/plugin-mobile/client';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { mockApp } from '@nocobase/client/demo-utils';
|
import { mockApp } from '@nocobase/client/demo-utils';
|
||||||
|
|
||||||
@ -37,7 +37,7 @@ const app = mockApp({
|
|||||||
},
|
},
|
||||||
plugins: [DemoPlugin],
|
plugins: [DemoPlugin],
|
||||||
apis: {
|
apis: {
|
||||||
'mobileRoutes:list': {
|
'mobileRoutes:listAccessible': {
|
||||||
data: [],
|
data: [],
|
||||||
},
|
},
|
||||||
'uiSchemas:getJsonSchema/tab1': {
|
'uiSchemas:getJsonSchema/tab1': {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Plugin } from '@nocobase/client';
|
import { Plugin } from '@nocobase/client';
|
||||||
import { MobilePageContent, MobileRoutesProvider } from '@nocobase/plugin-mobile/client';
|
import { MobilePageContent, MobileRoutesProvider } from '@nocobase/plugin-mobile/client';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { mockApp } from '@nocobase/client/demo-utils';
|
import { mockApp } from '@nocobase/client/demo-utils';
|
||||||
|
|
||||||
@ -31,7 +31,7 @@ const app = mockApp({
|
|||||||
},
|
},
|
||||||
plugins: [DemoPlugin],
|
plugins: [DemoPlugin],
|
||||||
apis: {
|
apis: {
|
||||||
'mobileRoutes:list': {
|
'mobileRoutes:listAccessible': {
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
id: 28,
|
id: 28,
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import React from 'react';
|
import { Plugin } from '@nocobase/client';
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { mockApp } from '@nocobase/client/demo-utils';
|
import { mockApp } from '@nocobase/client/demo-utils';
|
||||||
import { SchemaComponent, Plugin } from '@nocobase/client';
|
|
||||||
import {
|
import {
|
||||||
MobilePageNavigationBar,
|
MobilePageNavigationBar,
|
||||||
MobilePageProvider,
|
MobilePageProvider,
|
||||||
@ -9,6 +7,8 @@ import {
|
|||||||
MobileRoutesProvider,
|
MobileRoutesProvider,
|
||||||
MobileTitleProvider,
|
MobileTitleProvider,
|
||||||
} from '@nocobase/plugin-mobile/client';
|
} from '@nocobase/plugin-mobile/client';
|
||||||
|
import React from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
const Demo = () => {
|
const Demo = () => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@ -51,7 +51,7 @@ const app = mockApp({
|
|||||||
},
|
},
|
||||||
plugins: [DemoPlugin],
|
plugins: [DemoPlugin],
|
||||||
apis: {
|
apis: {
|
||||||
'mobileRoutes:list': {
|
'mobileRoutes:listAccessible': {
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import React from 'react';
|
import { Plugin } from '@nocobase/client';
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { mockApp } from '@nocobase/client/demo-utils';
|
import { mockApp } from '@nocobase/client/demo-utils';
|
||||||
import { SchemaComponent, Plugin } from '@nocobase/client';
|
|
||||||
import {
|
import {
|
||||||
MobilePageNavigationBar,
|
MobilePageNavigationBar,
|
||||||
MobilePageProvider,
|
MobilePageProvider,
|
||||||
@ -9,6 +7,8 @@ import {
|
|||||||
MobileRoutesProvider,
|
MobileRoutesProvider,
|
||||||
MobileTitleProvider,
|
MobileTitleProvider,
|
||||||
} from '@nocobase/plugin-mobile/client';
|
} from '@nocobase/plugin-mobile/client';
|
||||||
|
import React from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
const Demo = () => {
|
const Demo = () => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@ -52,7 +52,7 @@ const app = mockApp({
|
|||||||
},
|
},
|
||||||
plugins: [DemoPlugin],
|
plugins: [DemoPlugin],
|
||||||
apis: {
|
apis: {
|
||||||
'mobileRoutes:list': {
|
'mobileRoutes:listAccessible': {
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
@ -7,8 +7,9 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { FC } from 'react';
|
import { css } from '@emotion/css';
|
||||||
import { Resizable } from 're-resizable';
|
import { Resizable } from 're-resizable';
|
||||||
|
import React, { FC } from 'react';
|
||||||
import { useSize } from './sizeContext';
|
import { useSize } from './sizeContext';
|
||||||
|
|
||||||
interface DesktopModeContentProps {
|
interface DesktopModeContentProps {
|
||||||
@ -28,6 +29,11 @@ export const DesktopModeContent: FC<DesktopModeContentProps> = ({ children }) =>
|
|||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
padding: 80,
|
padding: 80,
|
||||||
}}
|
}}
|
||||||
|
className={css`
|
||||||
|
.adm-error-block-full-page {
|
||||||
|
padding-top: 100px;
|
||||||
|
}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
<Resizable
|
<Resizable
|
||||||
style={{
|
style={{
|
||||||
|
@ -43,11 +43,16 @@ import {
|
|||||||
useMobileNavigationBarLink,
|
useMobileNavigationBarLink,
|
||||||
} from './pages';
|
} from './pages';
|
||||||
|
|
||||||
|
import PluginACLClient from '@nocobase/plugin-acl/client';
|
||||||
|
import { MenuPermissions, MobileAllRoutesProvider } from './MenuPermissions';
|
||||||
|
|
||||||
// 导出 JSBridge,会挂在到 window 上
|
// 导出 JSBridge,会挂在到 window 上
|
||||||
import './js-bridge';
|
import './js-bridge';
|
||||||
import { MobileSettings } from './mobile-blocks/settings-block/MobileSettings';
|
import { MobileSettings } from './mobile-blocks/settings-block/MobileSettings';
|
||||||
import { MobileSettingsBlockInitializer } from './mobile-blocks/settings-block/MobileSettingsBlockInitializer';
|
import { MobileSettingsBlockInitializer } from './mobile-blocks/settings-block/MobileSettingsBlockInitializer';
|
||||||
import { MobileSettingsBlockSchemaSettings } from './mobile-blocks/settings-block/schemaSettings';
|
import { MobileSettingsBlockSchemaSettings } from './mobile-blocks/settings-block/schemaSettings';
|
||||||
|
// @ts-ignore
|
||||||
|
import pkg from './../../package.json';
|
||||||
|
|
||||||
export * from './desktop-mode';
|
export * from './desktop-mode';
|
||||||
export * from './mobile';
|
export * from './mobile';
|
||||||
@ -105,6 +110,7 @@ export class PluginMobileClient extends Plugin {
|
|||||||
this.addInitializers();
|
this.addInitializers();
|
||||||
this.addSettings();
|
this.addSettings();
|
||||||
this.addScopes();
|
this.addScopes();
|
||||||
|
this.addPermissionsSettingsUI();
|
||||||
|
|
||||||
this.app.pluginSettingsManager.add('mobile', {
|
this.app.pluginSettingsManager.add('mobile', {
|
||||||
title: generatePluginTranslationTemplate('Mobile'),
|
title: generatePluginTranslationTemplate('Mobile'),
|
||||||
@ -240,6 +246,32 @@ export class PluginMobileClient extends Plugin {
|
|||||||
getRouterComponent() {
|
getRouterComponent() {
|
||||||
return this.mobileRouter.getRouterComponent();
|
return this.mobileRouter.getRouterComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addPermissionsSettingsUI() {
|
||||||
|
this.app.pm.get(PluginACLClient)?.settingsUI.addPermissionsTab(({ t, TabLayout, activeKey, currentUserRole }) => {
|
||||||
|
if (
|
||||||
|
currentUserRole &&
|
||||||
|
((!currentUserRole.snippets.includes('pm.mobile') && !currentUserRole.snippets.includes('pm.*')) ||
|
||||||
|
currentUserRole.snippets.includes('!pm.mobile'))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: 'mobile-menu',
|
||||||
|
label: t('Mobile menu', {
|
||||||
|
ns: pkg.name,
|
||||||
|
}),
|
||||||
|
children: (
|
||||||
|
<TabLayout>
|
||||||
|
<MobileAllRoutesProvider active={activeKey === 'mobile-menu'}>
|
||||||
|
<MenuPermissions active={activeKey === 'mobile-menu'} />
|
||||||
|
</MobileAllRoutesProvider>
|
||||||
|
</TabLayout>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PluginMobileClient;
|
export default PluginMobileClient;
|
||||||
|
@ -7,9 +7,9 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import pkg from './../../package.json';
|
import pkg from './../../package.json';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
export function usePluginTranslation() {
|
export function usePluginTranslation() {
|
||||||
return useTranslation([pkg.name, 'client'], { nsMode: 'fallback' });
|
return useTranslation([pkg.name, 'client'], { nsMode: 'fallback' });
|
||||||
|
@ -69,9 +69,9 @@ export const MobileTabBar: FC<MobileTabBarProps> & {
|
|||||||
styles.mobileTabBarList,
|
styles.mobileTabBarList,
|
||||||
css({
|
css({
|
||||||
maxWidth: designable ? 'calc(100% - 58px)' : '100%',
|
maxWidth: designable ? 'calc(100% - 58px)' : '100%',
|
||||||
'.nb-block-item': {
|
// '.nb-block-item': {
|
||||||
maxWidth: `${100 / routeList.length}%`,
|
// maxWidth: `${100 / routeList.length}%`,
|
||||||
},
|
// },
|
||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -82,7 +82,6 @@ export const MobileTabBar: FC<MobileTabBarProps> & {
|
|||||||
</DndContext>
|
</DndContext>
|
||||||
<MobileTabBarInitializer />
|
<MobileTabBarInitializer />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SafeArea position="bottom" />
|
<SafeArea position="bottom" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -33,6 +33,7 @@ export const useStyles = createStyles(() => ({
|
|||||||
justifyContent: 'space-around',
|
justifyContent: 'space-around',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
overflowX: 'auto',
|
||||||
'.adm-tab-bar-item': {
|
'.adm-tab-bar-item': {
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
'.adm-tab-bar-item-title': {
|
'.adm-tab-bar-item-title': {
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { FC, useEffect } from 'react';
|
import React, { FC, useEffect } from 'react';
|
||||||
|
import { ShowTipWhenNoPages } from '../ShowTipWhenNoPages';
|
||||||
import { MobileRoutesProvider, MobileTitleProvider } from './context';
|
import { MobileRoutesProvider, MobileTitleProvider } from './context';
|
||||||
|
|
||||||
export interface MobileProvidersProps {
|
export interface MobileProvidersProps {
|
||||||
@ -23,7 +23,9 @@ export const MobileProviders: FC<MobileProvidersProps> = ({ children }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileTitleProvider>
|
<MobileTitleProvider>
|
||||||
<MobileRoutesProvider>{children}</MobileRoutesProvider>
|
<MobileRoutesProvider>
|
||||||
|
<ShowTipWhenNoPages>{children}</ShowTipWhenNoPages>
|
||||||
|
</MobileRoutesProvider>
|
||||||
</MobileTitleProvider>
|
</MobileTitleProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { APIClient, useAPIClient, useRequest } from '@nocobase/client';
|
import { APIClient, useAPIClient, useRequest } from '@nocobase/client';
|
||||||
import { Spin } from 'antd';
|
import { Spin } from 'antd';
|
||||||
import React, { createContext, useContext, useEffect, useMemo } from 'react';
|
import React, { createContext, FC, useContext, useEffect, useMemo } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import type { IResource } from '@nocobase/sdk';
|
import type { IResource } from '@nocobase/sdk';
|
||||||
@ -88,7 +88,17 @@ function useTitle(activeTabBar: MobileRouteItem) {
|
|||||||
}, [activeTabBar, context]);
|
}, [activeTabBar, context]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MobileRoutesProvider = ({ children }) => {
|
export const MobileRoutesProvider: FC<{
|
||||||
|
/**
|
||||||
|
* list: return all route data, and only administrators can access;
|
||||||
|
* listAccessible: return the route data that the current user can access;
|
||||||
|
*
|
||||||
|
* @default 'listAccessible'
|
||||||
|
*/
|
||||||
|
action?: 'list' | 'listAccessible';
|
||||||
|
refreshRef?: any;
|
||||||
|
manual?: boolean;
|
||||||
|
}> = ({ children, refreshRef, manual, action = 'listAccessible' }) => {
|
||||||
const api = useAPIClient();
|
const api = useAPIClient();
|
||||||
const resource = useMemo(() => api.resource('mobileRoutes'), [api]);
|
const resource = useMemo(() => api.resource('mobileRoutes'), [api]);
|
||||||
const schemaResource = useMemo(() => api.resource('uiSchemas'), [api]);
|
const schemaResource = useMemo(() => api.resource('uiSchemas'), [api]);
|
||||||
@ -96,9 +106,17 @@ export const MobileRoutesProvider = ({ children }) => {
|
|||||||
data,
|
data,
|
||||||
runAsync: refresh,
|
runAsync: refresh,
|
||||||
loading,
|
loading,
|
||||||
} = useRequest<{ data: MobileRouteItem[] }>(() =>
|
} = useRequest<{ data: MobileRouteItem[] }>(
|
||||||
resource.list({ tree: true, sort: 'sort' }).then((res) => res.data),
|
() => resource[action]({ tree: true, sort: 'sort' }).then((res) => res.data),
|
||||||
|
{
|
||||||
|
manual,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (refreshRef) {
|
||||||
|
refreshRef.current = refresh;
|
||||||
|
}
|
||||||
|
|
||||||
const routeList = useMemo(() => data?.data || [], [data]);
|
const routeList = useMemo(() => data?.data || [], [data]);
|
||||||
const { activeTabBarItem, activeTabItem } = useActiveTabBar(routeList);
|
const { activeTabBarItem, activeTabItem } = useActiveTabBar(routeList);
|
||||||
|
|
||||||
|
@ -21,5 +21,8 @@
|
|||||||
"Icon field is required": "Icon field is required",
|
"Icon field is required": "Icon field is required",
|
||||||
"Desktop data blocks": "Desktop data blocks",
|
"Desktop data blocks": "Desktop data blocks",
|
||||||
"Other desktop blocks": "Other desktop blocks",
|
"Other desktop blocks": "Other desktop blocks",
|
||||||
"Settings": "Settings"
|
"Settings": "Settings",
|
||||||
|
"Mobile menu": "Mobile menu",
|
||||||
|
"No accessible pages found": "No accessible pages found",
|
||||||
|
"This might be due to permission configuration issues": "This might be due to permission configuration issues"
|
||||||
}
|
}
|
||||||
|
@ -22,5 +22,8 @@
|
|||||||
"Icon field is required": "图标必填",
|
"Icon field is required": "图标必填",
|
||||||
"Desktop data blocks": "桌面端数据区块",
|
"Desktop data blocks": "桌面端数据区块",
|
||||||
"Other desktop blocks": "其他桌面端区块",
|
"Other desktop blocks": "其他桌面端区块",
|
||||||
"Settings": "设置"
|
"Settings": "设置",
|
||||||
|
"Mobile menu": "移动端菜单",
|
||||||
|
"No accessible pages found": "没有找到你可以访问的页面",
|
||||||
|
"This might be due to permission configuration issues": "这可能是权限配置的问题"
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* This file is part of the NocoBase (R) project.
|
||||||
|
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||||
|
* Authors: NocoBase Team.
|
||||||
|
*
|
||||||
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||||
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { extendCollection } from '@nocobase/database';
|
||||||
|
|
||||||
|
export default extendCollection({
|
||||||
|
name: 'roles',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'belongsToMany',
|
||||||
|
name: 'mobileRoutes',
|
||||||
|
target: 'mobileRoutes',
|
||||||
|
through: 'rolesMobileRoutes',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'boolean',
|
||||||
|
name: 'allowNewMobileMenu',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
@ -294,6 +294,13 @@ export default defineCollection({
|
|||||||
title: 'sort',
|
title: 'sort',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'belongsToMany',
|
||||||
|
name: 'roles',
|
||||||
|
through: 'rolesMobileRoutes',
|
||||||
|
target: 'roles',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
category: [],
|
category: [],
|
||||||
logging: true,
|
logging: true,
|
||||||
|
@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* istanbul ignore file -- @preserve */
|
||||||
|
|
||||||
|
import { Migration } from '@nocobase/server';
|
||||||
|
|
||||||
|
export default class extends Migration {
|
||||||
|
appVersion = '<1.4.0-beta';
|
||||||
|
|
||||||
|
async up() {
|
||||||
|
await this.db.sequelize.transaction(async (transaction) => {
|
||||||
|
const mobileRoutes = await this.db.getRepository('mobileRoutes').find({
|
||||||
|
sort: 'sort',
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
const roles = await this.db.getRepository('roles').find({
|
||||||
|
filter: {
|
||||||
|
'allowNewMobileMenu.$isFalsy': true,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
const mobileRouteIds = mobileRoutes.map((item) => item.get('id'));
|
||||||
|
for (const role of roles) {
|
||||||
|
// 如果是 false,不处理
|
||||||
|
if (role.allowNewMobileMenu === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
role.allowNewMobileMenu = true;
|
||||||
|
await role.save({ transaction });
|
||||||
|
await this.db.getRepository<any>('roles.mobileRoutes', role.get('name')).add({
|
||||||
|
tk: mobileRouteIds,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async down() {}
|
||||||
|
}
|
@ -7,21 +7,83 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Model } from '@nocobase/database';
|
||||||
import { Plugin } from '@nocobase/server';
|
import { Plugin } from '@nocobase/server';
|
||||||
|
|
||||||
export class PluginMobileServer extends Plugin {
|
export class PluginMobileServer extends Plugin {
|
||||||
async load() {
|
async load() {
|
||||||
|
this.registerActionHandlers();
|
||||||
|
this.bindNewMenuToRoles();
|
||||||
|
this.setACL();
|
||||||
|
}
|
||||||
|
|
||||||
|
setACL() {
|
||||||
this.app.acl.registerSnippet({
|
this.app.acl.registerSnippet({
|
||||||
name: `ui.${this.name}`,
|
name: `ui.mobile`,
|
||||||
actions: ['mobileRoutes:create', 'mobileRoutes:update', 'mobileRoutes:destroy'],
|
actions: ['mobileRoutes:create', 'mobileRoutes:update', 'mobileRoutes:destroy'],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.app.acl.registerSnippet({
|
this.app.acl.registerSnippet({
|
||||||
name: `pm.${this.name}`,
|
name: `pm.mobile`,
|
||||||
actions: ['mobileRoutes:list'],
|
actions: ['mobileRoutes:list', 'roles.mobileRoutes:*'],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.app.acl.allow('mobileRoutes', 'list', 'loggedIn');
|
this.app.acl.allow('mobileRoutes', 'listAccessible', 'loggedIn');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* used to implement: roles with permission (allowNewMobileMenu is true) can directly access the newly created menu
|
||||||
|
*/
|
||||||
|
bindNewMenuToRoles() {
|
||||||
|
this.app.db.on('roles.beforeCreate', async (instance: Model) => {
|
||||||
|
instance.set('allowNewMobileMenu', ['admin', 'member'].includes(instance.name));
|
||||||
|
});
|
||||||
|
this.app.db.on('mobileRoutes.afterCreate', async (instance: Model, { transaction }) => {
|
||||||
|
const addNewMenuRoles = await this.app.db.getRepository('roles').find({
|
||||||
|
filter: {
|
||||||
|
allowNewMobileMenu: true,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
await this.app.db.getRepository('mobileRoutes.roles', instance.id).add({
|
||||||
|
tk: addNewMenuRoles.map((role) => role.name),
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
registerActionHandlers() {
|
||||||
|
this.app.resourceManager.registerActionHandler('mobileRoutes:listAccessible', async (ctx, next) => {
|
||||||
|
const mobileRoutesRepository = ctx.db.getRepository('mobileRoutes');
|
||||||
|
const rolesRepository = ctx.db.getRepository('roles');
|
||||||
|
|
||||||
|
if (ctx.state.currentRole === 'root') {
|
||||||
|
ctx.body = await mobileRoutesRepository.find({
|
||||||
|
tree: true,
|
||||||
|
...ctx.query,
|
||||||
|
});
|
||||||
|
return await next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await rolesRepository.findOne({
|
||||||
|
filterByTk: ctx.state.currentRole,
|
||||||
|
appends: ['mobileRoutes'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mobileRoutesId = role.get('mobileRoutes').map((item) => item.id);
|
||||||
|
|
||||||
|
ctx.body = await mobileRoutesRepository.find({
|
||||||
|
tree: true,
|
||||||
|
...ctx.query,
|
||||||
|
filter: {
|
||||||
|
id: mobileRoutesId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,6 +102,7 @@
|
|||||||
"@nocobase/plugin-gantt",
|
"@nocobase/plugin-gantt",
|
||||||
"@nocobase/plugin-kanban",
|
"@nocobase/plugin-kanban",
|
||||||
"@nocobase/plugin-logger",
|
"@nocobase/plugin-logger",
|
||||||
|
"@nocobase/plugin-mobile",
|
||||||
"@nocobase/plugin-system-settings",
|
"@nocobase/plugin-system-settings",
|
||||||
"@nocobase/plugin-ui-schema-storage",
|
"@nocobase/plugin-ui-schema-storage",
|
||||||
"@nocobase/plugin-user-data-sync",
|
"@nocobase/plugin-user-data-sync",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user