@nocobase/client-v2 3.0.0-alpha.1 → 3.0.0-alpha.12

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 (133) hide show
  1. package/es/APIClient.d.ts +12 -0
  2. package/es/acl/aclCheckReadiness.d.ts +11 -0
  3. package/es/authRedirect.d.ts +6 -2
  4. package/es/flow/actions/linkageRules.d.ts +24 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
  6. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  7. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
  8. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  9. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  10. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  11. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  12. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  13. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  14. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  15. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  16. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  17. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  18. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  19. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  20. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  21. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  22. package/es/flow/models/blocks/table/TableColumnModel.d.ts +11 -0
  23. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  24. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  25. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  26. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  27. package/es/flow-compat/passwordUtils.d.ts +1 -1
  28. package/es/index.d.ts +2 -1
  29. package/es/index.mjs +140 -140
  30. package/es/nocobase-buildin-plugin/index.d.ts +1 -0
  31. package/es/settings-app/SettingsGroupNav.d.ts +3 -2
  32. package/es/settings-app/SettingsSearch.d.ts +1 -1
  33. package/es/ui-operation/index.d.ts +15 -0
  34. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  35. package/lib/index.js +145 -145
  36. package/package.json +7 -7
  37. package/src/APIClient.ts +92 -0
  38. package/src/Application.tsx +3 -1
  39. package/src/__tests__/APIClient.test.tsx +58 -0
  40. package/src/__tests__/app.test.tsx +122 -3
  41. package/src/__tests__/authRedirect.test.ts +17 -0
  42. package/src/__tests__/browserChecker.test.ts +81 -33
  43. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
  44. package/src/__tests__/settings-center.test.tsx +312 -11
  45. package/src/__tests__/settings-layout-root.test.tsx +346 -6
  46. package/src/__tests__/settings-runtime-paths.test.ts +1 -1
  47. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  48. package/src/__tests__/settings-shell.test.tsx +62 -1
  49. package/src/acl/ACLProvider.tsx +34 -3
  50. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  51. package/src/acl/aclCheckReadiness.ts +44 -0
  52. package/src/authRedirect.ts +13 -5
  53. package/src/components/AppComponents.tsx +16 -7
  54. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  55. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  56. package/src/components/form/table/Table.tsx +81 -6
  57. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  58. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  59. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  60. package/src/flow/actions/linkageRules.tsx +33 -10
  61. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  62. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  63. package/src/flow/components/FieldAssignValueInput.tsx +38 -621
  64. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  65. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
  66. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
  67. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  68. package/src/flow/components/field-value-variable/index.ts +12 -0
  69. package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
  70. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  71. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  72. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  73. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  74. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  75. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  76. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  77. package/src/flow/models/base/CollectionBlockModel.tsx +32 -1
  78. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  79. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
  80. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  81. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  82. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  83. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  84. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  85. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  86. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  87. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  88. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  89. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  90. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  91. package/src/flow/models/blocks/form/FormBlockModel.tsx +47 -0
  92. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  93. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
  94. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  95. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  96. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  97. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  98. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  99. package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -1
  100. package/src/flow/models/blocks/table/TableColumnModel.tsx +18 -8
  101. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  102. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  103. package/src/flow/models/blocks/table/utils.ts +7 -0
  104. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  105. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
  106. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  107. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  108. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
  109. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  110. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +167 -3
  111. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  112. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  113. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  114. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  115. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  116. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
  117. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  118. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  119. package/src/index.ts +2 -0
  120. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  121. package/src/layout-manager/LayoutRoute.tsx +2 -1
  122. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  123. package/src/nocobase-buildin-plugin/index.tsx +14 -1
  124. package/src/settings-app/SettingsGroupNav.tsx +11 -11
  125. package/src/settings-app/SettingsSearch.tsx +115 -10
  126. package/src/settings-app/SettingsShell.tsx +23 -6
  127. package/src/settings-app/runtimePaths.ts +0 -1
  128. package/src/settings-app/settingsTheme.ts +82 -14
  129. package/src/settings-center/AdminSettingsLayout.tsx +132 -107
  130. package/src/settings-center/__tests__/groups.test.ts +25 -0
  131. package/src/settings-center/groups.ts +1 -1
  132. package/src/ui-operation/index.ts +33 -0
  133. package/src/ui-operation/ui-operation-codec.ts +54 -0
@@ -0,0 +1,33 @@
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 } from 'vitest';
11
+ import { getActionLinkageStateOptions } from '../linkageRules';
12
+
13
+ describe('getActionLinkageStateOptions', () => {
14
+ const t = (key: string) => key;
15
+
16
+ it('returns all states for regular action models', () => {
17
+ expect(getActionLinkageStateOptions({}, t).map((option) => option.value)).toEqual([
18
+ 'visible',
19
+ 'hidden',
20
+ 'hiddenText',
21
+ 'enabled',
22
+ 'disabled',
23
+ ]);
24
+ });
25
+
26
+ it('filters states declared unsupported by the action model', () => {
27
+ expect(
28
+ getActionLinkageStateOptions({ supportedActionLinkageStates: ['visible', 'hidden'] }, t).map(
29
+ (option) => option.value,
30
+ ),
31
+ ).toEqual(['visible', 'hidden']);
32
+ });
33
+ });
@@ -18,6 +18,7 @@ import {
18
18
  subFormFieldLinkageRules,
19
19
  subFormLinkageSetFieldProps,
20
20
  } from '../linkageRules';
21
+ import { CreateFormModel, EditFormModel, FormGridModel, FormItemModel, JSItemModel } from '../../models';
21
22
 
22
23
  const createSubFormFieldModel = ({
23
24
  uid,
@@ -1056,6 +1057,77 @@ describe('linkageSetFieldProps action', () => {
1056
1057
  expect(await screen.findByText('Published')).toBeTruthy();
1057
1058
  });
1058
1059
 
1060
+ it.each([
1061
+ ['create form', CreateFormModel, linkageSetFieldProps, false],
1062
+ ['edit form', EditFormModel, linkageSetFieldProps, false],
1063
+ ['to-one subform', FlowModel, subFormLinkageSetFieldProps, false],
1064
+ ['to-many subform row', FlowModel, subFormLinkageSetFieldProps, true],
1065
+ ])('should exclude JS items from %s field state options', async (_, ModelClass, action, useGridFork) => {
1066
+ const engine = new FlowEngine();
1067
+ engine.registerModels({
1068
+ CreateFormModel,
1069
+ EditFormModel,
1070
+ FormGridModel,
1071
+ FormItemModel,
1072
+ JSItemModel,
1073
+ });
1074
+ const model = new ModelClass({
1075
+ flowEngine: engine,
1076
+ uid: 'form-model',
1077
+ use: ModelClass.name,
1078
+ }) as FlowModel;
1079
+ const grid = engine.createModel<FormGridModel>({
1080
+ use: 'FormGridModel',
1081
+ uid: 'form-grid',
1082
+ });
1083
+ const fieldModel = engine.createModel<FormItemModel>({
1084
+ use: 'FormItemModel',
1085
+ uid: 'name-field',
1086
+ props: { label: 'Name' },
1087
+ subModels: {
1088
+ field: {
1089
+ use: 'FlowModel',
1090
+ uid: 'name-field-component',
1091
+ },
1092
+ },
1093
+ });
1094
+ const jsItemModel = engine.createModel<JSItemModel>({
1095
+ use: 'JSItemModel',
1096
+ uid: 'js-item',
1097
+ props: { label: 'JS item' },
1098
+ });
1099
+ Object.assign(model.subModels, {
1100
+ grid: useGridFork
1101
+ ? grid.createFork({}, 'row-0')
1102
+ : Object.assign(grid, {
1103
+ subModels: {
1104
+ ...grid.subModels,
1105
+ items: [fieldModel, jsItemModel],
1106
+ },
1107
+ }),
1108
+ });
1109
+ Object.assign(model.subModels.grid.subModels, {
1110
+ items: [fieldModel, jsItemModel],
1111
+ });
1112
+ const ctx = model.context;
1113
+ const Comp: any = action.uiSchema.value['x-component'];
1114
+ const view = render(
1115
+ React.createElement(
1116
+ FlowSettingsContextProvider,
1117
+ { value: ctx },
1118
+ React.createElement(Comp, {
1119
+ value: { fields: [] },
1120
+ onChange: () => {},
1121
+ }),
1122
+ ),
1123
+ );
1124
+
1125
+ fireEvent.mouseDown(view.container.querySelector('.ant-select-selector') as Element);
1126
+
1127
+ expect(await screen.findByText('Name')).toBeTruthy();
1128
+ expect(screen.queryByText('JS item')).toBeNull();
1129
+ });
1130
+
1059
1131
  it('should only show options state for supported single field selection', () => {
1060
1132
  const supportedField: any = {
1061
1133
  uid: 'status-field',
@@ -120,11 +120,16 @@ const isActionFlowModel = (model: any): boolean => {
120
120
  return false;
121
121
  };
122
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
+
123
128
  // 获取表单中所有字段的 model 实例的通用函数
124
129
  const getFormFields = (ctx: any) => {
125
130
  try {
126
131
  const fieldModels = ctx.model?.subModels?.grid?.subModels?.items || [];
127
- return fieldModels.map((model: any) => ({
132
+ return fieldModels.filter(isFormFieldModel).map((model) => ({
128
133
  label: model.props.label || model.props.name,
129
134
  value: model.uid,
130
135
  model,
@@ -346,8 +351,8 @@ const FieldStateEditor = ({
346
351
  const getFormFieldsByForkModel = (ctx: any) => {
347
352
  try {
348
353
  const fieldModels = ctx.model?.subModels?.grid?.subModels?.items || [];
349
- return fieldModels.map((model: any) => {
350
- 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;
351
356
 
352
357
  if (forkModel) {
353
358
  return {
@@ -660,6 +665,30 @@ export const linkageSetBlockProps = defineAction({
660
665
  },
661
666
  });
662
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
+
663
692
  export const linkageSetActionProps = defineAction({
664
693
  name: 'linkageSetActionProps',
665
694
  title: tExpr('Set button state'),
@@ -680,13 +709,7 @@ export const linkageSetActionProps = defineAction({
680
709
  onChange={onChange}
681
710
  placeholder={t('Please select state')}
682
711
  style={{ width: '100%' }}
683
- options={[
684
- { label: t('Visible'), value: 'visible' },
685
- { label: t('Hidden'), value: 'hidden' },
686
- { label: t('Hidden text'), value: 'hiddenText' },
687
- { label: t('Enabled'), value: 'enabled' },
688
- { label: t('Disabled'), value: 'disabled' },
689
- ]}
712
+ options={getActionLinkageStateOptions(ctx.model, t)}
690
713
  allowClear
691
714
  />
692
715
  );
@@ -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 }}',