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

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 (85) hide show
  1. package/es/RouteRepository.d.ts +6 -0
  2. package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
  3. package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
  4. package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
  5. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
  6. package/es/flow/components/filter/VariableFilterItem.d.ts +10 -1
  7. package/es/flow/internal/utils/quickEditDataScope.d.ts +48 -0
  8. package/es/flow/internal/utils/quickEditViewContainer.d.ts +27 -0
  9. package/es/flow/models/actions/LinkActionUtils.d.ts +1 -0
  10. package/es/flow/models/blocks/assign-form/assignFormValue.d.ts +29 -0
  11. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +1 -0
  12. package/es/index.mjs +79 -79
  13. package/lib/index.js +84 -84
  14. package/package.json +9 -8
  15. package/src/BaseApplication.tsx +2 -0
  16. package/src/RouteRepository.ts +14 -0
  17. package/src/__tests__/RouteRepository.test.ts +51 -0
  18. package/src/__tests__/app.test.tsx +13 -0
  19. package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
  20. package/src/collection-manager/field-configure.ts +2 -2
  21. package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
  22. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
  23. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
  24. package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
  25. package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
  26. package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
  27. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  28. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
  29. package/src/flow/components/FieldAssignValueInput.tsx +4 -0
  30. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
  31. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
  32. package/src/flow/components/filter/VariableFilterItem.tsx +14 -4
  33. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  34. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  35. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  36. package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
  37. package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
  38. package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
  39. package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
  40. package/src/flow/models/actions/JSItemActionModel.tsx +19 -3
  41. package/src/flow/models/actions/LinkActionModel.tsx +11 -9
  42. package/src/flow/models/actions/LinkActionUtils.ts +4 -2
  43. package/src/flow/models/actions/__tests__/JSItemActionModel.test.tsx +47 -1
  44. package/src/flow/models/actions/__tests__/LinkActionModel.test.ts +14 -0
  45. package/src/flow/models/base/GridModel.tsx +0 -1
  46. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
  47. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +40 -12
  48. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
  49. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.multipleSelect.test.tsx +216 -0
  50. package/src/flow/models/blocks/assign-form/__tests__/assignFormValue.test.ts +41 -0
  51. package/src/flow/models/blocks/assign-form/assignFormValue.ts +64 -0
  52. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
  53. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
  54. package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
  55. package/src/flow/models/blocks/form/FormBlockModel.tsx +26 -5
  56. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
  57. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +145 -0
  58. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
  59. package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
  60. package/src/flow/models/blocks/js-block/JSBlock.tsx +5 -1
  61. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +23 -1
  62. package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
  63. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
  64. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +7 -1
  65. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
  66. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +6 -3
  67. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -0
  68. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +47 -17
  69. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
  70. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.largeField.test.tsx +178 -0
  71. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
  72. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +26 -2
  73. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
  74. package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
  75. package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
  76. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  77. package/src/flow/models/fields/InputFieldModel.tsx +1 -1
  78. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  79. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  80. package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
  81. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +16 -0
  82. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
  83. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +1 -0
  84. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +20 -0
  85. package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
@@ -0,0 +1,74 @@
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 { act, render } from '@nocobase/test/client';
11
+ import { sanitizeRichTextHtml } from '@nocobase/utils/client';
12
+ import React from 'react';
13
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
14
+ import { RichTextField } from '..';
15
+
16
+ const mockState = vi.hoisted(() => ({
17
+ onChange: undefined as ((value: string) => void) | undefined,
18
+ value: undefined as unknown,
19
+ }));
20
+
21
+ vi.mock('../../../../../flow-compat', async (importOriginal) => {
22
+ const actual = await importOriginal<typeof import('../../../../../flow-compat')>();
23
+ return {
24
+ ...actual,
25
+ lazy: () => (props: { onChange?: (value: string) => void; value?: unknown }) => {
26
+ mockState.onChange = props.onChange;
27
+ mockState.value = props.value;
28
+ return null;
29
+ },
30
+ };
31
+ });
32
+
33
+ describe('RichTextField edit mode sanitization', () => {
34
+ beforeEach(() => {
35
+ mockState.onChange = undefined;
36
+ mockState.value = undefined;
37
+ });
38
+
39
+ it('sanitizes stored HTML before passing it to ReactQuill', () => {
40
+ const payload = '<p>safe</p><img src="https://example.com/image.png" onerror="alert(1)"><script>alert(1)</script>';
41
+
42
+ render(<RichTextField value={payload} onChange={vi.fn()} />);
43
+
44
+ expect(mockState.value).toBe(sanitizeRichTextHtml(payload));
45
+ });
46
+
47
+ it('preserves Delta values', () => {
48
+ const delta = { ops: [{ insert: 'safe\n' }] };
49
+
50
+ render(<RichTextField value={delta} onChange={vi.fn()} />);
51
+
52
+ expect(mockState.value).toBe(delta);
53
+ });
54
+
55
+ it('does not normalize active editor feedback', () => {
56
+ const editorValue = '<p>Hello<br>World</p>';
57
+ expect(sanitizeRichTextHtml(editorValue)).not.toBe(editorValue);
58
+
59
+ const { rerender } = render(<RichTextField value="" onChange={vi.fn()} />);
60
+ act(() => mockState.onChange?.(editorValue));
61
+ rerender(<RichTextField value={editorValue} onChange={vi.fn()} />);
62
+
63
+ expect(mockState.value).toBe(editorValue);
64
+ });
65
+
66
+ it('sanitizes subsequent external values', () => {
67
+ const payload = '<img src=x onerror="alert(1)">';
68
+ const { rerender } = render(<RichTextField value="" onChange={vi.fn()} />);
69
+
70
+ rerender(<RichTextField value={payload} onChange={vi.fn()} />);
71
+
72
+ expect(mockState.value).toBe(sanitizeRichTextHtml(payload));
73
+ });
74
+ });
@@ -9,6 +9,8 @@
9
9
 
10
10
  import React from 'react';
11
11
  import { largeField, EditableItemModel } from '@nocobase/flow-engine';
12
+ import { sanitizeRichTextHtml } from '@nocobase/utils/client';
13
+ import type ReactQuillComponent from 'react-quill';
12
14
  import { lazy } from '../../../../flow-compat';
13
15
  import { useRichTextStyles } from './style';
14
16
  import { FieldModel } from '../../base';
@@ -26,6 +28,8 @@ const ReactQuill = lazy(async () => {
26
28
  return import('react-quill');
27
29
  });
28
30
 
31
+ type ReactQuillValue = ReactQuillComponent.ReactQuillProps['value'];
32
+
29
33
  export const RichTextField = (props) => {
30
34
  const richTextClass = useRichTextStyles();
31
35
  const boundsClass = React.useMemo(() => `quill-bounds-${Math.random().toString(36).slice(2, 9)}`, []);
@@ -83,19 +87,36 @@ export const RichTextField = (props) => {
83
87
  'break',
84
88
  ];
85
89
  const { value, onChange, disabled, modules: propsModules, formats: propsFormats } = props;
90
+ const previousIncomingValueRef = React.useRef(value);
91
+ const pendingEditorValueRef = React.useRef<{ value: ReactQuillValue }>();
92
+ const [editorValue, setEditorValue] = React.useState<ReactQuillValue>(() =>
93
+ typeof value === 'string' ? sanitizeRichTextHtml(value) : value,
94
+ );
95
+
96
+ React.useEffect(() => {
97
+ if (Object.is(value, previousIncomingValueRef.current)) {
98
+ return;
99
+ }
100
+ previousIncomingValueRef.current = value;
101
+ const pendingEditorValue = pendingEditorValueRef.current;
102
+ pendingEditorValueRef.current = undefined;
103
+ if (pendingEditorValue && Object.is(value, pendingEditorValue.value)) {
104
+ return;
105
+ }
106
+ setEditorValue(typeof value === 'string' ? sanitizeRichTextHtml(value) : value);
107
+ }, [value]);
86
108
 
87
109
  return (
88
110
  <ReactQuill
89
111
  className={`${richTextClass} ${boundsClass}`}
90
112
  modules={propsModules || modules}
91
113
  formats={propsFormats || formats}
92
- value={value}
114
+ value={editorValue}
93
115
  onChange={(value) => {
94
- if (value === '<p><br></p>') {
95
- onChange('');
96
- } else {
97
- onChange(value);
98
- }
116
+ const nextValue = value === '<p><br></p>' ? '' : value;
117
+ pendingEditorValueRef.current = { value: nextValue };
118
+ setEditorValue(nextValue);
119
+ onChange(nextValue);
99
120
  }}
100
121
  readOnly={disabled}
101
122
  bounds={`.${boundsClass}`}
@@ -13,6 +13,7 @@ import React from 'react';
13
13
  import { FieldModel } from '../base/FieldModel';
14
14
  import { MobileSelect } from './mobile-components/MobileSelect';
15
15
  import { enumToOptions, getSelectedEnumLabels, translateOptionLabel } from '../../internal/utils/enumOptionsUtils';
16
+ import { syncQuickEditPreventClose } from '../../internal/utils/quickEditViewContainer';
16
17
 
17
18
  const getOriginalEnumOptions = (model: SelectFieldModel) => {
18
19
  const fromEnum = enumToOptions(model.context.collectionField?.uiSchema?.enum, model.translate) || [];
@@ -62,6 +63,10 @@ export class SelectFieldModel extends FieldModel {
62
63
  this.props.onChange?.(nextValue?.value);
63
64
  }}
64
65
  options={options}
66
+ onDropdownVisibleChange={(open) => {
67
+ syncQuickEditPreventClose(this, open);
68
+ this.props.onDropdownVisibleChange?.(open);
69
+ }}
65
70
  labelRender={(item) => item.label}
66
71
  maxTagCount="responsive"
67
72
  maxTagPlaceholder={(omittedValues) => (
@@ -27,6 +27,22 @@ function createInputFieldModel(props?: Record<string, unknown>) {
27
27
  }
28
28
 
29
29
  describe('InputFieldModel', () => {
30
+ it('aligns plain inputs with the top of other filter form controls', () => {
31
+ const model = createInputFieldModel();
32
+
33
+ render(<>{model.render()}</>);
34
+
35
+ expect(screen.getByRole('textbox')).toHaveStyle({ verticalAlign: 'top' });
36
+ });
37
+
38
+ it('preserves explicitly configured input styles', () => {
39
+ const model = createInputFieldModel({ style: { verticalAlign: 'middle', width: 200 } });
40
+
41
+ render(<>{model.render()}</>);
42
+
43
+ expect(screen.getByRole('textbox')).toHaveStyle({ verticalAlign: 'middle', width: '200px' });
44
+ });
45
+
30
46
  it('renders the normal input when scan input is disabled', () => {
31
47
  const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
32
48
  const model = createInputFieldModel({ disableManualInput: true, enableScan: false });
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import React from 'react';
11
- import { describe, expect, it } from 'vitest';
11
+ import { describe, expect, it, vi } from 'vitest';
12
12
  import { SelectFieldModel } from '../SelectFieldModel';
13
13
 
14
14
  function mockT(text: string) {
@@ -40,4 +40,32 @@ describe('SelectFieldModel', () => {
40
40
 
41
41
  expect(element.props.value).toEqual({ label: '是', value: true });
42
42
  });
43
+
44
+ it('keeps the quick edit popover open while the dropdown is open', () => {
45
+ vi.useFakeTimers();
46
+ try {
47
+ const update = vi.fn();
48
+ const onDropdownVisibleChange = vi.fn();
49
+ const model = {
50
+ props: { value: undefined, onDropdownVisibleChange },
51
+ parent: { use: 'QuickEditFormModel', viewContainer: { update } },
52
+ context: { collectionField: { uiSchema: { enum: [] } } },
53
+ translate: mockT,
54
+ } as unknown as SelectFieldModel;
55
+
56
+ const element = SelectFieldModel.prototype.render.call(model) as React.ReactElement;
57
+
58
+ element.props.onDropdownVisibleChange(true);
59
+ expect(update).toHaveBeenCalledWith({ preventClose: true });
60
+ expect(onDropdownVisibleChange).toHaveBeenCalledWith(true);
61
+
62
+ element.props.onDropdownVisibleChange(false);
63
+ expect(onDropdownVisibleChange).toHaveBeenCalledWith(false);
64
+ expect(update).toHaveBeenCalledTimes(1);
65
+ vi.runAllTimers();
66
+ expect(update).toHaveBeenLastCalledWith({ preventClose: false });
67
+ } finally {
68
+ vi.useRealTimers();
69
+ }
70
+ });
43
71
  });
@@ -263,6 +263,7 @@ export function MobileLazySelect(props: Readonly<LazySelectProps>) {
263
263
  <>
264
264
  <Select
265
265
  {...restProps}
266
+ disabled={disabled}
266
267
  dropdownStyle={buildDropdownStyle(dropdownStyle)}
267
268
  fieldNames={fieldNames}
268
269
  options={realOptions}
@@ -388,6 +388,26 @@ describe('MobileLazySelect', () => {
388
388
  resetMockState();
389
389
  });
390
390
 
391
+ it.each([false, true])('updates the disabled trigger state in %s multiple mode', (multiple) => {
392
+ const onDropdownVisibleChange = vi.fn();
393
+ const { rerender } = renderMobileLazySelect({ multiple, onDropdownVisibleChange });
394
+
395
+ rerender({ disabled: true });
396
+
397
+ expect(mockState.selectProps?.disabled).toBe(true);
398
+ act(() => {
399
+ mockState.selectProps?.onClick?.();
400
+ });
401
+ expect(screen.queryByTestId('popup')).not.toBeInTheDocument();
402
+ expect(onDropdownVisibleChange).not.toHaveBeenCalled();
403
+
404
+ rerender({ disabled: false });
405
+
406
+ expect(mockState.selectProps?.disabled).toBe(false);
407
+ openLazyPopup();
408
+ expect(onDropdownVisibleChange).toHaveBeenCalledWith(true);
409
+ });
410
+
391
411
  it('renders a translated cancel action after searching', () => {
392
412
  renderMobileLazySelect();
393
413
 
@@ -201,12 +201,12 @@ function TopbarInternalSettingsLabel(props: { title: React.ReactNode; path?: str
201
201
  adminRoutePath: getTopbarAdminRoutePath(app),
202
202
  });
203
203
 
204
+ if (!shouldOpenInNewWindow) {
205
+ return <Link to={stripTopbarRouterBasePath(targetPathInCurrentApp, basename)}>{props.title}</Link>;
206
+ }
207
+
204
208
  return (
205
- <a
206
- href={href}
207
- target={shouldOpenInNewWindow ? '_blank' : undefined}
208
- rel={shouldOpenInNewWindow ? 'noopener noreferrer' : undefined}
209
- >
209
+ <a href={href} target="_blank" rel="noopener noreferrer">
210
210
  {props.title}
211
211
  </a>
212
212
  );