@nocobase/client-v2 2.1.0-beta.43 → 2.1.0-beta.44

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.1.0-beta.43",
3
+ "version": "2.1.0-beta.44",
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.1.0-beta.43",
31
- "@nocobase/flow-engine": "2.1.0-beta.43",
32
- "@nocobase/sdk": "2.1.0-beta.43",
33
- "@nocobase/shared": "2.1.0-beta.43",
34
- "@nocobase/utils": "2.1.0-beta.43",
30
+ "@nocobase/evaluators": "2.1.0-beta.44",
31
+ "@nocobase/flow-engine": "2.1.0-beta.44",
32
+ "@nocobase/sdk": "2.1.0-beta.44",
33
+ "@nocobase/shared": "2.1.0-beta.44",
34
+ "@nocobase/utils": "2.1.0-beta.44",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -45,5 +45,5 @@
45
45
  "react-i18next": "^11.15.1",
46
46
  "react-router-dom": "^6.30.1"
47
47
  },
48
- "gitHead": "6d7750e2373bf2451d246de88cc1f62491685e18"
48
+ "gitHead": "540d4897b1696cc24c0754521b0b766978b4933c"
49
49
  }
@@ -55,6 +55,15 @@ const insertPositionToMethod = {
55
55
  afterEnd: 'insertAfter',
56
56
  } as const;
57
57
 
58
+ const omitMenuRuntimeProps = (props: Record<string, unknown> = {}) => {
59
+ const persistableProps = { ...props };
60
+ delete persistableProps.item;
61
+ delete persistableProps.dom;
62
+ delete persistableProps.options;
63
+ delete persistableProps.renderType;
64
+ return persistableProps;
65
+ };
66
+
58
67
  export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStructure> {
59
68
  private creationPersisted = false;
60
69
  private persistedStateHydrated = false;
@@ -261,6 +270,12 @@ export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStruc
261
270
  return currentFlowCount > 0 || initialFlowCount > 0;
262
271
  }
263
272
 
273
+ serialize(): ReturnType<FlowModel['serialize']> {
274
+ const data = super.serialize();
275
+ data.props = omitMenuRuntimeProps(data.props);
276
+ return data;
277
+ }
278
+
264
279
  setHidden(value: boolean) {
265
280
  const previous = this.hidden;
266
281
  super.setHidden(value);
@@ -698,7 +713,6 @@ AdminLayoutMenuItemModel.registerFlow({
698
713
  AdminLayoutMenuItemModel.registerFlow({
699
714
  key: 'menuSettings',
700
715
  title: 'Menu settings',
701
- manual: true,
702
716
  steps: {
703
717
  edit: {
704
718
  title: 'Edit',
@@ -950,6 +950,73 @@ describe('AdminLayoutModel menu items', () => {
950
950
  });
951
951
  });
952
952
 
953
+ it('should save menu linkage rules without serializing runtime render props', async () => {
954
+ type SerializedFlowModel = Record<string, unknown> & {
955
+ props?: Record<string, unknown>;
956
+ subModels?: unknown;
957
+ };
958
+ const save = vi.fn(
959
+ async (targetModel: { serialize: () => SerializedFlowModel }, options?: { onlyStepParams?: boolean }) => {
960
+ const data = targetModel.serialize();
961
+ if (options?.onlyStepParams) {
962
+ delete data.subModels;
963
+ }
964
+
965
+ expect(data.props?.item).toBeUndefined();
966
+ expect(data.props?.dom).toBeUndefined();
967
+ expect(data.props?.options).toBeUndefined();
968
+ expect(data.props?.renderType).toBeUndefined();
969
+ expect(() => JSON.stringify(data)).not.toThrow();
970
+ return data;
971
+ },
972
+ );
973
+ const updateRoute = vi.fn().mockResolvedValue(undefined);
974
+ engine.setModelRepository({ save } as Parameters<FlowEngine['setModelRepository']>[0]);
975
+ engine.context.routeRepository.updateRoute = updateRoute;
976
+
977
+ const route = createRoute();
978
+ const model = engine.createModel<AdminLayoutMenuItemModel>({
979
+ uid: 'menu-item-linkage-runtime-props',
980
+ use: AdminLayoutMenuItemModel,
981
+ props: {
982
+ route,
983
+ },
984
+ });
985
+ const item = {
986
+ name: 'Page 1',
987
+ path: '/admin/page-1',
988
+ _route: route,
989
+ _model: model,
990
+ };
991
+
992
+ model.setProps({
993
+ item,
994
+ dom: React.createElement('span', null, 'Page 1'),
995
+ renderType: 'item',
996
+ options: { collapsed: false },
997
+ });
998
+ model.setStepParams('menuSettings', 'linkageRules', {
999
+ value: [
1000
+ {
1001
+ key: 'r1',
1002
+ title: 'Hide menu item',
1003
+ enable: true,
1004
+ condition: { logic: '$and', items: [] },
1005
+ actions: [{ key: 'a1', name: 'linkageSetMenuItemProps', params: { value: 'hidden' } }],
1006
+ },
1007
+ ],
1008
+ });
1009
+
1010
+ await model.saveStepParams();
1011
+
1012
+ expect(save).toHaveBeenCalledWith(model, { onlyStepParams: true });
1013
+ expect(updateRoute).toHaveBeenCalledWith(1, {
1014
+ options: {
1015
+ hasPersistedMenuInstanceFlow: true,
1016
+ },
1017
+ });
1018
+ });
1019
+
953
1020
  it('should clear persisted menu linkage rules when no persisted state remains', async () => {
954
1021
  const saveModel = vi.spyOn(engine, 'saveModel').mockResolvedValue(undefined as any);
955
1022
  const destroy = vi.fn().mockResolvedValue(true);
@@ -1006,7 +1006,7 @@ export class GridModel<T extends { subModels: { items: FlowModel[] } } = Default
1006
1006
  <Space
1007
1007
  ref={this.gridContainerRef}
1008
1008
  direction={'vertical'}
1009
- style={{ width: '100%', marginBottom: this.props.rowGap }}
1009
+ style={{ width: '100%', marginBottom: this.props.rowGap, display: 'flex' }}
1010
1010
  size={this.props.rowGap}
1011
1011
  >
1012
1012
  <DndProvider
@@ -49,7 +49,11 @@ export class ChildPageModel extends PageModel {
49
49
  throw new Error('Invalid drag event');
50
50
  }
51
51
 
52
- this.flowEngine.moveModel(event.active.id, event.over.id);
52
+ if (event.active.id === event.over.id) {
53
+ return;
54
+ }
55
+
56
+ await this.flowEngine.moveModel(event.active.id, event.over.id);
53
57
  }
54
58
 
55
59
  render() {
@@ -107,6 +107,18 @@ describe('ChildPageModel', () => {
107
107
  expect(mockFlowEngine.moveModel).toHaveBeenCalledWith('active-model-id', 'over-model-id');
108
108
  });
109
109
 
110
+ it('should ignore self-drop', async () => {
111
+ await childPageModel.handleDragEnd({
112
+ ...mockDragEndEvent,
113
+ over: {
114
+ ...(mockDragEndEvent.over as NonNullable<DragEndEvent['over']>),
115
+ id: 'active-model-id',
116
+ },
117
+ });
118
+
119
+ expect(mockFlowEngine.moveModel).not.toHaveBeenCalled();
120
+ });
121
+
110
122
  it('should handle case when over is null', async () => {
111
123
  const eventWithNullOver = {
112
124
  ...mockDragEndEvent,
@@ -0,0 +1,53 @@
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 { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
11
+ import { render } from '@testing-library/react';
12
+ import React from 'react';
13
+ import { beforeEach, describe, expect, it } from 'vitest';
14
+ import { GridModel } from '../GridModel';
15
+
16
+ describe('GridModel.render', () => {
17
+ let engine: FlowEngine;
18
+
19
+ beforeEach(async () => {
20
+ engine = new FlowEngine();
21
+ engine.registerModels({ GridModel });
22
+ await engine.flowSettings.disable();
23
+ });
24
+
25
+ it('renders the Space wrapper as block-level flex to avoid inline baseline spacing', () => {
26
+ const item = engine.createModel({ use: 'FlowModel', uid: 'item-1' });
27
+ const model = engine.createModel<GridModel>({
28
+ use: 'GridModel',
29
+ uid: 'grid-render',
30
+ props: {
31
+ rows: {
32
+ row1: [['item-1']],
33
+ },
34
+ sizes: {
35
+ row1: [24],
36
+ },
37
+ rowGap: 16,
38
+ },
39
+ structure: {} as any,
40
+ });
41
+ (model as any).subModels = { items: [item] };
42
+
43
+ expect(Object.keys((model as any).getVisibleLayout().rows)).toEqual(['row1']);
44
+
45
+ const { container } = render(<FlowEngineProvider engine={engine}>{model.render()}</FlowEngineProvider>);
46
+ const space = container.querySelector('.ant-space') as HTMLElement;
47
+
48
+ expect(space).toBeTruthy();
49
+ expect(space.style.width).toBe('100%');
50
+ expect(space.style.marginBottom).toBe('16px');
51
+ expect(space.style.display).toBe('flex');
52
+ });
53
+ });
@@ -221,6 +221,23 @@ describe('FlowModel core behaviors (collected)', () => {
221
221
  expect(i1).toEqual({ ping: 1 });
222
222
  });
223
223
 
224
+ it('unchanged stepParams do not trigger a beforeRender rerun', async () => {
225
+ vi.useFakeTimers();
226
+ const spy = vi.fn().mockResolvedValue([]);
227
+ (engine as any).executor.dispatchEvent = spy;
228
+ const model = engine.createModel<FlowModel>({
229
+ use: 'FlowModel',
230
+ uid: 'm-flow-3-noop',
231
+ stepParams: { anyFlow: { anyStep: { x: 1 } } },
232
+ });
233
+
234
+ await model.dispatchEvent('beforeRender', { ping: 1 });
235
+ model.setStepParams('anyFlow', 'anyStep', { x: 1 });
236
+ await vi.advanceTimersByTimeAsync(150);
237
+
238
+ expect(spy).toHaveBeenCalledTimes(1);
239
+ });
240
+
224
241
  it('applyFlow delegates to executor.runFlow', async () => {
225
242
  const spyRun = vi.fn().mockResolvedValue('ok');
226
243
  (engine as any).executor.runFlow = spyRun;
@@ -15,6 +15,7 @@ import { observable } from '@formily/reactive';
15
15
  import { get as lodashGet, merge as lodashMerge, set as lodashSet } from 'lodash';
16
16
  import { FlowContext, JSRunner } from '@nocobase/flow-engine';
17
17
  import { FormValueRuntime } from '..';
18
+ import type { FormInstance } from 'antd';
18
19
 
19
20
  function createFormStub(initialValues: any = {}) {
20
21
  const store: any = JSON.parse(JSON.stringify(initialValues || {}));
@@ -121,6 +122,34 @@ function createFieldContext(runtime: FormValueRuntime) {
121
122
  }
122
123
 
123
124
  describe('FormValueRuntime (default rules)', () => {
125
+ it('skips object patches when values are unchanged', async () => {
126
+ const engineEmitter = new EventEmitter();
127
+ const blockEmitter = new EventEmitter();
128
+ const formStub = createFormStub({ name: 'old' });
129
+ const dispatchEvent = vi.fn();
130
+
131
+ const blockModel = {
132
+ uid: 'form-noop-patch',
133
+ flowEngine: { emitter: engineEmitter },
134
+ emitter: blockEmitter,
135
+ dispatchEvent,
136
+ getAclActionName: () => 'create',
137
+ } as ConstructorParameters<typeof FormValueRuntime>[0]['model'];
138
+
139
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as unknown as FormInstance });
140
+ runtime.mount({ sync: true });
141
+
142
+ const blockCtx = createFieldContext(runtime);
143
+ await runtime.setFormValues(blockCtx, { name: 'old' }, { source: 'system' });
144
+
145
+ expect(dispatchEvent).not.toHaveBeenCalled();
146
+
147
+ await runtime.setFormValues(blockCtx, { name: 'new' }, { source: 'system' });
148
+
149
+ expect(formStub.getFieldValue(['name'])).toBe('new');
150
+ expect(dispatchEvent).toHaveBeenCalledTimes(1);
151
+ });
152
+
124
153
  it('recomputes default on dependency change when current equals last default; user change disables default permanently', async () => {
125
154
  const engineEmitter = new EventEmitter();
126
155
  const blockEmitter = new EventEmitter();
@@ -846,7 +846,11 @@ export class FormValueRuntime {
846
846
  const changedPaths: NamePath[] = [];
847
847
 
848
848
  if (!Array.isArray(patch)) {
849
- const patchEntries = Object.entries(patch || {}).filter(([pathKey]) => !shouldSkipByLinkageScope(pathKey));
849
+ const patchEntries = Object.entries(patch || {}).filter(([pathKey, rawValue]) => {
850
+ if (shouldSkipByLinkageScope(pathKey)) return false;
851
+ const value = isObservable(rawValue) ? toJS(rawValue) : rawValue;
852
+ return !_.isEqual(this.getFormValueAtPath([pathKey]), value);
853
+ });
850
854
  const patchToApply = Object.fromEntries(patchEntries);
851
855
  const patchKeys = patchEntries.map(([pathKey]) => pathKey);
852
856
  if (!patchKeys.length) {
@@ -10,7 +10,7 @@
10
10
  import { useFlowModelContext } from '@nocobase/flow-engine';
11
11
  import { Select } from 'antd';
12
12
  import type { CSSProperties } from 'react';
13
- import React, { useCallback, useEffect, useMemo, useState } from 'react';
13
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
14
14
  import { Button, CheckList, Popup, SearchBar, SpinLoading } from 'antd-mobile';
15
15
  import { css } from '@emotion/css';
16
16
  import {
@@ -51,7 +51,7 @@ function deriveRecordsFromValue(
51
51
  ) {
52
52
  if (isMultiple) {
53
53
  if (Array.isArray(value)) {
54
- return (value.filter(Boolean) as any[]).map((item) => {
54
+ return (value.filter((item) => item !== null && item !== undefined) as any[]).map((item) => {
55
55
  if (valueMode === 'value') {
56
56
  return optionMap.get(item) ?? { [valueKey]: item };
57
57
  }
@@ -113,11 +113,13 @@ export function MobileLazySelect(props: Readonly<LazySelectProps>) {
113
113
  );
114
114
 
115
115
  const [selectedRecords, setSelectedRecords] = useState<AssociationOption[]>(() => derivedRecords);
116
+ const wasVisibleRef = useRef(visible);
116
117
 
117
118
  useEffect(() => {
118
- if (visible) {
119
+ if (visible && !wasVisibleRef.current) {
119
120
  setSelectedRecords(derivedRecords);
120
121
  }
122
+ wasVisibleRef.current = visible;
121
123
  }, [derivedRecords, visible]);
122
124
 
123
125
  useEffect(() => {
@@ -10,6 +10,7 @@
10
10
  import React from 'react';
11
11
  import { beforeEach, describe, it, expect, vi } from 'vitest';
12
12
  import { act, fireEvent, render, screen } from '@nocobase/test/client';
13
+ import { MobileLazySelect } from '../MobileLazySelect';
13
14
  import { MobileSelect } from '../MobileSelect';
14
15
 
15
16
  const DEFAULT_OPTIONS = [
@@ -17,6 +18,11 @@ const DEFAULT_OPTIONS = [
17
18
  { label: 'Option B', value: 'b' },
18
19
  ];
19
20
 
21
+ const RELATION_OPTIONS = [
22
+ { uuid: '05f6a3b4-bfb7-7943-578a-3819e2687a7e' },
23
+ { uuid: 'c7d99828-a1de-9e70-4c2d-b0139abdf02e' },
24
+ ];
25
+
20
26
  const mockState = vi.hoisted(() => ({
21
27
  selectProps: undefined as any,
22
28
  popupProps: undefined as any,
@@ -44,6 +50,13 @@ function openPopup() {
44
50
  expect(screen.getByTestId('popup')).toBeInTheDocument();
45
51
  }
46
52
 
53
+ function openLazyPopup() {
54
+ act(() => {
55
+ mockState.selectProps?.onClick?.();
56
+ });
57
+ expect(screen.getByTestId('popup')).toBeInTheDocument();
58
+ }
59
+
47
60
  function selectValues(values: string[]) {
48
61
  act(() => {
49
62
  mockState.checklistProps?.onChange?.(values);
@@ -73,6 +86,30 @@ function renderMobileSelect(props: Record<string, any> = {}) {
73
86
  return { onChange, onChangeComplete };
74
87
  }
75
88
 
89
+ function renderMobileLazySelect(props: Record<string, any> = {}) {
90
+ const onChange = props.onChange ?? vi.fn();
91
+ const renderComponent = (nextProps: Record<string, any> = {}) => (
92
+ <MobileLazySelect
93
+ fieldNames={{ label: 'uuid', value: 'uuid' }}
94
+ value={[]}
95
+ multiple
96
+ allowMultiple
97
+ options={RELATION_OPTIONS}
98
+ {...props}
99
+ {...nextProps}
100
+ onChange={onChange}
101
+ />
102
+ );
103
+
104
+ const result = render(renderComponent());
105
+
106
+ return {
107
+ ...result,
108
+ onChange,
109
+ rerender: (nextProps: Record<string, any> = {}) => result.rerender(renderComponent(nextProps)),
110
+ };
111
+ }
112
+
76
113
  vi.mock('@nocobase/flow-engine', async () => {
77
114
  const actual = await vi.importActual<any>('@nocobase/flow-engine');
78
115
  return {
@@ -80,6 +117,12 @@ vi.mock('@nocobase/flow-engine', async () => {
80
117
  useFlowModelContext: () => ({
81
118
  t: (value: string) => value,
82
119
  }),
120
+ useFlowModel: () => ({
121
+ context: {
122
+ collectionField: {},
123
+ },
124
+ subModels: {},
125
+ }),
83
126
  };
84
127
  });
85
128
 
@@ -240,3 +283,30 @@ describe('MobileSelect in SubForm/SubTable containers', () => {
240
283
  expect(onCommit).toHaveBeenNthCalledWith(2, ['a', 'b']);
241
284
  });
242
285
  });
286
+
287
+ describe('MobileLazySelect', () => {
288
+ beforeEach(() => {
289
+ resetMockState();
290
+ });
291
+
292
+ it('keeps pending relation records selected until confirm', () => {
293
+ const { onChange, rerender } = renderMobileLazySelect();
294
+
295
+ openLazyPopup();
296
+ expect(mockState.checklistProps?.value).toEqual([]);
297
+
298
+ selectValues(['c7d99828-a1de-9e70-4c2d-b0139abdf02e']);
299
+ expect(mockState.checklistProps?.value).toEqual(['c7d99828-a1de-9e70-4c2d-b0139abdf02e']);
300
+
301
+ rerender({
302
+ options: RELATION_OPTIONS.map((item) => ({ ...item })),
303
+ });
304
+
305
+ expect(mockState.checklistProps?.value).toEqual(['c7d99828-a1de-9e70-4c2d-b0139abdf02e']);
306
+
307
+ confirmSelection();
308
+
309
+ expect(onChange).toHaveBeenCalledTimes(1);
310
+ expect(onChange).toHaveBeenCalledWith([RELATION_OPTIONS[1]]);
311
+ });
312
+ });