@nocobase/client-v2 2.3.0-beta.20 → 2.3.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.3.0-beta.20",
3
+ "version": "2.3.0-beta.21",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -27,11 +27,11 @@
27
27
  "@formily/antd-v5": "1.2.3",
28
28
  "@formily/react": "^2.2.27",
29
29
  "@formily/shared": "^2.2.27",
30
- "@nocobase/evaluators": "2.3.0-beta.20",
31
- "@nocobase/flow-engine": "2.3.0-beta.20",
32
- "@nocobase/sdk": "2.3.0-beta.20",
33
- "@nocobase/shared": "2.3.0-beta.20",
34
- "@nocobase/utils": "2.3.0-beta.20",
30
+ "@nocobase/evaluators": "2.3.0-beta.21",
31
+ "@nocobase/flow-engine": "2.3.0-beta.21",
32
+ "@nocobase/sdk": "2.3.0-beta.21",
33
+ "@nocobase/shared": "2.3.0-beta.21",
34
+ "@nocobase/utils": "2.3.0-beta.21",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -49,5 +49,5 @@
49
49
  "react-router-dom": "^6.30.1",
50
50
  "zxing-wasm": "^3.1.3"
51
51
  },
52
- "gitHead": "31adfe1b6bd3136bec29ff936fe1a0a7b5273d67"
52
+ "gitHead": "a2bdd27cc7bc8ced9ad395b3a7468fc1850eef4a"
53
53
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { createContext } from 'react';
11
+
12
+ // Internal callback for dismissing the mobile topbar after a nested menu action completes.
13
+ export const MobileTopbarActionsContext = createContext<(() => void) | undefined>(undefined);
@@ -11,13 +11,14 @@ import { EllipsisOutlined } from '@ant-design/icons';
11
11
  import { css } from '@emotion/css';
12
12
  import { FlowModelRenderer } from '@nocobase/flow-engine';
13
13
  import { ConfigProvider, Divider, Popover, theme } from 'antd';
14
- import React, { useCallback, useMemo, useState } from 'react';
14
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
15
15
  import { ErrorBoundary } from 'react-error-boundary';
16
16
  import { useAclSnippets } from '../../../acl/useAclSnippets';
17
17
  import type { CustomToken } from '../../../theme';
18
18
  import { TopbarActionModel } from '../../models/topbar/TopbarActionModel';
19
19
  import { USER_CENTER_ACTION_ID } from '../../models/topbar/UserCenterTopbarActionModel';
20
20
  import { HelpLite } from './HelpLite';
21
+ import { MobileTopbarActionsContext } from './MobileTopbarActionsContext';
21
22
 
22
23
  const topbarActionsBarClassName = css`
23
24
  display: inline-flex;
@@ -183,11 +184,21 @@ export const TopbarActionsBar = React.memo((props: { actions: TopbarActionModel[
183
184
  setOpen(false);
184
185
  }, []);
185
186
 
187
+ useEffect(() => {
188
+ if (!props.isMobile) {
189
+ setOpen(false);
190
+ }
191
+ }, [props.isMobile]);
192
+
186
193
  if (props.isMobile) {
187
194
  return (
188
195
  <Popover
189
196
  rootClassName={popoverStyle}
190
- content={<TopbarActionsContent actions={props.actions} onActionClick={handleContentClick} />}
197
+ content={
198
+ <MobileTopbarActionsContext.Provider value={handleContentClick}>
199
+ <TopbarActionsContent actions={props.actions} onActionClick={handleContentClick} />
200
+ </MobileTopbarActionsContext.Provider>
201
+ }
191
202
  color={customToken.colorBgHeader}
192
203
  trigger="click"
193
204
  open={open}
@@ -0,0 +1,205 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
11
+ import { Drawer, Input } from 'antd';
12
+ import React, { useState } from 'react';
13
+ import { afterEach, describe, expect, it, vi } from 'vitest';
14
+
15
+ vi.mock('@nocobase/flow-engine', () => ({
16
+ FlowModel: class {
17
+ uid: string;
18
+ context = { t: (value: string) => value };
19
+
20
+ constructor(options: { uid: string }) {
21
+ this.uid = options.uid;
22
+ }
23
+ },
24
+ FlowModelRenderer: ({ model }: { model: { render: () => React.ReactNode } }) => model.render(),
25
+ observer: (component: React.ComponentType) => component,
26
+ }));
27
+
28
+ vi.mock('../../../models/topbar/TopbarActionModel', async () => {
29
+ const { FlowModel } = await import('@nocobase/flow-engine');
30
+ return {
31
+ TopbarActionModel: class extends FlowModel {
32
+ isHidden() {
33
+ return false;
34
+ }
35
+
36
+ getActionId() {
37
+ return 'user-center';
38
+ }
39
+
40
+ renderWrapper(node: React.ReactNode) {
41
+ return node;
42
+ }
43
+
44
+ renderButton() {
45
+ return <button data-testid="user-center-button">User center</button>;
46
+ }
47
+ },
48
+ };
49
+ });
50
+
51
+ vi.mock('../../../../acl/useAclSnippets', () => ({
52
+ useAclSnippets: () => ({ allow: () => true }),
53
+ }));
54
+
55
+ vi.mock('../HelpLite', () => ({ HelpLite: () => null }));
56
+
57
+ import {
58
+ UserCenterActionItemModel,
59
+ UserCenterSelectItemModel,
60
+ UserCenterTopbarActionModel,
61
+ } from '../../../models/topbar/UserCenterTopbarActionModel';
62
+ import { TopbarActionsBar } from '../TopbarActionsBar';
63
+
64
+ afterEach(cleanup);
65
+
66
+ function createUserCenter(onClick: () => Promise<void>, closeOnClick = true) {
67
+ const item = new UserCenterActionItemModel({ uid: 'test-edit-profile', use: 'UserCenterActionItemModel' });
68
+ item.label = 'Edit profile';
69
+ item.onClick = onClick;
70
+ item.closeOnClick = closeOnClick;
71
+ const model = new UserCenterTopbarActionModel({ uid: 'test-user-center', use: 'UserCenterTopbarActionModel' });
72
+ vi.spyOn(model, 'getPreparedItems').mockImplementation(async (closeDropdown) => {
73
+ item.setCloseDropdown(closeDropdown);
74
+ return [item];
75
+ });
76
+ return model;
77
+ }
78
+
79
+ async function openUserMenu(isMobile: boolean) {
80
+ if (isMobile) {
81
+ fireEvent.click(screen.getByRole('img', { name: 'ellipsis' }));
82
+ }
83
+ fireEvent.mouseEnter(screen.getByTestId('user-center-button'));
84
+ const action = await screen.findByRole('button', { name: 'Edit profile' });
85
+ await waitFor(() => expect(action).toBeVisible());
86
+ return action;
87
+ }
88
+
89
+ describe('mobile user center popover', () => {
90
+ it('closes both menus after selecting an action and keeps the opened drawer mounted', async () => {
91
+ const model = createUserCenter(vi.fn());
92
+ const Test = () => {
93
+ const [open, setOpen] = useState(false);
94
+ model.getPreparedItems = async (closeDropdown) => {
95
+ const item = new UserCenterActionItemModel({ uid: 'profile', use: 'UserCenterActionItemModel' });
96
+ item.label = 'Edit profile';
97
+ item.onClick = async () => setOpen(true);
98
+ item.setCloseDropdown(closeDropdown);
99
+ return [item];
100
+ };
101
+ return (
102
+ <>
103
+ <TopbarActionsBar actions={[model]} isMobile />
104
+ <Drawer open={open} title="Edit profile">
105
+ <Input aria-label="Nickname" />
106
+ </Drawer>
107
+ </>
108
+ );
109
+ };
110
+ render(<Test />);
111
+
112
+ fireEvent.click(await openUserMenu(true));
113
+
114
+ await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument());
115
+ expect(screen.getByRole('textbox', { name: 'Nickname' })).toBeVisible();
116
+ expect(screen.queryByRole('button', { name: 'Edit profile' })).not.toBeInTheDocument();
117
+
118
+ fireEvent.click(screen.getByRole('img', { name: 'ellipsis' }));
119
+ fireEvent.mouseEnter(screen.getByTestId('user-center-button'));
120
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Edit profile' })).toBeVisible());
121
+ });
122
+
123
+ it('respects actions that keep the menu open', async () => {
124
+ const onClick = vi.fn(async () => {});
125
+ render(<TopbarActionsBar actions={[createUserCenter(onClick, false)]} isMobile />);
126
+
127
+ fireEvent.click(await openUserMenu(true));
128
+
129
+ await waitFor(() => expect(onClick).toHaveBeenCalledOnce());
130
+ expect(screen.getByRole('tooltip')).toBeVisible();
131
+ expect(screen.getByRole('button', { name: 'Edit profile' })).toBeVisible();
132
+ });
133
+
134
+ it('keeps the topbar open when the user only leaves the nested menu', async () => {
135
+ render(<TopbarActionsBar actions={[createUserCenter(vi.fn())]} isMobile />);
136
+ const action = await openUserMenu(true);
137
+
138
+ const dropdown = action.closest('.ant-dropdown');
139
+ expect(dropdown).not.toBeNull();
140
+ if (dropdown) {
141
+ fireEvent.mouseLeave(dropdown);
142
+ }
143
+ fireEvent.mouseLeave(screen.getByTestId('user-center-button'));
144
+
145
+ await waitFor(() => expect(screen.queryByRole('button', { name: 'Edit profile' })).not.toBeInTheDocument());
146
+ expect(screen.getByRole('tooltip')).toBeVisible();
147
+ });
148
+
149
+ it('keeps desktop actions working without a mobile popover', async () => {
150
+ const onClick = vi.fn(async () => {});
151
+ render(<TopbarActionsBar actions={[createUserCenter(onClick)]} />);
152
+
153
+ fireEvent.click(await openUserMenu(false));
154
+
155
+ await waitFor(() => expect(screen.queryByRole('button', { name: 'Edit profile' })).not.toBeInTheDocument());
156
+ expect(onClick).toHaveBeenCalledOnce();
157
+ expect(screen.queryByRole('img', { name: 'ellipsis' })).not.toBeInTheDocument();
158
+ });
159
+
160
+ it.each([true, false])('respects closeOnChange=%s for preference selectors', async (closeOnChange) => {
161
+ const onChange = vi.fn(async () => {});
162
+ const item = new UserCenterSelectItemModel({ uid: 'language', use: 'UserCenterSelectItemModel' });
163
+ item.label = 'Language';
164
+ item.value = 'en';
165
+ item.options = [
166
+ { label: 'English', value: 'en' },
167
+ { label: 'Chinese', value: 'zh' },
168
+ ];
169
+ item.onChange = onChange;
170
+ item.closeOnChange = closeOnChange;
171
+ const model = createUserCenter(vi.fn());
172
+ model.getPreparedItems = async (closeDropdown) => {
173
+ item.setCloseDropdown(closeDropdown);
174
+ return [item];
175
+ };
176
+ render(<TopbarActionsBar actions={[model]} isMobile />);
177
+ fireEvent.click(screen.getByRole('img', { name: 'ellipsis' }));
178
+ fireEvent.mouseEnter(screen.getByTestId('user-center-button'));
179
+ const select = await screen.findByRole('combobox');
180
+ await waitFor(() => expect(select.closest('.ant-select')).toBeVisible());
181
+
182
+ fireEvent.mouseDown(select);
183
+ fireEvent.click(await screen.findByText('Chinese'));
184
+
185
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith('zh'));
186
+ if (closeOnChange) {
187
+ await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument());
188
+ } else {
189
+ expect(screen.getByRole('tooltip')).toBeVisible();
190
+ expect(select.closest('.ant-select')).toBeVisible();
191
+ }
192
+ });
193
+
194
+ it('does not reopen the previous popover after switching to desktop and back', async () => {
195
+ const actions = [createUserCenter(vi.fn())];
196
+ const { rerender } = render(<TopbarActionsBar actions={actions} isMobile />);
197
+ fireEvent.click(screen.getByRole('img', { name: 'ellipsis' }));
198
+ await waitFor(() => expect(screen.getByRole('tooltip')).toBeVisible());
199
+
200
+ rerender(<TopbarActionsBar actions={actions} isMobile={false} />);
201
+ rerender(<TopbarActionsBar actions={actions} isMobile />);
202
+
203
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
204
+ });
205
+ });
@@ -751,7 +751,10 @@ async function originalHandler(ctx, params) {
751
751
 
752
752
  const targetInterface = targetLabelField.getInterfaceOptions();
753
753
 
754
- const operator = targetInterface?.filterable?.operators?.[0]?.value || '$includes';
754
+ const operator =
755
+ targetLabelField.type === 'string' || targetLabelField.dataType === 'string'
756
+ ? '$includes'
757
+ : targetInterface?.filterable?.operators?.[0]?.value || '$includes';
755
758
 
756
759
  const searchText = ctx.inputArgs.searchText?.trim();
757
760
  const resource = ctx.model.resource;
@@ -590,7 +590,10 @@ async function originalHandler(ctx, params) {
590
590
 
591
591
  const targetInterface = targetLabelField.getInterfaceOptions();
592
592
 
593
- const operator = targetInterface?.filterable?.operators?.[0]?.value || '$includes';
593
+ const operator =
594
+ targetLabelField.type === 'string' || targetLabelField.dataType === 'string'
595
+ ? '$includes'
596
+ : targetInterface?.filterable?.operators?.[0]?.value || '$includes';
594
597
 
595
598
  const searchText = ctx.inputArgs.searchText?.trim();
596
599
 
@@ -0,0 +1,113 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { CollectionField } from '@nocobase/flow-engine';
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
12
+ import '../../../index';
13
+ import { createTypedFilterable } from '../../../../../collection-manager/filter-operators';
14
+ import { CascadeSelectInnerFieldModel } from '../CascadeSelectFieldModel';
15
+ import { RecordSelectFieldModel } from '../RecordSelectFieldModel';
16
+
17
+ function createContext(fieldOptions: Record<string, unknown>, searchText = ' 3 ') {
18
+ const field = new CollectionField({ name: 'display_name', ...fieldOptions });
19
+ const filterable = createTypedFilterable<{ dataType?: string }>(
20
+ [
21
+ { types: ['boolean'], operators: 'boolean' },
22
+ { types: ['string'], operators: 'string' },
23
+ { types: ['integer'], operators: 'number' },
24
+ ],
25
+ (meta) => meta.dataType || 'string',
26
+ );
27
+ vi.spyOn(field, 'getInterfaceOptions').mockReturnValue({ filterable });
28
+ const records = [{ id: 3, display_name: '3 Jackson Hughes' }];
29
+ const resource = {
30
+ setPage: vi.fn(),
31
+ addFilterGroup: vi.fn(),
32
+ removeFilterGroup: vi.fn(),
33
+ refresh: vi.fn().mockResolvedValue(undefined),
34
+ getData: vi.fn(() => records),
35
+ };
36
+ return {
37
+ field,
38
+ inputArgs: { searchText },
39
+ model: {
40
+ collectionField: { targetCollection: { getField: vi.fn(() => field) } },
41
+ props: { fieldNames: { label: 'display_name', value: 'id' } },
42
+ resource,
43
+ setDataSource: vi.fn(),
44
+ setProps: vi.fn(),
45
+ },
46
+ };
47
+ }
48
+
49
+ describe.each([
50
+ { name: 'RecordSelect', model: RecordSelectFieldModel, searchGroup: '__search__display_name' },
51
+ { name: 'CascadeSelect', model: CascadeSelectInnerFieldModel, searchGroup: 'display_name' },
52
+ ])('$name search', ({ model, searchGroup }) => {
53
+ beforeEach(() => vi.useFakeTimers());
54
+ afterEach(() => {
55
+ vi.clearAllTimers();
56
+ vi.useRealTimers();
57
+ vi.restoreAllMocks();
58
+ });
59
+
60
+ async function search(ctx: ReturnType<typeof createContext>) {
61
+ const handler = model.globalFlowRegistry.getFlow('searchSettings').getStep('step1').serialize().handler as (
62
+ ctx: ReturnType<typeof createContext>,
63
+ params: Record<string, unknown>,
64
+ ) => unknown;
65
+ handler(ctx, {});
66
+ await vi.advanceTimersByTimeAsync(500);
67
+ }
68
+
69
+ it.each([{ type: 'formula', dataType: 'string' }, { type: 'string' }])(
70
+ 'uses partial text matching for $type labels',
71
+ async (options) => {
72
+ const ctx = createContext(options);
73
+
74
+ await search(ctx);
75
+
76
+ expect(ctx.model.resource.addFilterGroup).toHaveBeenCalledWith(searchGroup, { 'display_name.$includes': '3' });
77
+ expect(ctx.model.resource.setPage).toHaveBeenCalledWith(1);
78
+ expect(ctx.model.resource.refresh).toHaveBeenCalledOnce();
79
+ expect(ctx.model.setDataSource).toHaveBeenCalledWith([
80
+ expect.objectContaining({ display_name: '3 Jackson Hughes' }),
81
+ ]);
82
+ expect(ctx.model.setProps).toHaveBeenCalledWith({ searchText: '3' });
83
+ },
84
+ );
85
+
86
+ it('preserves the interface operator for non-string labels', async () => {
87
+ const ctx = createContext({ type: 'integer' });
88
+ vi.spyOn(ctx.field, 'getInterfaceOptions').mockReturnValue({ filterable: { operators: [{ value: '$eq' }] } });
89
+
90
+ await search(ctx);
91
+
92
+ expect(ctx.model.resource.addFilterGroup).toHaveBeenCalledWith(searchGroup, { 'display_name.$eq': '3' });
93
+ });
94
+
95
+ it('falls back to partial text matching without an interface', async () => {
96
+ const ctx = createContext({ type: 'string' });
97
+ vi.spyOn(ctx.field, 'getInterfaceOptions').mockReturnValue(undefined);
98
+
99
+ await search(ctx);
100
+
101
+ expect(ctx.model.resource.addFilterGroup).toHaveBeenCalledWith(searchGroup, { 'display_name.$includes': '3' });
102
+ });
103
+
104
+ it('removes the search filter when the search is cleared', async () => {
105
+ const ctx = createContext({ type: 'formula', dataType: 'string' }, ' ');
106
+
107
+ await search(ctx);
108
+
109
+ expect(ctx.model.resource.removeFilterGroup).toHaveBeenCalledWith(searchGroup);
110
+ expect(ctx.model.resource.addFilterGroup).not.toHaveBeenCalled();
111
+ expect(ctx.model.resource.refresh).toHaveBeenCalledOnce();
112
+ });
113
+ });
@@ -11,9 +11,10 @@ import { UserOutlined } from '@ant-design/icons';
11
11
  import { css, cx } from '@emotion/css';
12
12
  import { FlowModel, FlowModelRenderer, observer } from '@nocobase/flow-engine';
13
13
  import { Divider, Dropdown, Empty, Select, Spin, theme } from 'antd';
14
- import React, { useEffect, useState } from 'react';
14
+ import React, { useCallback, useContext, useEffect, useState } from 'react';
15
15
  import { ErrorBoundary } from 'react-error-boundary';
16
16
  import { createAclSnippetAllow } from '../../../acl/createAclSnippetAllow';
17
+ import { MobileTopbarActionsContext } from '../../admin-shell/admin-layout/MobileTopbarActionsContext';
17
18
  import { TopbarActionModel } from './TopbarActionModel';
18
19
 
19
20
  export const USER_CENTER_ACTION_ID = 'user-center';
@@ -452,6 +453,11 @@ const UserCenterTopbarActionView = observer(
452
453
  (props: { model: UserCenterTopbarActionModel }) => {
453
454
  const { model } = props;
454
455
  const [open, setOpen] = useState(false);
456
+ const closeMobileTopbar = useContext(MobileTopbarActionsContext);
457
+ const closeDropdown = useCallback(() => {
458
+ setOpen(false);
459
+ closeMobileTopbar?.();
460
+ }, [closeMobileTopbar]);
455
461
 
456
462
  return model.renderWrapper(
457
463
  <Dropdown
@@ -459,9 +465,7 @@ const UserCenterTopbarActionView = observer(
459
465
  placement="bottomRight"
460
466
  open={open}
461
467
  onOpenChange={setOpen}
462
- dropdownRender={() => (
463
- <UserCenterDropdownPanel model={model} open={open} closeDropdown={() => setOpen(false)} />
464
- )}
468
+ dropdownRender={() => <UserCenterDropdownPanel model={model} open={open} closeDropdown={closeDropdown} />}
465
469
  >
466
470
  <span className={userCenterTriggerClassName}>{model.renderButton()}</span>
467
471
  </Dropdown>,