@nocobase/client-v2 2.1.0-beta.42 → 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.
Files changed (33) hide show
  1. package/es/components/form/TypedVariableInput.d.ts +75 -0
  2. package/es/components/form/index.d.ts +1 -0
  3. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  4. package/es/flow/models/blocks/assign-form/assignFieldValuesFlow.d.ts +8 -0
  5. package/es/index.mjs +95 -95
  6. package/lib/index.js +105 -105
  7. package/package.json +7 -7
  8. package/src/components/README.md +48 -0
  9. package/src/components/README.zh-CN.md +48 -0
  10. package/src/components/form/TypedVariableInput.tsx +441 -0
  11. package/src/components/form/__tests__/TypedVariableInput.test.tsx +152 -0
  12. package/src/components/form/index.tsx +1 -0
  13. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +100 -0
  14. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +23 -1
  15. package/src/flow/actions/linkageRules.tsx +5 -4
  16. package/src/flow/actions/linkageRulesRefresh.tsx +2 -3
  17. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +15 -1
  18. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +67 -0
  19. package/src/flow/components/code-editor/__tests__/runjsCompletions.test.ts +272 -27
  20. package/src/flow/components/code-editor/runjsCompletions.ts +490 -9
  21. package/src/flow/models/actions/__tests__/AssignFormRefill.test.ts +90 -0
  22. package/src/flow/models/base/GridModel.tsx +1 -1
  23. package/src/flow/models/base/PageModel/ChildPageModel.tsx +5 -1
  24. package/src/flow/models/base/PageModel/__tests__/ChildPageModel.test.ts +12 -0
  25. package/src/flow/models/base/__tests__/GridModel.render.test.tsx +53 -0
  26. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +14 -3
  27. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +94 -2
  28. package/src/flow/models/blocks/assign-form/assignFieldValuesFlow.tsx +67 -13
  29. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  30. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +29 -0
  31. package/src/flow/models/blocks/form/value-runtime/runtime.ts +5 -1
  32. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +5 -3
  33. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +70 -0
@@ -0,0 +1,152 @@
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 { FlowContext, FlowContextProvider } from '@nocobase/flow-engine';
11
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
12
+ import React from 'react';
13
+ import { describe, expect, it, vi } from 'vitest';
14
+ import { TypedVariableInput } from '../TypedVariableInput';
15
+
16
+ function createContextWithEnv() {
17
+ const ctx = new FlowContext();
18
+ (ctx as any).t = (key: string) => key;
19
+
20
+ ctx.defineProperty('$env', {
21
+ value: { SMTP_PORT: 465, SECURE_FLAG: true },
22
+ meta: {
23
+ title: 'Env',
24
+ type: 'object',
25
+ properties: {
26
+ SMTP_PORT: { title: 'SMTP Port', type: 'number' },
27
+ SECURE_FLAG: { title: 'Secure flag', type: 'boolean' },
28
+ },
29
+ },
30
+ });
31
+
32
+ return ctx;
33
+ }
34
+
35
+ function renderWithCtx(ctx: FlowContext, node: React.ReactNode) {
36
+ return render(<FlowContextProvider context={ctx}>{node}</FlowContextProvider>);
37
+ }
38
+
39
+ describe('TypedVariableInput - constant rendering', () => {
40
+ it('renders an InputNumber for a numeric value when types=[number]', async () => {
41
+ const ctx = createContextWithEnv();
42
+ renderWithCtx(
43
+ ctx,
44
+ <TypedVariableInput
45
+ value={465}
46
+ types={[['number', { min: 1, max: 65535 }]]}
47
+ namespaces={['$env']}
48
+ onChange={() => undefined}
49
+ />,
50
+ );
51
+ const numberInput = await screen.findByDisplayValue('465');
52
+ expect(numberInput).toBeInTheDocument();
53
+ expect(numberInput.getAttribute('role')).toBe('spinbutton');
54
+ });
55
+
56
+ it('renders a boolean Select when value is true and types=[boolean]', async () => {
57
+ const ctx = createContextWithEnv();
58
+ renderWithCtx(
59
+ ctx,
60
+ <TypedVariableInput value={true} types={['boolean']} namespaces={['$env']} onChange={() => undefined} />,
61
+ );
62
+ await waitFor(() => {
63
+ // antd Select renders the chosen item label via a selection-item span
64
+ const item = screen.getByText('True');
65
+ expect(item).toBeInTheDocument();
66
+ });
67
+ });
68
+
69
+ it('renders the Null placeholder when value=null and nullable=true', async () => {
70
+ const ctx = createContextWithEnv();
71
+ renderWithCtx(
72
+ ctx,
73
+ <TypedVariableInput value={null} types={['number']} namespaces={['$env']} nullable onChange={() => undefined} />,
74
+ );
75
+ // Uses placeholder slot (not value) so antd's grey placeholder colour
76
+ // applies — see comment in TypedVariableInput.tsx.
77
+ const nullInput = await screen.findByPlaceholderText('<Null>');
78
+ expect(nullInput).toBeInTheDocument();
79
+ expect(nullInput.getAttribute('readonly')).not.toBeNull();
80
+ });
81
+ });
82
+
83
+ describe('TypedVariableInput - variable rendering', () => {
84
+ it('renders a labelled pill when value is a {{ $env.X }} expression', async () => {
85
+ const ctx = createContextWithEnv();
86
+ renderWithCtx(
87
+ ctx,
88
+ <TypedVariableInput
89
+ value="{{$env.SMTP_PORT}}"
90
+ types={['number']}
91
+ namespaces={['$env']}
92
+ onChange={() => undefined}
93
+ />,
94
+ );
95
+ await waitFor(() => {
96
+ const tag = screen.getByRole('button', { name: 'variable-tag' });
97
+ expect(tag.textContent).toContain('Env');
98
+ expect(tag.textContent).toContain('SMTP Port');
99
+ });
100
+ });
101
+
102
+ it('clears back to null when the close button is clicked (nullable=true)', async () => {
103
+ const ctx = createContextWithEnv();
104
+ const handleChange = vi.fn();
105
+ renderWithCtx(
106
+ ctx,
107
+ <TypedVariableInput
108
+ value="{{$env.SMTP_PORT}}"
109
+ types={['number']}
110
+ namespaces={['$env']}
111
+ nullable
112
+ onChange={handleChange}
113
+ />,
114
+ );
115
+ const clear = await screen.findByRole('button', { name: 'icon-close' });
116
+ fireEvent.click(clear);
117
+ expect(handleChange).toHaveBeenCalledWith(null);
118
+ });
119
+
120
+ it('clears back to default-of-first-type when nullable=false', async () => {
121
+ const ctx = createContextWithEnv();
122
+ const handleChange = vi.fn();
123
+ renderWithCtx(
124
+ ctx,
125
+ <TypedVariableInput
126
+ value="{{$env.SMTP_PORT}}"
127
+ types={['number']}
128
+ namespaces={['$env']}
129
+ nullable={false}
130
+ onChange={handleChange}
131
+ />,
132
+ );
133
+ const clear = await screen.findByRole('button', { name: 'icon-close' });
134
+ fireEvent.click(clear);
135
+ expect(handleChange).toHaveBeenCalledWith(0);
136
+ });
137
+ });
138
+
139
+ describe('TypedVariableInput - editor onChange propagation', () => {
140
+ it('forwards numeric edits via onChange', async () => {
141
+ const ctx = createContextWithEnv();
142
+ const handleChange = vi.fn();
143
+ renderWithCtx(ctx, <TypedVariableInput value={465} types={['number']} onChange={handleChange} />);
144
+ const numberInput = await screen.findByDisplayValue('465');
145
+ fireEvent.change(numberInput, { target: { value: '587' } });
146
+ // antd InputNumber may emit on blur; force blur to flush
147
+ fireEvent.blur(numberInput);
148
+ expect(handleChange).toHaveBeenCalled();
149
+ const lastCall = handleChange.mock.calls[handleChange.mock.calls.length - 1][0];
150
+ expect(lastCall).toBe(587);
151
+ });
152
+ });
@@ -16,4 +16,5 @@ export * from './FileSizeInput';
16
16
  export * from './JsonTextArea';
17
17
  export * from './PasswordInput';
18
18
  export * from './RemoteSelect';
19
+ export * from './TypedVariableInput';
19
20
  export * from './VariableInput';
@@ -0,0 +1,100 @@
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 { describe, expect, it, vi } from 'vitest';
11
+ import { actionLinkageRules } from '../linkageRules';
12
+
13
+ function createRule(actions: any[]) {
14
+ return {
15
+ key: 'r1',
16
+ title: 'r1',
17
+ enable: true,
18
+ condition: { logic: '$and', items: [] },
19
+ actions,
20
+ };
21
+ }
22
+
23
+ describe('linkageRules hidden state propagation', () => {
24
+ it('restores hidden state to same-target models when reverting to original props', async () => {
25
+ const blockModel = { uid: 'block-1' };
26
+
27
+ const hostModel: any = {
28
+ uid: 'host',
29
+ hidden: false,
30
+ context: { blockModel },
31
+ __allModels: [],
32
+ setProps: vi.fn(),
33
+ getFlow: vi.fn(() => ({})),
34
+ getStepParams: vi.fn((_flowKey: string, stepKey: string) => {
35
+ if (stepKey === 'init') return { fieldPath: 'org_m2o' };
36
+ return undefined;
37
+ }),
38
+ translate: (s: string) => s,
39
+ };
40
+
41
+ const sameTargetModel: any = {
42
+ uid: 'same-target',
43
+ hidden: false,
44
+ context: { blockModel },
45
+ setProps: vi.fn(),
46
+ getStepParams: vi.fn((_flowKey: string, stepKey: string) => {
47
+ if (stepKey === 'init') return { fieldPath: 'org_m2o' };
48
+ return undefined;
49
+ }),
50
+ translate: (s: string) => s,
51
+ };
52
+
53
+ const engine = {
54
+ forEachModel: (visitor: (m: any) => void) => {
55
+ [hostModel, sameTargetModel].forEach(visitor);
56
+ },
57
+ };
58
+
59
+ const ctx: any = {
60
+ flowKey: 'buttonSettings',
61
+ model: hostModel,
62
+ engine,
63
+ app: {
64
+ jsonLogic: {
65
+ apply: vi.fn(() => true),
66
+ },
67
+ },
68
+ resolveJsonTemplate: vi.fn(async (value: any) => value),
69
+ getAction: (name: string) => {
70
+ if (name !== 'linkageSetActionProps') return undefined;
71
+ return {
72
+ handler: async (_ctx: any, params: any) => {
73
+ params.setProps(hostModel, {
74
+ hiddenModel: params.value === 'hidden',
75
+ disabled: false,
76
+ hiddenText: false,
77
+ });
78
+ },
79
+ };
80
+ },
81
+ };
82
+
83
+ await actionLinkageRules.handler(ctx, {
84
+ value: [
85
+ createRule([
86
+ {
87
+ name: 'linkageSetActionProps',
88
+ params: { value: 'hidden' },
89
+ },
90
+ ]),
91
+ ],
92
+ });
93
+ expect(hostModel.hidden).toBe(true);
94
+ expect(sameTargetModel.hidden).toBe(true);
95
+
96
+ await actionLinkageRules.handler(ctx, { value: [] });
97
+ expect(hostModel.hidden).toBe(false);
98
+ expect(sameTargetModel.hidden).toBe(false);
99
+ });
100
+ });
@@ -46,7 +46,7 @@ describe('linkageRulesRefresh action', () => {
46
46
  expect(handler).not.toHaveBeenCalled();
47
47
  });
48
48
 
49
- it('runs linkage action on master model when forks are not mounted in runtime mode', async () => {
49
+ it('skips master model when forks can handle flow even if regular action buttons do not attach refs', async () => {
50
50
  const handler = vi.fn(async () => {});
51
51
  const model: any = {
52
52
  isFork: false,
@@ -74,6 +74,28 @@ describe('linkageRulesRefresh action', () => {
74
74
  flowKey: 'buttonSettings',
75
75
  });
76
76
 
77
+ expect(handler).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it('runs linkage action on master model when no forks exist in runtime mode', async () => {
81
+ const handler = vi.fn(async () => {});
82
+ const model: any = {
83
+ isFork: false,
84
+ forks: new Set(),
85
+ getFlow: vi.fn(() => ({})),
86
+ getStepParams: vi.fn(() => ({ value: ['master-runtime'] })),
87
+ };
88
+ const ctx: any = {
89
+ model,
90
+ resolveJsonTemplate: vi.fn(async (p: any) => p),
91
+ getAction: vi.fn(() => ({ handler })),
92
+ };
93
+
94
+ await linkageRulesRefresh.handler(ctx, {
95
+ actionName: 'actionLinkageRules',
96
+ flowKey: 'buttonSettings',
97
+ });
98
+
77
99
  expect(handler).toHaveBeenCalledWith(ctx, { value: ['master-runtime'] });
78
100
  });
79
101
 
@@ -2099,16 +2099,17 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
2099
2099
  mergedByUid.forEach((model: any, uid) => {
2100
2100
  const patchProps = mergedPropsByUid.get(uid) || {};
2101
2101
  const newProps = { ...model.__originalProps, ...patchProps };
2102
- const hasHiddenModelPatch = Object.prototype.hasOwnProperty.call(patchProps, 'hiddenModel');
2102
+ const prevHidden = !!model.hidden;
2103
+ const nextHidden = !!newProps.hiddenModel;
2103
2104
 
2104
2105
  model.setProps(_.omit(newProps, ['hiddenModel', 'value', 'hiddenText']));
2105
2106
  syncFieldOptionsToForks(model, patchProps);
2106
2107
  if (typeof model.setHidden === 'function') {
2107
2108
  model.setHidden(!!newProps.hiddenModel);
2108
2109
  } else {
2109
- model.hidden = !!newProps.hiddenModel;
2110
- if (hasHiddenModelPatch) {
2111
- hiddenStatePatches.push({ model, hidden: model.hidden });
2110
+ model.hidden = nextHidden;
2111
+ if (prevHidden !== nextHidden) {
2112
+ hiddenStatePatches.push({ model, hidden: nextHidden });
2112
2113
  }
2113
2114
  }
2114
2115
 
@@ -41,16 +41,15 @@ export const linkageRulesRefresh = defineAction({
41
41
  const flowSettingsEnabled = Boolean(
42
42
  (ctx as any)?.flowSettingsEnabled || (model as any)?.context?.flowSettingsEnabled,
43
43
  );
44
- const hasMountedForkWithFlow =
44
+ const hasForkWithFlow =
45
45
  !model?.isFork &&
46
46
  !!model?.forks?.size &&
47
47
  Array.from(model?.forks || []).some((fork: any) => {
48
48
  if (!fork || fork.disposed) return false;
49
- if (!fork?.context?.ref?.current) return false;
50
49
  return !!fork?.getFlow?.(flowKey);
51
50
  });
52
51
  const isMasterMounted = Boolean((model as any)?.context?.ref?.current);
53
- if (hasMountedForkWithFlow && !isMasterMounted && !flowSettingsEnabled) {
52
+ if (hasForkWithFlow && !isMasterMounted && !flowSettingsEnabled) {
54
53
  return;
55
54
  }
56
55
 
@@ -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);