@nocobase/client-v2 2.2.0-alpha.8 → 2.2.0-alpha.9

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 (41) hide show
  1. package/es/APIClient.d.ts +1 -3
  2. package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -1
  3. package/es/flow/actions/index.d.ts +1 -1
  4. package/es/flow/actions/linkageRules.d.ts +2 -0
  5. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  6. package/es/flow/components/filter/VariableFilterItem.d.ts +5 -1
  7. package/es/flow/models/base/PageModel/PageModel.d.ts +12 -0
  8. package/es/flow/models/base/PageModel/PageModelTabBar.d.ts +22 -0
  9. package/es/flow/models/base/PageModel/PageTabModel.d.ts +12 -0
  10. package/es/flow-compat/fieldValidationConstants.d.ts +1 -1
  11. package/es/flow-compat/routeTypes.d.ts +1 -0
  12. package/es/index.mjs +103 -102
  13. package/lib/index.js +117 -116
  14. package/lib/locale/languageCodes.js +2 -1
  15. package/package.json +7 -7
  16. package/src/APIClient.ts +1 -10
  17. package/src/Application.tsx +4 -0
  18. package/src/__tests__/app.test.tsx +18 -0
  19. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +28 -0
  20. package/src/collection-manager/field-validation.ts +1 -1
  21. package/src/components/form/ScanInput/CodeScanner.tsx +23 -6
  22. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +35 -1
  23. package/src/components/form/ScanInput/useCodeScanner.ts +16 -3
  24. package/src/flow/actions/__tests__/linkageRules.tab.test.ts +171 -0
  25. package/src/flow/actions/index.ts +2 -0
  26. package/src/flow/actions/linkageRules.tsx +86 -11
  27. package/src/flow/components/filter/VariableFilterItem.tsx +20 -5
  28. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +49 -1
  29. package/src/flow/models/base/PageModel/PageModel.tsx +387 -53
  30. package/src/flow/models/base/PageModel/PageModelTabBar.tsx +114 -0
  31. package/src/flow/models/base/PageModel/PageTabModel.tsx +184 -3
  32. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +790 -10
  33. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.module-isolation.test.tsx +130 -0
  34. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.test.tsx +118 -0
  35. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +572 -1
  36. package/src/flow/models/blocks/table/TableBlockModel.tsx +1 -1
  37. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +1 -1
  38. package/src/flow-compat/fieldValidationConstants.ts +1 -1
  39. package/src/flow-compat/routeTypes.ts +1 -0
  40. package/src/locale/languageCodes.ts +2 -1
  41. package/src/nocobase-buildin-plugin/index.tsx +12 -0
@@ -131,6 +131,10 @@ export interface VariableFilterItemProps {
131
131
  * 默认使用整棵 ctx 的 metaTree:model.context.getPropertyMetaTree()
132
132
  */
133
133
  rightMetaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
134
+ /**
135
+ * 右侧变量的领域转换器。常量和空值仍由 VariableFilterItem 处理,未匹配的值回退到 FlowEngine 默认转换器。
136
+ */
137
+ rightVariableConverters?: Pick<Converters, 'resolvePathFromValue' | 'resolveValueFromPath'>;
134
138
  ignoreFieldNames?: string[];
135
139
  maxAssociationFieldDepth?: number;
136
140
  }
@@ -352,7 +356,16 @@ function findMetaTreeNodeByPath(metaTree: MetaTreeNode[], targetPath: string[]):
352
356
  * 上下文筛选项组件
353
357
  */
354
358
  export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
355
- ({ value, model, disabled = false, rightAsVariable, rightMetaTree, ignoreFieldNames, maxAssociationFieldDepth }) => {
359
+ ({
360
+ value,
361
+ model,
362
+ disabled = false,
363
+ rightAsVariable,
364
+ rightMetaTree,
365
+ rightVariableConverters,
366
+ ignoreFieldNames,
367
+ maxAssociationFieldDepth,
368
+ }) => {
356
369
  // 使用 View 上下文,确保可访问 ctx.view 的异步子树
357
370
  const ctx = useFlowViewContext();
358
371
  const t = model.translate;
@@ -684,17 +697,19 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
684
697
  const first = meta?.paths?.[0];
685
698
  if (first === 'constant') return '';
686
699
  if (first === 'null') return null;
687
- return undefined; // 交给默认逻辑格式化变量表达式
700
+ return rightVariableConverters?.resolveValueFromPath?.(meta);
688
701
  },
689
702
  resolvePathFromValue: (val) => {
690
703
  if (val === null) return ['null'];
691
- // 变量表达式:使用内置解析;其他静态值走 constant
692
- const parsed = typeof val === 'string' ? parseValueToPath(val) : undefined;
704
+ // 变量表达式:优先使用领域转换器,再回退内置解析;其他静态值走 constant
705
+ const parsed =
706
+ rightVariableConverters?.resolvePathFromValue?.(val) ??
707
+ (typeof val === 'string' ? parseValueToPath(val) : undefined);
693
708
  if (parsed) return parsed;
694
709
  return ['constant'];
695
710
  },
696
711
  };
697
- }, [NullComponent, staticInputRenderer]);
712
+ }, [NullComponent, rightVariableConverters, staticInputRenderer]);
698
713
 
699
714
  // 为 2.0 左侧字段选择器追加“接口 filterable.children”定义(如 chinaRegion 的“省市区名称”子项),
700
715
  // 以恢复 1.0 左侧子菜单的能力。
@@ -11,7 +11,7 @@ import React from 'react';
11
11
  import { describe, it, expect, vi, beforeEach } from 'vitest';
12
12
  import { render, screen, fireEvent, waitFor } from '@testing-library/react';
13
13
  import { VariableFilterItem, VariableFilterItemValue } from '../VariableFilterItem';
14
- import { FlowEngine, FlowModel } from '@nocobase/flow-engine';
14
+ import { FlowEngine, FlowModel, type Converters, type MetaTreeNode } from '@nocobase/flow-engine';
15
15
  import { observable } from '@formily/reactive';
16
16
  import { createMockFlowApp, TestCollectionFieldInterface } from '../../../__tests__/helpers/mockFlowApp';
17
17
 
@@ -179,6 +179,54 @@ describe('VariableFilterItem', () => {
179
179
  expect(operatorSelect).toHaveClass('ant-select-disabled');
180
180
  });
181
181
 
182
+ it('composes custom right variable converters with constant, null, and core fallbacks', () => {
183
+ const value: VariableFilterItemValue = {
184
+ path: 'name',
185
+ operator: '$eq',
186
+ value: '{{$context.data.id}}',
187
+ };
188
+ const model = CreateModel();
189
+ const rightVariableConverters: Pick<Converters, 'resolvePathFromValue' | 'resolveValueFromPath'> = {
190
+ resolvePathFromValue: (input) => (input === '{{$context.data.id}}' ? ['$context', 'data', 'id'] : undefined),
191
+ resolveValueFromPath: (metaTreeNode) =>
192
+ metaTreeNode.paths?.[0] === '$context' ? `{{${metaTreeNode.paths.join('.')}}}` : undefined,
193
+ };
194
+
195
+ render(
196
+ <VariableFilterItem
197
+ value={value}
198
+ model={model}
199
+ rightAsVariable
200
+ rightVariableConverters={rightVariableConverters}
201
+ />,
202
+ );
203
+
204
+ const variableInputProps = (
205
+ globalThis as {
206
+ __LAST_VARIABLE_INPUT_PROPS__?: {
207
+ converters?: Converters;
208
+ value?: unknown;
209
+ };
210
+ }
211
+ ).__LAST_VARIABLE_INPUT_PROPS__;
212
+ const converters = variableInputProps?.converters;
213
+ const constantNode: MetaTreeNode = { name: 'constant', title: 'Constant', type: 'string', paths: ['constant'] };
214
+ const nullNode: MetaTreeNode = { name: 'null', title: 'Null', type: 'object', paths: ['null'] };
215
+ const workflowNode: MetaTreeNode = {
216
+ name: 'id',
217
+ title: 'ID',
218
+ type: 'number',
219
+ paths: ['$context', 'data', 'id'],
220
+ };
221
+
222
+ expect(variableInputProps?.value).toBe('{{$context.data.id}}');
223
+ expect(converters?.resolvePathFromValue?.('{{$context.data.id}}')).toEqual(['$context', 'data', 'id']);
224
+ expect(converters?.resolvePathFromValue?.('{{ ctx.$context.data.id }}')).toEqual(['$context', 'data', 'id']);
225
+ expect(converters?.resolveValueFromPath?.(constantNode)).toBe('');
226
+ expect(converters?.resolveValueFromPath?.(nullNode)).toBeNull();
227
+ expect(converters?.resolveValueFromPath?.(workflowNode)).toBe('{{$context.data.id}}');
228
+ });
229
+
182
230
  it('uses scoped context dataSourceManager when app dataSourceManager has no field interface manager', async () => {
183
231
  const value = observable({ path: '', operator: '', value: '' }) as any;
184
232
  const model = CreateModel();
@@ -33,6 +33,7 @@ import { commonConditionHandler, ConditionBuilder } from '../../../components/Co
33
33
  import { TextAreaWithContextSelector } from '../../../components/TextAreaWithContextSelector';
34
34
  import { confirmUnsavedChangesHandler } from './closeGuard';
35
35
  import { BasePageTabModel } from './PageTabModel';
36
+ import { FilteredPageTabBar, NO_ACTIVE_PAGE_TAB_KEY } from './PageModelTabBar';
36
37
 
37
38
  type PageModelStructure = {
38
39
  subModels: {
@@ -49,6 +50,54 @@ type PageModelContextWithRoute = {
49
50
  currentRoute?: CurrentRouteWithTabs | null;
50
51
  };
51
52
 
53
+ type RequestedTabKey = {
54
+ key?: string;
55
+ source: 'url' | 'implicit' | 'props' | 'none';
56
+ };
57
+
58
+ type TabActiveResolution = RequestedTabKey & {
59
+ status: 'unspecified' | 'visible' | 'hidden' | 'unknown';
60
+ effectiveKey?: string;
61
+ allTabs: BasePageTabModel[];
62
+ unhiddenTabs: BasePageTabModel[];
63
+ };
64
+
65
+ type TabActiveKeySyncState = {
66
+ previousEffectiveActiveKey?: string;
67
+ effectiveActiveKey?: string;
68
+ previousAllHidden: boolean;
69
+ lastCorrectionSignature?: string;
70
+ };
71
+
72
+ type TabActiveKeySyncProps = {
73
+ dependencyKey: string;
74
+ onSync: (state: TabActiveKeySyncState) => TabActiveKeySyncState;
75
+ };
76
+
77
+ type LocallyCommittedTabTransition = {
78
+ activeKey: string;
79
+ previousActiveKey?: string;
80
+ };
81
+
82
+ function TabActiveKeySync({ dependencyKey, onSync }: TabActiveKeySyncProps) {
83
+ const previousEffectiveActiveKey = React.useRef<string>();
84
+ const previousAllHidden = React.useRef(false);
85
+ const lastCorrectionSignature = React.useRef<string>();
86
+
87
+ React.useEffect(() => {
88
+ const nextState = onSync({
89
+ previousEffectiveActiveKey: previousEffectiveActiveKey.current,
90
+ previousAllHidden: previousAllHidden.current,
91
+ lastCorrectionSignature: lastCorrectionSignature.current,
92
+ });
93
+ previousEffectiveActiveKey.current = nextState.effectiveActiveKey;
94
+ previousAllHidden.current = nextState.previousAllHidden;
95
+ lastCorrectionSignature.current = nextState.lastCorrectionSignature;
96
+ }, [dependencyKey, onSync]);
97
+
98
+ return null;
99
+ }
100
+
52
101
  export class PageModel extends FlowModel<PageModelStructure> {
53
102
  tabBarExtraContent: { left?: ReactNode; right?: ReactNode } = {};
54
103
  private viewActivatedListener?: (_payload?: unknown) => void;
@@ -57,6 +106,8 @@ export class PageModel extends FlowModel<PageModelStructure> {
57
106
  private dirtyRefreshScheduled = false;
58
107
  private unmounted = false;
59
108
  private documentTitleUpdateVersion = 0;
109
+ private implicitActiveKey?: string;
110
+ private locallyCommittedTabTransition?: LocallyCommittedTabTransition;
60
111
 
61
112
  /**
62
113
  * 根页面标签页开关以路由表为准,避免 flow model 里的旧配置覆盖路由管理设置。
@@ -75,14 +126,277 @@ export class PageModel extends FlowModel<PageModelStructure> {
75
126
  return !!this.props.enableTabs;
76
127
  }
77
128
 
78
- private getActiveTabKey(): string | undefined {
129
+ private getAllTabs(): BasePageTabModel[] {
130
+ return this.subModels?.tabs || [];
131
+ }
132
+
133
+ private getUnhiddenTabs(): BasePageTabModel[] {
134
+ return this.getAllTabs().filter((tab) => !tab.hidden);
135
+ }
136
+
137
+ private getRequestedTabKey(): RequestedTabKey {
79
138
  const viewParams = this.context.view?.navigation?.viewParams;
80
139
  if (viewParams) {
81
- return viewParams.tabUid || this.getFirstTab()?.uid;
140
+ const urlKey = typeof viewParams.tabUid === 'string' && viewParams.tabUid ? viewParams.tabUid : undefined;
141
+ if (urlKey) {
142
+ this.implicitActiveKey = undefined;
143
+ return { key: urlKey, source: 'url' };
144
+ }
145
+
146
+ const activeKey =
147
+ typeof this.props.tabActiveKey === 'string' && this.props.tabActiveKey ? this.props.tabActiveKey : undefined;
148
+ if (activeKey && activeKey === this.implicitActiveKey) {
149
+ return { key: activeKey, source: 'implicit' };
150
+ }
151
+ return { source: 'none' };
152
+ }
153
+
154
+ const activeKey =
155
+ typeof this.props.tabActiveKey === 'string' && this.props.tabActiveKey ? this.props.tabActiveKey : undefined;
156
+ if (!activeKey) {
157
+ return { source: 'none' };
158
+ }
159
+ return { key: activeKey, source: activeKey === this.implicitActiveKey ? 'implicit' : 'props' };
160
+ }
161
+
162
+ private resolveTabActiveState(): TabActiveResolution {
163
+ const allTabs = this.getAllTabs();
164
+ const unhiddenTabs = this.getUnhiddenTabs();
165
+ const requested = this.getRequestedTabKey();
166
+
167
+ if (!requested.key) {
168
+ return {
169
+ ...requested,
170
+ status: 'unspecified',
171
+ effectiveKey: unhiddenTabs[0]?.uid,
172
+ allTabs,
173
+ unhiddenTabs,
174
+ };
175
+ }
176
+
177
+ const requestedTab = allTabs.find((tab) => tab.uid === requested.key);
178
+ if (requested.source === 'implicit' && (!requestedTab || requestedTab.hidden)) {
179
+ return {
180
+ ...requested,
181
+ status: 'unspecified',
182
+ effectiveKey: unhiddenTabs[0]?.uid,
183
+ allTabs,
184
+ unhiddenTabs,
185
+ };
186
+ }
187
+
188
+ if (!requestedTab) {
189
+ return {
190
+ ...requested,
191
+ status: 'unknown',
192
+ effectiveKey: requested.key,
193
+ allTabs,
194
+ unhiddenTabs,
195
+ };
196
+ }
197
+
198
+ if (unhiddenTabs.some((tab) => tab.uid === requested.key)) {
199
+ return {
200
+ ...requested,
201
+ status: 'visible',
202
+ effectiveKey: requested.key,
203
+ allTabs,
204
+ unhiddenTabs,
205
+ };
206
+ }
207
+
208
+ return {
209
+ ...requested,
210
+ status: 'hidden',
211
+ effectiveKey: requested.key,
212
+ allTabs,
213
+ unhiddenTabs,
214
+ };
215
+ }
216
+
217
+ private getActiveTabKey(): string | undefined {
218
+ if (!this.getEnableTabs()) {
219
+ const firstTabKey = this.getFirstTab()?.uid;
220
+ if (firstTabKey) {
221
+ return firstTabKey;
222
+ }
223
+ }
224
+ return this.resolveTabActiveState().effectiveKey;
225
+ }
226
+
227
+ private getTabActiveKeySyncDependency() {
228
+ const allTabs = this.getAllTabs();
229
+ const unhiddenTabs = this.getUnhiddenTabs();
230
+ const viewParams = this.context.view?.navigation?.viewParams;
231
+ return JSON.stringify([
232
+ !!this.context.flowSettingsEnabled,
233
+ viewParams ? viewParams.tabUid || null : null,
234
+ this.props.tabActiveKey || null,
235
+ allTabs.map((tab) => tab.uid),
236
+ unhiddenTabs.map((tab) => tab.uid),
237
+ this.implicitActiveKey || null,
238
+ ]);
239
+ }
240
+
241
+ private requestDocumentTitleUpdate(preferredActiveTabKey?: string, retryCount = 0) {
242
+ this.updateDocumentTitle(preferredActiveTabKey, retryCount).catch((error) => {
243
+ console.warn('[PageModel] Failed to update document title', error);
244
+ });
245
+ }
246
+
247
+ private rememberImplicitActiveKey(activeKey: string) {
248
+ this.implicitActiveKey = activeKey;
249
+ if (this.props.tabActiveKey !== activeKey) {
250
+ this.setProps('tabActiveKey', activeKey);
251
+ }
252
+ }
253
+
254
+ private commitTabActiveKey(
255
+ activeKey: string | undefined,
256
+ options: {
257
+ previousActiveKey?: string;
258
+ navigate: boolean;
259
+ preserveImplicitKey?: boolean;
260
+ rememberLocalTransition?: boolean;
261
+ },
262
+ ) {
263
+ const { previousActiveKey, navigate, preserveImplicitKey = false, rememberLocalTransition = false } = options;
264
+ if (rememberLocalTransition && activeKey) {
265
+ this.locallyCommittedTabTransition = { activeKey, previousActiveKey };
266
+ }
267
+ this.implicitActiveKey = activeKey && preserveImplicitKey ? activeKey : undefined;
268
+
269
+ if (navigate) {
270
+ this.context.view?.navigation?.changeTo?.({ tabUid: activeKey });
271
+ }
272
+
273
+ if (activeKey && activeKey !== previousActiveKey) {
274
+ this.invokeTabModelLifecycleMethod(activeKey, 'onActive');
275
+ }
276
+ if (previousActiveKey && previousActiveKey !== activeKey) {
277
+ this.invokeTabModelLifecycleMethod(previousActiveKey, 'onInactive');
278
+ }
279
+ this.setProps('tabActiveKey', activeKey);
280
+
281
+ if (!activeKey) {
282
+ this.requestDocumentTitleUpdate();
283
+ }
284
+ }
285
+
286
+ private consumeLocallyCommittedTabTransition(previousActiveKey: string | undefined, activeKey: string | undefined) {
287
+ const transition = this.locallyCommittedTabTransition;
288
+ if (!transition || !activeKey) {
289
+ return false;
290
+ }
291
+ if (transition.activeKey === activeKey && transition.previousActiveKey === previousActiveKey) {
292
+ this.locallyCommittedTabTransition = undefined;
293
+ return true;
82
294
  }
83
- return this.props.tabActiveKey || this.getFirstTab()?.uid;
295
+ if (activeKey !== transition.previousActiveKey) {
296
+ this.locallyCommittedTabTransition = undefined;
297
+ }
298
+ return false;
84
299
  }
85
300
 
301
+ private synchronizeTabActiveKey = (state: TabActiveKeySyncState): TabActiveKeySyncState => {
302
+ const resolution = this.resolveTabActiveState();
303
+ const allHidden = resolution.allTabs.length > 0 && resolution.unhiddenTabs.length === 0;
304
+ const createNextState = (effectiveActiveKey: string | undefined, lastCorrectionSignature?: string) => ({
305
+ previousEffectiveActiveKey: effectiveActiveKey,
306
+ effectiveActiveKey,
307
+ previousAllHidden: allHidden,
308
+ lastCorrectionSignature,
309
+ });
310
+
311
+ if (resolution.status === 'unknown') {
312
+ return createNextState(resolution.effectiveKey);
313
+ }
314
+
315
+ if (resolution.status === 'visible') {
316
+ const activeKey = resolution.effectiveKey;
317
+ if (this.consumeLocallyCommittedTabTransition(state.previousEffectiveActiveKey, activeKey)) {
318
+ return createNextState(activeKey);
319
+ }
320
+ const shouldActivateAfterRestore = state.previousAllHidden && resolution.source !== 'url';
321
+ const shouldSyncChangedVisibleKey =
322
+ !!state.previousEffectiveActiveKey && state.previousEffectiveActiveKey !== activeKey;
323
+ if ((shouldActivateAfterRestore || shouldSyncChangedVisibleKey) && activeKey) {
324
+ const signature = `visible:${resolution.source}:${state.previousEffectiveActiveKey || ''}->${activeKey}`;
325
+ if (signature !== state.lastCorrectionSignature) {
326
+ this.commitTabActiveKey(activeKey, {
327
+ previousActiveKey: state.previousEffectiveActiveKey,
328
+ navigate: false,
329
+ preserveImplicitKey: resolution.source === 'implicit',
330
+ });
331
+ }
332
+ return createNextState(activeKey, signature);
333
+ }
334
+ return createNextState(activeKey);
335
+ }
336
+
337
+ if (resolution.status === 'hidden') {
338
+ const activeKey = resolution.effectiveKey;
339
+ if (this.consumeLocallyCommittedTabTransition(state.previousEffectiveActiveKey, activeKey)) {
340
+ return createNextState(activeKey);
341
+ }
342
+ if (activeKey && state.previousEffectiveActiveKey !== activeKey) {
343
+ const signature = `hidden:${resolution.source}:${state.previousEffectiveActiveKey || ''}->${activeKey}`;
344
+ if (signature !== state.lastCorrectionSignature) {
345
+ this.commitTabActiveKey(activeKey, {
346
+ previousActiveKey: state.previousEffectiveActiveKey,
347
+ navigate: false,
348
+ });
349
+ }
350
+ return createNextState(activeKey, signature);
351
+ }
352
+ return createNextState(activeKey);
353
+ }
354
+
355
+ const activeKey = resolution.effectiveKey;
356
+ if (!activeKey) {
357
+ if (!allHidden) {
358
+ if (resolution.source !== 'implicit' || !resolution.key) {
359
+ return createNextState(undefined);
360
+ }
361
+ const signature = `implicit-missing:${state.previousEffectiveActiveKey || ''}`;
362
+ if (signature !== state.lastCorrectionSignature) {
363
+ this.commitTabActiveKey(undefined, {
364
+ previousActiveKey: state.previousEffectiveActiveKey,
365
+ navigate: false,
366
+ });
367
+ }
368
+ return createNextState(undefined, signature);
369
+ }
370
+ const signature = `all-hidden:${state.previousEffectiveActiveKey || ''}`;
371
+ if (!state.previousAllHidden && signature !== state.lastCorrectionSignature) {
372
+ this.implicitActiveKey = undefined;
373
+ if (this.props.tabActiveKey || state.previousEffectiveActiveKey) {
374
+ this.commitTabActiveKey(undefined, {
375
+ previousActiveKey: state.previousEffectiveActiveKey,
376
+ navigate: false,
377
+ });
378
+ } else {
379
+ this.requestDocumentTitleUpdate();
380
+ }
381
+ }
382
+ return createNextState(undefined, signature);
383
+ }
384
+
385
+ const shouldSwitchImplicitActiveKey =
386
+ state.previousAllHidden || (!!state.previousEffectiveActiveKey && state.previousEffectiveActiveKey !== activeKey);
387
+ const signature = `implicit:${state.previousEffectiveActiveKey || ''}->${activeKey}`;
388
+ if (shouldSwitchImplicitActiveKey && signature !== state.lastCorrectionSignature) {
389
+ this.commitTabActiveKey(activeKey, {
390
+ previousActiveKey: state.previousEffectiveActiveKey,
391
+ navigate: false,
392
+ preserveImplicitKey: true,
393
+ });
394
+ } else {
395
+ this.rememberImplicitActiveKey(activeKey);
396
+ }
397
+ return createNextState(activeKey, shouldSwitchImplicitActiveKey ? signature : undefined);
398
+ };
399
+
86
400
  private scheduleActiveLifecycleRefresh(forceRefresh = false): void {
87
401
  if (this.dirtyRefreshScheduled) return;
88
402
  this.dirtyRefreshScheduled = true;
@@ -107,7 +421,7 @@ export class PageModel extends FlowModel<PageModelStructure> {
107
421
  }
108
422
 
109
423
  deactivateCurrentTab() {
110
- const activeKey = this.props.tabActiveKey || this.getFirstTab()?.uid;
424
+ const activeKey = this.getActiveTabKey();
111
425
  if (activeKey) {
112
426
  this.invokeTabModelLifecycleMethod(activeKey, 'onInactive');
113
427
  }
@@ -116,9 +430,10 @@ export class PageModel extends FlowModel<PageModelStructure> {
116
430
  onMount(): void {
117
431
  super.onMount();
118
432
  this.unmounted = false;
433
+ this.implicitActiveKey = undefined;
119
434
  this.setProps('tabActiveKey', this.context.view.inputArgs?.tabUid);
120
435
  if (this.context?.pageInfo) this.context.pageInfo.version = 'v2';
121
- void this.updateDocumentTitle();
436
+ this.requestDocumentTitleUpdate();
122
437
 
123
438
  // When a nested view (popup/page) is closed, the opener view becomes active again.
124
439
  // We align this with the existing tab lifecycle by invoking `onActive` for the current tab blocks.
@@ -189,7 +504,7 @@ export class PageModel extends FlowModel<PageModelStructure> {
189
504
  }
190
505
 
191
506
  if (method === 'onActive') {
192
- void this.updateDocumentTitle(tabActiveKey);
507
+ this.requestDocumentTitleUpdate(tabActiveKey);
193
508
  }
194
509
  }
195
510
 
@@ -236,11 +551,15 @@ export class PageModel extends FlowModel<PageModelStructure> {
236
551
  }
237
552
  };
238
553
 
554
+ const unhiddenTabs = this.getUnhiddenTabs();
555
+ const activeTabKey = preferredActiveTabKey || this.getActiveTabKey();
556
+ const shouldUsePageTitle = !this.getEnableTabs() || (!activeTabKey && unhiddenTabs.length === 0);
557
+
239
558
  let nextTitle = '';
240
- if (this.getEnableTabs()) {
241
- const activeTabKey = preferredActiveTabKey || this.getActiveTabKey();
559
+ if (!shouldUsePageTitle) {
242
560
  const activeTabModel = activeTabKey
243
- ? (this.flowEngine.getModel(activeTabKey) as BasePageTabModel | undefined)
561
+ ? this.findSubModel('tabs', (model) => model.uid === activeTabKey) ||
562
+ (this.flowEngine.getModel(activeTabKey) as BasePageTabModel | undefined)
244
563
  : this.getFirstTab();
245
564
  if (!activeTabModel && retryCount < 5) {
246
565
  window.setTimeout(() => {
@@ -251,7 +570,7 @@ export class PageModel extends FlowModel<PageModelStructure> {
251
570
  if (updateVersion !== this.documentTitleUpdateVersion) {
252
571
  return;
253
572
  }
254
- void this.updateDocumentTitle(activeTabKey, retryCount + 1);
573
+ this.requestDocumentTitleUpdate(activeTabKey, retryCount + 1);
255
574
  }, 0);
256
575
  return;
257
576
  }
@@ -293,33 +612,31 @@ export class PageModel extends FlowModel<PageModelStructure> {
293
612
 
294
613
  mapTabs() {
295
614
  return this.mapSubModels('tabs', (model) => {
296
- return !this.context.flowSettingsEnabled && model.hidden
297
- ? null
298
- : {
299
- key: model.uid,
300
- label: (
301
- <Droppable model={model}>
302
- <FlowModelRenderer
303
- model={model}
304
- showFlowSettings={{
305
- showBackground: true,
306
- showBorder: false,
307
- toolbarPosition: 'above',
308
- style: { transform: 'translateY(8px)' },
309
- }}
310
- extraToolbarItems={[
311
- {
312
- key: 'drag-handler',
313
- component: DragHandler,
314
- sort: 1,
315
- },
316
- ]}
317
- />
318
- </Droppable>
319
- ),
320
- children: model.renderChildren(),
321
- };
322
- }).filter(Boolean);
615
+ return {
616
+ key: model.uid,
617
+ label: (
618
+ <Droppable model={model}>
619
+ <FlowModelRenderer
620
+ model={model}
621
+ showFlowSettings={{
622
+ showBackground: true,
623
+ showBorder: false,
624
+ toolbarPosition: 'above',
625
+ style: { transform: 'translateY(8px)' },
626
+ }}
627
+ extraToolbarItems={[
628
+ {
629
+ key: 'drag-handler',
630
+ component: DragHandler,
631
+ sort: 1,
632
+ },
633
+ ]}
634
+ />
635
+ </Droppable>
636
+ ),
637
+ children: model.renderChildren(),
638
+ };
639
+ });
323
640
  }
324
641
 
325
642
  getFirstTab() {
@@ -336,6 +653,17 @@ export class PageModel extends FlowModel<PageModelStructure> {
336
653
  }
337
654
 
338
655
  renderTabs() {
656
+ const activeState = this.resolveTabActiveState();
657
+ const tabItems = this.mapTabs();
658
+ const hiddenTabKeys = new Set(
659
+ this.context.flowSettingsEnabled ? [] : activeState.allTabs.filter((tab) => tab.hidden).map((tab) => tab.uid),
660
+ );
661
+ const hiddenActiveTabLabel =
662
+ activeState.status === 'hidden'
663
+ ? activeState.allTabs.find((tab) => tab.uid === activeState.effectiveKey)?.getTabTitle?.()
664
+ : undefined;
665
+ const tabsActiveKey =
666
+ activeState.effectiveKey || (activeState.allTabs.length > 0 ? NO_ACTIVE_PAGE_TAB_KEY : undefined);
339
667
  const tabNavPaddingInlineStart = this.context.themeToken?.paddingLG ?? 16;
340
668
  const leftExtraContent =
341
669
  this.tabBarExtraContent.left !== undefined ? (
@@ -366,25 +694,31 @@ export class PageModel extends FlowModel<PageModelStructure> {
366
694
 
367
695
  return (
368
696
  <DndProvider onDragEnd={this.handleDragEnd.bind(this)}>
697
+ <TabActiveKeySync dependencyKey={this.getTabActiveKeySyncDependency()} onSync={this.synchronizeTabActiveKey} />
369
698
  <Tabs
370
- activeKey={
371
- this.context.view?.navigation?.viewParams
372
- ? this.context.view.navigation.viewParams.tabUid || this.getFirstTab()?.uid
373
- : this.props.tabActiveKey
374
- }
699
+ activeKey={tabsActiveKey}
375
700
  tabBarStyle={this.props.tabBarStyle}
376
- items={this.mapTabs()}
701
+ items={tabItems}
702
+ renderTabBar={
703
+ hiddenTabKeys.size > 0
704
+ ? (tabBarProps) => (
705
+ <FilteredPageTabBar
706
+ hiddenTabKeys={hiddenTabKeys}
707
+ hiddenActiveTabLabel={hiddenActiveTabLabel}
708
+ items={tabItems}
709
+ tabBarProps={tabBarProps}
710
+ />
711
+ )
712
+ : undefined
713
+ }
377
714
  onChange={(activeKey) => {
378
- const previousActiveKey = this.props.tabActiveKey || this.getActiveTabKey();
379
- this.context.view.navigation?.changeTo?.({
380
- tabUid: activeKey,
715
+ const previousActiveKey = this.getActiveTabKey();
716
+ this.implicitActiveKey = undefined;
717
+ this.commitTabActiveKey(activeKey, {
718
+ previousActiveKey,
719
+ navigate: !!this.context.view?.navigation,
720
+ rememberLocalTransition: true,
381
721
  });
382
-
383
- this.invokeTabModelLifecycleMethod(activeKey, 'onActive');
384
- if (previousActiveKey && previousActiveKey !== activeKey) {
385
- this.invokeTabModelLifecycleMethod(previousActiveKey, 'onInactive');
386
- }
387
- this.setProps('tabActiveKey', activeKey);
388
722
  }}
389
723
  // destroyInactiveTabPane
390
724
  tabBarExtraContent={{
@@ -527,7 +861,7 @@ PageModel.registerFlow({
527
861
  marginBottom: 0,
528
862
  });
529
863
  }
530
- void (ctx.model as PageModel).updateDocumentTitle();
864
+ await (ctx.model as PageModel).updateDocumentTitle();
531
865
  },
532
866
  },
533
867
  },