@nocobase/client-v2 2.3.0-alpha.1 → 2.3.0-beta.11

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 (173) hide show
  1. package/es/APIClient.d.ts +12 -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/actions/linkageRules.d.ts +25 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +6 -30
  6. package/es/flow/components/FlowRoute.d.ts +2 -2
  7. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  8. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +32 -0
  9. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  10. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  11. package/es/flow/components/filter/VariableFilterItem.d.ts +7 -0
  12. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  13. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  14. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  15. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  16. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  17. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  18. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  19. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  20. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  21. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  22. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  23. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  24. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  25. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  26. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  27. package/es/flow/models/blocks/table/TableColumnModel.d.ts +12 -0
  28. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  29. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  30. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  31. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  32. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  33. package/es/flow-compat/passwordUtils.d.ts +1 -1
  34. package/es/index.d.ts +1 -0
  35. package/es/index.mjs +148 -145
  36. package/es/ui-operation/index.d.ts +15 -0
  37. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  38. package/lib/index.js +170 -167
  39. package/lib/locale/languageCodes.js +1 -0
  40. package/package.json +10 -8
  41. package/src/APIClient.ts +92 -0
  42. package/src/Application.tsx +3 -1
  43. package/src/__tests__/APIClient.test.tsx +58 -0
  44. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +7 -2
  45. package/src/__tests__/settings-center.test.tsx +270 -6
  46. package/src/acl/ACLProvider.tsx +2 -0
  47. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  48. package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
  49. package/src/collection-manager/field-configure.ts +2 -2
  50. package/src/components/form/ScanInput/CodeScanner.tsx +64 -35
  51. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +107 -0
  52. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +294 -24
  53. package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
  54. package/src/components/form/ScanInput/useCodeScanner.ts +281 -23
  55. package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
  56. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  57. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  58. package/src/components/form/table/Table.tsx +81 -6
  59. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  60. package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
  61. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  62. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  63. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  64. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  65. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  66. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  67. package/src/flow/actions/linkageRules.tsx +59 -16
  68. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  69. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  70. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
  71. package/src/flow/common/Markdown/style.ts +4 -0
  72. package/src/flow/components/BlockItemCard.tsx +2 -2
  73. package/src/flow/components/FieldAssignValueInput.tsx +42 -621
  74. package/src/flow/components/FlowRoute.tsx +71 -29
  75. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  76. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +335 -0
  77. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +389 -0
  78. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  79. package/src/flow/components/field-value-variable/index.ts +12 -0
  80. package/src/flow/components/filter/VariableFilterItem.tsx +23 -6
  81. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  82. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  83. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  84. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  85. package/src/flow/index.ts +2 -0
  86. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  87. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  88. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  89. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  90. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  91. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  92. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  93. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  94. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  95. package/src/flow/models/base/CollectionBlockModel.tsx +38 -3
  96. package/src/flow/models/base/GridModel.tsx +0 -1
  97. package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
  98. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
  99. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  100. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  101. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  102. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  103. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
  104. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +36 -50
  105. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
  106. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  107. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  108. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
  109. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  110. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  111. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  112. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  113. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
  114. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  115. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  116. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  117. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
  118. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
  119. package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
  120. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  121. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  122. package/src/flow/models/blocks/form/FormBlockModel.tsx +73 -5
  123. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  124. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +153 -2
  125. package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
  126. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  127. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  128. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  129. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  130. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  131. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  132. package/src/flow/models/blocks/table/TableBlockModel.tsx +28 -2
  133. package/src/flow/models/blocks/table/TableColumnModel.tsx +48 -9
  134. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  135. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  136. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  137. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  138. package/src/flow/models/blocks/table/utils.ts +7 -0
  139. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  140. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +12 -3
  141. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  142. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  143. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +29 -3
  144. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  145. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +5 -1
  146. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +191 -3
  147. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  148. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  149. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  150. package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
  151. package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
  152. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  153. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  154. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  155. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  156. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  157. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  158. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  159. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  160. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  161. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
  162. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  163. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  164. package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
  165. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  166. package/src/index.ts +1 -0
  167. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  168. package/src/layout-manager/LayoutRoute.tsx +2 -1
  169. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  170. package/src/locale/languageCodes.ts +1 -0
  171. package/src/settings-center/AdminSettingsLayout.tsx +23 -2
  172. package/src/ui-operation/index.ts +33 -0
  173. package/src/ui-operation/ui-operation-codec.ts +54 -0
@@ -68,6 +68,12 @@ interface LinkageRule {
68
68
  }[];
69
69
  }
70
70
 
71
+ export function updateLinkageRules<T>(rules: T[], updater: (nextRules: T[]) => void): T[] {
72
+ const nextRules = _.cloneDeep(rules);
73
+ updater(nextRules);
74
+ return nextRules;
75
+ }
76
+
71
77
  const previewValueForLog = (value: any) => {
72
78
  if (value == null) return value;
73
79
  const t = typeof value;
@@ -114,11 +120,16 @@ const isActionFlowModel = (model: any): boolean => {
114
120
  return false;
115
121
  };
116
122
 
123
+ const isFormFieldModel = (model: unknown): model is FlowModel => {
124
+ if (!model || typeof model !== 'object') return false;
125
+ return !!(model as FlowModel).subModels?.field;
126
+ };
127
+
117
128
  // 获取表单中所有字段的 model 实例的通用函数
118
129
  const getFormFields = (ctx: any) => {
119
130
  try {
120
131
  const fieldModels = ctx.model?.subModels?.grid?.subModels?.items || [];
121
- return fieldModels.map((model: any) => ({
132
+ return fieldModels.filter(isFormFieldModel).map((model) => ({
122
133
  label: model.props.label || model.props.name,
123
134
  value: model.uid,
124
135
  model,
@@ -340,8 +351,8 @@ const FieldStateEditor = ({
340
351
  const getFormFieldsByForkModel = (ctx: any) => {
341
352
  try {
342
353
  const fieldModels = ctx.model?.subModels?.grid?.subModels?.items || [];
343
- return fieldModels.map((model: any) => {
344
- const forkModel = Array.from(model.forks)[0] as any;
354
+ return fieldModels.filter(isFormFieldModel).map((model) => {
355
+ const forkModel = Array.from(model.forks)[0] as FlowModel | undefined;
345
356
 
346
357
  if (forkModel) {
347
358
  return {
@@ -654,6 +665,30 @@ export const linkageSetBlockProps = defineAction({
654
665
  },
655
666
  });
656
667
 
668
+ const ACTION_LINKAGE_STATE_OPTIONS = [
669
+ { label: 'Visible', value: 'visible' },
670
+ { label: 'Hidden', value: 'hidden' },
671
+ { label: 'Hidden text', value: 'hiddenText' },
672
+ { label: 'Enabled', value: 'enabled' },
673
+ { label: 'Disabled', value: 'disabled' },
674
+ ] as const;
675
+
676
+ type ActionLinkageState = (typeof ACTION_LINKAGE_STATE_OPTIONS)[number]['value'];
677
+
678
+ export function getActionLinkageStateOptions(
679
+ model: { supportedActionLinkageStates?: readonly ActionLinkageState[] },
680
+ t: (key: string) => string,
681
+ ) {
682
+ const supportedStates = model.supportedActionLinkageStates ? new Set(model.supportedActionLinkageStates) : undefined;
683
+
684
+ return ACTION_LINKAGE_STATE_OPTIONS.filter((option) => !supportedStates || supportedStates.has(option.value)).map(
685
+ (option) => ({
686
+ label: t(option.label),
687
+ value: option.value,
688
+ }),
689
+ );
690
+ }
691
+
657
692
  export const linkageSetActionProps = defineAction({
658
693
  name: 'linkageSetActionProps',
659
694
  title: tExpr('Set button state'),
@@ -674,13 +709,7 @@ export const linkageSetActionProps = defineAction({
674
709
  onChange={onChange}
675
710
  placeholder={t('Please select state')}
676
711
  style={{ width: '100%' }}
677
- options={[
678
- { label: t('Visible'), value: 'visible' },
679
- { label: t('Hidden'), value: 'hidden' },
680
- { label: t('Hidden text'), value: 'hiddenText' },
681
- { label: t('Enabled'), value: 'enabled' },
682
- { label: t('Disabled'), value: 'disabled' },
683
- ]}
712
+ options={getActionLinkageStateOptions(ctx.model, t)}
684
713
  allowClear
685
714
  />
686
715
  );
@@ -1500,13 +1529,26 @@ async function resolveLinkageRulesParamsPreservingRunJsScripts(ctx: FlowContext,
1500
1529
  }
1501
1530
 
1502
1531
  const LinkageRulesUI = observer(
1503
- (props: { readonly value: LinkageRule[]; supportedActions: string[]; title?: string }) => {
1504
- const { value: rules, supportedActions } = props;
1532
+ (props: {
1533
+ readonly value: LinkageRule[];
1534
+ onChange?: (value: LinkageRule[]) => void;
1535
+ supportedActions: string[];
1536
+ title?: string;
1537
+ }) => {
1538
+ const { value: rules = [], onChange, supportedActions } = props;
1505
1539
  const ctx = useFlowContext();
1506
1540
  const flowEngine = useFlowEngine();
1507
1541
  const t = ctx.model.translate.bind(ctx.model);
1508
1542
  const assignPriorityTip = t('Assignment takes precedence over form field assignment');
1509
1543
 
1544
+ const replaceRules = (updater: (nextRules: LinkageRule[]) => void) => {
1545
+ if (onChange) {
1546
+ onChange(updateLinkageRules(rules, updater));
1547
+ } else {
1548
+ updater(rules);
1549
+ }
1550
+ };
1551
+
1510
1552
  // 创建新规则的默认值
1511
1553
  const createNewRule = (): LinkageRule => ({
1512
1554
  key: uid(),
@@ -1523,7 +1565,7 @@ const LinkageRulesUI = observer(
1523
1565
 
1524
1566
  // 删除规则
1525
1567
  const handleDeleteRule = (index: number) => {
1526
- rules.splice(index, 1);
1568
+ replaceRules((nextRules) => nextRules.splice(index, 1));
1527
1569
  };
1528
1570
 
1529
1571
  // 上移规则
@@ -1562,7 +1604,9 @@ const LinkageRulesUI = observer(
1562
1604
 
1563
1605
  // 切换规则启用状态
1564
1606
  const handleToggleEnable = (index: number, enable: boolean) => {
1565
- rules[index].enable = enable;
1607
+ replaceRules((nextRules) => {
1608
+ nextRules[index].enable = enable;
1609
+ });
1566
1610
  };
1567
1611
 
1568
1612
  // 获取可用的动作类型
@@ -2222,7 +2266,7 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
2222
2266
  }
2223
2267
 
2224
2268
  rememberOriginalProp(key, model.props?.[key]);
2225
- if (key === 'hiddenText') {
2269
+ if (key === 'hiddenText' && normalizedProps[key]) {
2226
2270
  rememberOriginalProp('title', model.props?.title);
2227
2271
  }
2228
2272
  if (key === 'required') {
@@ -2286,7 +2330,6 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
2286
2330
  const newProps = { ...model.__originalProps, ...patchProps };
2287
2331
  const prevHidden = !!model.hidden;
2288
2332
  const nextHidden = !!newProps.hiddenModel;
2289
-
2290
2333
  model.setProps(_.omit(newProps, ['hiddenModel', 'value', 'hiddenText', LINKAGE_ASSIGN_MODE_PROP]));
2291
2334
  syncFieldOptionsToForks(model, patchProps);
2292
2335
  if (typeof model.setHidden === 'function') {
@@ -17,7 +17,7 @@ import {
17
17
  FlowSettingsButton,
18
18
  observer,
19
19
  } from '@nocobase/flow-engine';
20
- import { Badge, Tooltip } from 'antd';
20
+ import { Badge } from 'antd';
21
21
  import qs from 'qs';
22
22
  import React, { FC, useCallback, useContext, useEffect } from 'react';
23
23
  import { Link, useLocation, type NavigateFunction } from 'react-router-dom';
@@ -153,6 +153,24 @@ const getLayoutRoutePathFromModel = (model: FlowModel): AdminLayoutRoutePathLike
153
153
  };
154
154
 
155
155
  const menuItemStyle = { display: 'flex', alignItems: 'center', justifyContent: 'space-between' };
156
+ const siderMenuItemStyle: React.CSSProperties = { ...menuItemStyle, width: '100%', minWidth: 0 };
157
+ const siderMenuItemContentStyle: React.CSSProperties = { flex: 1, minWidth: 0, overflow: 'hidden' };
158
+ const siderMenuItemLinkStyle: React.CSSProperties = {
159
+ display: 'block',
160
+ flex: 1,
161
+ width: '100%',
162
+ minWidth: 0,
163
+ overflow: 'hidden',
164
+ };
165
+ const getMenuItemText = (name: React.ReactNode) => {
166
+ if (typeof name === 'string' || typeof name === 'number') {
167
+ return String(name);
168
+ }
169
+
170
+ if (React.isValidElement<{ title?: unknown }>(name) && typeof name.props.title === 'string') {
171
+ return name.props.title;
172
+ }
173
+ };
156
174
  const groupLandingEntryStyle = {
157
175
  display: 'inline-flex',
158
176
  alignItems: 'center',
@@ -553,16 +571,16 @@ export function resolveAdminLayoutMenuDragMoveOptionsFromEvent(
553
571
 
554
572
  const GroupItem: FC<{ item: AdminLayoutMenuNode; options?: AdminLayoutMenuRenderOptions }> = (props) => {
555
573
  const { item } = props;
574
+ const { inHeader } = useContext(HeaderContext);
556
575
  const badgeCount = useEvaluatedExpression(item._route.options?.badge?.count, item._model?.context);
557
576
  const navigate = useNavigateNoUpdate();
558
577
  const routerBasename = useRouterBasename();
559
578
  const { closeMobileMenu } = useContext(MobileMenuControlContext);
560
579
  const ariaLabel =
561
- typeof item.name === 'string' || typeof item.name === 'number'
562
- ? String(item.name)
563
- : typeof item._route?.title === 'string' || typeof item._route?.title === 'number'
564
- ? String(item._route.title)
565
- : undefined;
580
+ getMenuItemText(item.name) ??
581
+ (typeof item._route?.title === 'string' || typeof item._route?.title === 'number'
582
+ ? String(item._route.title)
583
+ : undefined);
566
584
  const runtimePath = item._runtimePath;
567
585
  const spaRuntimePath = runtimePath ? toRouterNavigationPath(runtimePath, routerBasename) : runtimePath;
568
586
 
@@ -633,7 +651,12 @@ const GroupItem: FC<{ item: AdminLayoutMenuNode; options?: AdminLayoutMenuRender
633
651
  return (
634
652
  <ParentRouteContext.Provider value={item._parentRoute}>
635
653
  <NocoBaseRouteContext.Provider value={item._route}>
636
- <div aria-label={ariaLabel} role="none" style={menuItemStyle}>
654
+ <div
655
+ aria-label={ariaLabel}
656
+ title={inHeader || item._route?.tooltip ? undefined : ariaLabel}
657
+ role="none"
658
+ style={inHeader ? menuItemStyle : siderMenuItemStyle}
659
+ >
637
660
  {content}
638
661
  {landingEntry}
639
662
  {badgeCount != null && (
@@ -650,24 +673,9 @@ const GroupItem: FC<{ item: AdminLayoutMenuNode; options?: AdminLayoutMenuRender
650
673
  );
651
674
  };
652
675
 
653
- const WithTooltip: FC<{ title: React.ReactNode; hidden: boolean; badgeProps: any }> = (props) => {
654
- const { inHeader } = useContext(HeaderContext);
655
-
656
- if (props.hidden || inHeader) {
657
- return props.children;
658
- }
659
-
660
- return (
661
- <Tooltip title={props.title} placement="right">
662
- <Badge {...props.badgeProps} style={{ transform: 'none', maxWidth: '10em' }} dot={false}>
663
- {props.children}
664
- </Badge>
665
- </Tooltip>
666
- );
667
- };
668
-
669
676
  const MenuItem: FC<{ item: AdminLayoutMenuNode; options?: AdminLayoutMenuRenderOptions }> = (props) => {
670
677
  const { item } = props;
678
+ const { inHeader } = useContext(HeaderContext);
671
679
  const location = useLocation();
672
680
  const badgeCount = useEvaluatedExpression(item._route.options?.badge?.count, item._model?.context);
673
681
  const navigate = useNavigateNoUpdate();
@@ -677,7 +685,10 @@ const MenuItem: FC<{ item: AdminLayoutMenuNode; options?: AdminLayoutMenuRenderO
677
685
  const runtimePath = item._runtimePath || path;
678
686
  const spaRuntimePath = runtimePath ? toRouterNavigationPath(runtimePath, basenameOfCurrentRouter) : runtimePath;
679
687
  const isDocumentNavigation = item._navigationMode === 'document';
680
- const badgeProps = { ...item._route.options?.badge, count: badgeCount };
688
+ const { textColor: badgeTextColor, ...antdBadgeOptions } = item._route.options?.badge || {};
689
+ const badgeProps = { ...antdBadgeOptions, count: badgeCount };
690
+ const menuItemText = getMenuItemText(item.name);
691
+ const nativeTitle = inHeader || item._route?.tooltip ? undefined : menuItemText;
681
692
 
682
693
  const canUseNativeAnchor = !!runtimePath && isDocumentNavigation;
683
694
 
@@ -767,17 +778,25 @@ const MenuItem: FC<{ item: AdminLayoutMenuNode; options?: AdminLayoutMenuRenderO
767
778
  return (
768
779
  <ParentRouteContext.Provider value={item._parentRoute}>
769
780
  <NocoBaseRouteContext.Provider value={item._route}>
770
- <div role="none" style={menuItemStyle}>
771
- <div onClick={handleClickLink}>
772
- <Link to={location.pathname} aria-label={typeof item.name === 'string' ? item.name : undefined}>
781
+ <div role="none" style={inHeader ? menuItemStyle : siderMenuItemStyle}>
782
+ <div onClick={handleClickLink} style={inHeader ? undefined : siderMenuItemContentStyle}>
783
+ <Link
784
+ to={location.pathname}
785
+ aria-label={menuItemText}
786
+ title={nativeTitle}
787
+ style={inHeader ? undefined : siderMenuItemLinkStyle}
788
+ >
773
789
  {props.children}
774
790
  </Link>
775
791
  </div>
776
792
  {badgeCount != null && (
777
793
  <Badge
778
- {...item._route.options?.badge}
779
- count={badgeCount}
780
- style={{ marginLeft: 4, color: item._route.options?.badge?.textColor, maxWidth: '10em' }}
794
+ {...badgeProps}
795
+ style={{
796
+ marginLeft: 4,
797
+ ...(badgeTextColor == null ? {} : { color: badgeTextColor }),
798
+ maxWidth: '10em',
799
+ }}
781
800
  dot={false}
782
801
  ></Badge>
783
802
  )}
@@ -787,45 +806,45 @@ const MenuItem: FC<{ item: AdminLayoutMenuNode; options?: AdminLayoutMenuRenderO
787
806
  );
788
807
  }
789
808
 
790
- const itemContent = (
791
- <WithTooltip
792
- title={item.name}
793
- hidden={
794
- item._route.type === NocoBaseDesktopRouteType.group || (item._depth || 0) > 0 || !props.options?.collapsed
795
- }
796
- badgeProps={badgeProps}
809
+ const itemContent = canUseNativeAnchor ? (
810
+ <a
811
+ href={runtimePath}
812
+ aria-label={menuItemText}
813
+ title={nativeTitle}
814
+ onClick={handleClickMenuItem}
815
+ style={inHeader ? undefined : siderMenuItemLinkStyle}
797
816
  >
798
- {canUseNativeAnchor ? (
799
- <a
800
- href={runtimePath}
801
- aria-label={typeof item.name === 'string' ? item.name : undefined}
802
- onClick={handleClickMenuItem as any}
803
- >
804
- {props.children}
805
- </a>
806
- ) : runtimePath ? (
807
- <Link
808
- to={spaRuntimePath}
809
- aria-label={typeof item.name === 'string' ? item.name : undefined}
810
- onClick={handleClickMenuItem}
811
- >
812
- {props.children}
813
- </Link>
814
- ) : (
815
- <span aria-label={typeof item.name === 'string' ? item.name : undefined}>{props.children}</span>
816
- )}
817
- </WithTooltip>
817
+ {props.children}
818
+ </a>
819
+ ) : runtimePath ? (
820
+ <Link
821
+ to={spaRuntimePath}
822
+ aria-label={menuItemText}
823
+ title={nativeTitle}
824
+ onClick={handleClickMenuItem}
825
+ style={inHeader ? undefined : siderMenuItemLinkStyle}
826
+ >
827
+ {props.children}
828
+ </Link>
829
+ ) : (
830
+ <span aria-label={menuItemText} title={nativeTitle} style={inHeader ? undefined : siderMenuItemLinkStyle}>
831
+ {props.children}
832
+ </span>
818
833
  );
819
834
 
820
835
  return (
821
836
  <ParentRouteContext.Provider value={item._parentRoute}>
822
837
  <NocoBaseRouteContext.Provider value={item._route}>
823
- <div role="none" style={menuItemStyle}>
838
+ <div role="none" style={inHeader ? menuItemStyle : siderMenuItemStyle}>
824
839
  {itemContent}
825
840
  {badgeCount != null && (
826
841
  <Badge
827
842
  {...badgeProps}
828
- style={{ marginLeft: 4, color: item._route.options?.badge?.textColor, maxWidth: '10em' }}
843
+ style={{
844
+ marginLeft: 4,
845
+ ...(badgeTextColor == null ? {} : { color: badgeTextColor }),
846
+ maxWidth: '10em',
847
+ }}
829
848
  dot={false}
830
849
  ></Badge>
831
850
  )}
@@ -19,6 +19,7 @@ import {
19
19
  AdminLayoutMenuItemModel,
20
20
  AdminLayoutModel,
21
21
  ADMIN_LAYOUT_MODEL_UID,
22
+ buildMenuTitleWithIcon,
22
23
  getAdminLayoutMenuMovePositionOptions,
23
24
  normalizeAdminLayoutMenuLegacyVariables,
24
25
  openAdminLayoutMenuLink,
@@ -119,6 +120,233 @@ describe('AdminLayoutModel menu items', () => {
119
120
  ...options,
120
121
  });
121
122
 
123
+ type MenuTitleOptions = {
124
+ title: string;
125
+ collapsed: boolean;
126
+ name?: React.ReactNode;
127
+ routeTitle?: string;
128
+ renderType?: 'item' | 'group';
129
+ tooltip?: string;
130
+ badgeCount?: number;
131
+ badgeIndicatorColor?: string;
132
+ badgeTextColor?: string;
133
+ depth?: number;
134
+ };
135
+
136
+ const createMenuTitle = (options: MenuTitleOptions) => {
137
+ const {
138
+ title,
139
+ collapsed,
140
+ name = title,
141
+ routeTitle = title,
142
+ renderType = 'item',
143
+ tooltip,
144
+ badgeCount,
145
+ badgeIndicatorColor,
146
+ badgeTextColor,
147
+ depth = 1,
148
+ } = options;
149
+
150
+ return React.createElement(
151
+ FlowEngineProvider,
152
+ { engine },
153
+ React.createElement(
154
+ MemoryRouter,
155
+ { initialEntries: ['/admin/current-page'] },
156
+ React.createElement(AdminLayoutMenuItemRenderer, {
157
+ renderType,
158
+ item: {
159
+ name,
160
+ path: '/admin/menu-title',
161
+ _runtimePath: '/apps/demo/v2/admin/menu-title',
162
+ _navigationMode: 'spa',
163
+ _isLegacy: false,
164
+ _depth: depth,
165
+ _route: {
166
+ type: NocoBaseDesktopRouteType.flowPage,
167
+ title: routeTitle,
168
+ tooltip,
169
+ schemaUid: 'menu-title',
170
+ options:
171
+ badgeCount == null
172
+ ? {}
173
+ : {
174
+ badge: {
175
+ count: badgeCount,
176
+ styles: badgeIndicatorColor ? { indicator: { color: badgeIndicatorColor } } : undefined,
177
+ textColor: badgeTextColor,
178
+ },
179
+ },
180
+ },
181
+ },
182
+ dom: React.createElement(
183
+ 'span',
184
+ { className: 'ant-pro-base-menu-inline-item-text' },
185
+ collapsed ? title.slice(0, 1) : title,
186
+ ),
187
+ options: { isMobile: false, collapsed },
188
+ }),
189
+ ),
190
+ );
191
+ };
192
+
193
+ const renderMenuTitle = (options: MenuTitleOptions) => render(createMenuTitle(options));
194
+
195
+ it.each([false, true])(
196
+ 'should use a native title without an extra tooltip trigger when collapsed is %s',
197
+ async (collapsed) => {
198
+ vi.useFakeTimers();
199
+ const title = 'A complete menu title that may be truncated';
200
+
201
+ try {
202
+ const { container } = renderMenuTitle({ title, collapsed });
203
+ const link = screen.getByRole('link', { name: title });
204
+ const menuItem = link.closest<HTMLElement>('[role="none"]');
205
+
206
+ expect(link).toHaveAttribute('title', title);
207
+ expect(link.parentElement).toBe(menuItem);
208
+ expect(menuItem).toHaveStyle({ width: '100%', minWidth: 0 });
209
+ expect(link).toHaveStyle({ display: 'block', width: '100%', minWidth: 0, overflow: 'hidden' });
210
+ expect(container.querySelector('.ant-tooltip')).not.toBeInTheDocument();
211
+
212
+ await act(async () => {
213
+ fireEvent.mouseEnter(link);
214
+ await vi.advanceTimersByTimeAsync(500);
215
+ });
216
+
217
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
218
+ } finally {
219
+ vi.clearAllTimers();
220
+ vi.useRealTimers();
221
+ }
222
+ },
223
+ );
224
+
225
+ it('should use the translated native title for an icon-wrapped nested menu name', () => {
226
+ const title = 'A translated nested menu title';
227
+ const { name } = buildMenuTitleWithIcon(
228
+ { title: 'menu.nested.title', icon: 'AppstoreOutlined' },
229
+ () => title,
230
+ true,
231
+ );
232
+
233
+ renderMenuTitle({ title, name, collapsed: true, depth: 2 });
234
+
235
+ expect(screen.getByRole('link', { name: title })).toHaveAttribute('title', title);
236
+ });
237
+
238
+ it('should use the translated native title for an icon-wrapped nested group name', () => {
239
+ const title = 'A translated nested group title';
240
+ const { name } = buildMenuTitleWithIcon(
241
+ { title: 'menu.nested.group', icon: 'AppstoreOutlined' },
242
+ () => title,
243
+ true,
244
+ );
245
+ const { container } = renderMenuTitle({
246
+ title,
247
+ name,
248
+ routeTitle: 'menu.nested.group',
249
+ renderType: 'group',
250
+ collapsed: true,
251
+ depth: 2,
252
+ });
253
+ const group = container.querySelector('[role="none"]');
254
+
255
+ expect(group).toHaveAttribute('title', title);
256
+ expect(group).toHaveAttribute('aria-label', title);
257
+ });
258
+
259
+ it('should preserve a numeric group name as the native title', () => {
260
+ const { container } = renderMenuTitle({
261
+ title: '0',
262
+ name: 0,
263
+ routeTitle: 'menu.numeric.group',
264
+ renderType: 'group',
265
+ collapsed: true,
266
+ });
267
+ const group = container.querySelector('[role="none"]');
268
+
269
+ expect(group).toHaveAttribute('title', '0');
270
+ expect(group).toHaveAttribute('aria-label', '0');
271
+ });
272
+
273
+ it.each(['item', 'group'] as const)(
274
+ 'should let an explicit route tooltip own hover behavior for a sider %s',
275
+ (renderType) => {
276
+ const title = 'A menu title with an explicit description';
277
+ const { container } = renderMenuTitle({
278
+ title,
279
+ renderType,
280
+ collapsed: false,
281
+ tooltip: 'An explicitly configured menu description',
282
+ });
283
+ const titleTarget =
284
+ renderType === 'item' ? screen.getByRole('link', { name: title }) : container.querySelector('[role="none"]');
285
+
286
+ expect(titleTarget).not.toHaveAttribute('title');
287
+ expect(screen.getByRole('img', { name: 'question-circle' })).toBeInTheDocument();
288
+ },
289
+ );
290
+
291
+ it('should keep the same menu link when the sider expands', async () => {
292
+ vi.useFakeTimers();
293
+ const title = 'A title that fits after expanding';
294
+
295
+ try {
296
+ const { rerender } = renderMenuTitle({ title, collapsed: true });
297
+ const link = screen.getByRole('link', { name: title });
298
+
299
+ await act(async () => {
300
+ rerender(createMenuTitle({ title, collapsed: false }));
301
+ await vi.runOnlyPendingTimersAsync();
302
+ });
303
+
304
+ expect(screen.getByRole('link', { name: title })).toBe(link);
305
+ expect(link).toHaveAttribute('title', title);
306
+ } finally {
307
+ vi.clearAllTimers();
308
+ vi.useRealTimers();
309
+ }
310
+ });
311
+
312
+ it.each([true, false])(
313
+ 'should render one badge indicator for a nested menu title when collapsed is %s',
314
+ (collapsed) => {
315
+ const title = 'A nested menu title with a badge';
316
+ const { container } = renderMenuTitle({ title, collapsed, badgeCount: 7 });
317
+
318
+ expect(container.querySelectorAll('.ant-badge-count')).toHaveLength(1);
319
+ },
320
+ );
321
+
322
+ it('should render one badge indicator for a top-level collapsed menu title', () => {
323
+ const title = 'A top-level collapsed menu title with a badge';
324
+ const { container } = renderMenuTitle({
325
+ title,
326
+ collapsed: true,
327
+ badgeCount: 7,
328
+ badgeTextColor: 'rgb(1, 2, 3)',
329
+ depth: 0,
330
+ });
331
+ const indicator = container.querySelector<HTMLElement>('.ant-badge-count');
332
+
333
+ expect(container.querySelectorAll('.ant-badge-count')).toHaveLength(1);
334
+ expect(indicator).toHaveStyle({ color: 'rgb(1, 2, 3)' });
335
+ });
336
+
337
+ it('should preserve the Ant Design indicator color when collapsed badge textColor is unset', () => {
338
+ const title = 'A top-level collapsed menu title with an indicator color';
339
+ const { container } = renderMenuTitle({
340
+ title,
341
+ collapsed: true,
342
+ badgeCount: 7,
343
+ badgeIndicatorColor: 'rgb(4, 5, 6)',
344
+ depth: 0,
345
+ });
346
+
347
+ expect(container.querySelector('.ant-badge-count')).toHaveStyle({ color: 'rgb(4, 5, 6)' });
348
+ });
349
+
122
350
  it('should normalize legacy variables only inside template expressions', () => {
123
351
  expect(normalizeAdminLayoutMenuLegacyVariables('https://a.com?id={{ $user.id }}')).toBe(
124
352
  'https://a.com?id={{ ctx.user.id }}',
@@ -9,7 +9,7 @@
9
9
 
10
10
  import React from 'react';
11
11
  import { fireEvent, render, screen } from '@testing-library/react';
12
- import { MemoryRouter } from 'react-router-dom';
12
+ import { MemoryRouter, useLocation } from 'react-router-dom';
13
13
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
14
14
 
15
15
  const { allowMock, appMock, flowModelRendererSpy } = vi.hoisted(() => {
@@ -270,28 +270,47 @@ describe('TopbarActionsBar helpers', () => {
270
270
  expect(link).not.toHaveAttribute('target', '_blank');
271
271
  });
272
272
 
273
- it('should keep sub-app admin settings in the current window inside sub-app admin runtime', () => {
274
- const items = getTopbarPluginSettingsItems({
275
- canManagePlugins: false,
276
- t: (key) => key,
277
- settings: [
278
- {
279
- key: 'routes',
280
- name: 'routes',
281
- title: 'Routes',
282
- path: '/admin/settings/routes',
283
- icon: null,
284
- componentLoader: async () => null,
285
- },
286
- ] as any,
287
- });
288
-
289
- renderSettingsLabel((items as any[])[0].label, '/apps/a_9xlild35jir/admin/settings/routes');
290
-
291
- const link = screen.getByRole('link', { name: 'Routes' });
292
- expect(link).toHaveAttribute('href', '/nocobase/v/apps/a_9xlild35jir/admin/settings/routes');
293
- expect(link).not.toHaveAttribute('target', '_blank');
294
- });
273
+ it.each(['/nocobase/v', '/v', '/nocobase/v/apps/jhb20'])(
274
+ 'should navigate sub-app settings without document navigation with basename %s',
275
+ (basename) => {
276
+ appMock.current.router.getBasename = () => basename;
277
+ const items = getTopbarPluginSettingsItems({
278
+ canManagePlugins: false,
279
+ t: (key) => key,
280
+ settings: [
281
+ {
282
+ key: 'ai',
283
+ name: 'ai',
284
+ title: 'AI employees',
285
+ path: '/admin/settings/ai',
286
+ icon: null,
287
+ componentLoader: async () => null,
288
+ },
289
+ ],
290
+ });
291
+ const item = items[0];
292
+ if (!item || !('label' in item)) {
293
+ throw new Error('Expected settings menu item');
294
+ }
295
+ const appBase = basename.endsWith('/apps/jhb20') ? basename : `${basename}/apps/jhb20`;
296
+ const targetHref = `${appBase}/admin/settings/ai`;
297
+ const LocationDisplay = () => <output aria-label="Current route">{useLocation().pathname}</output>;
298
+ render(
299
+ <MemoryRouter basename={basename} initialEntries={[`${appBase}/admin/a3pq1t1773a`]}>
300
+ {item.label}
301
+ <LocationDisplay />
302
+ </MemoryRouter>,
303
+ );
304
+
305
+ const link = screen.getByRole('link', { name: 'AI employees' });
306
+ expect(link).toHaveAttribute('href', targetHref);
307
+ expect(link).not.toHaveAttribute('target', '_blank');
308
+ const click = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 });
309
+ fireEvent(link, click);
310
+ expect(click.defaultPrevented).toBe(true);
311
+ expect(screen.getByLabelText('Current route')).toHaveTextContent(targetHref.slice(basename.length));
312
+ },
313
+ );
295
314
 
296
315
  it('should not treat admin-like paths as admin runtime', () => {
297
316
  const items = getTopbarPluginSettingsItems({