@nocobase/client-v2 2.1.28 → 2.1.30

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 (25) hide show
  1. package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -2
  2. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  3. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  4. package/es/index.mjs +97 -94
  5. package/lib/index.js +89 -86
  6. package/package.json +7 -7
  7. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +7 -2
  8. package/src/components/form/ScanInput/CodeScanner.tsx +61 -34
  9. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
  10. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +114 -18
  11. package/src/components/form/ScanInput/useCodeScanner.ts +166 -23
  12. package/src/flow/__tests__/FlowRoute.test.tsx +172 -11
  13. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +115 -0
  14. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  15. package/src/flow/actions/linkageRules.tsx +24 -12
  16. package/src/flow/components/BlockItemCard.tsx +2 -2
  17. package/src/flow/components/FlowRoute.tsx +72 -4
  18. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  19. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  20. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
  21. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
  22. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  23. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  24. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  25. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
@@ -225,6 +225,108 @@ describe('ensureFormValueDrivenDataScopeClear', () => {
225
225
  expect(onChange).toHaveBeenCalledWith(null);
226
226
  });
227
227
 
228
+ it('does not clear a popup form field when data scope depends on the external parent item', () => {
229
+ const emitter = new EventEmitter();
230
+ const formBlock = {
231
+ uid: 'form-1',
232
+ disposed: false,
233
+ emitter,
234
+ context: {
235
+ form: {},
236
+ formValues: { staff_m2o: null },
237
+ },
238
+ };
239
+
240
+ const onChange = vi.fn();
241
+ const model: any = {
242
+ disposed: false,
243
+ props: {
244
+ value: { id: 10 },
245
+ onChange,
246
+ },
247
+ context: {
248
+ blockModel: formBlock,
249
+ },
250
+ };
251
+
252
+ const ctx: any = {
253
+ model,
254
+ flowKey: 'selectSettings',
255
+ };
256
+
257
+ const filter = {
258
+ logic: '$and',
259
+ items: [{ path: 'orgId', operator: '$eq', value: '{{ ctx.item.parentItem.value.org_m2o.id }}' }],
260
+ };
261
+
262
+ ensureFormValueDrivenDataScopeClear(ctx, filter);
263
+
264
+ emitter.emit('formValuesChange', {
265
+ changedPaths: [['staff_m2o']],
266
+ allValues: { staff_m2o: { id: 10 } },
267
+ });
268
+
269
+ expect(onChange).not.toHaveBeenCalled();
270
+ });
271
+
272
+ it('maps popup current item dependencies to the popup form root', () => {
273
+ const emitter = new EventEmitter();
274
+ const formBlock = {
275
+ uid: 'form-1',
276
+ disposed: false,
277
+ emitter,
278
+ context: {
279
+ form: {},
280
+ formValues: {
281
+ org_m2o: { id: 1 },
282
+ staff_m2o: { id: 10 },
283
+ },
284
+ },
285
+ };
286
+
287
+ const onChange = vi.fn();
288
+ const model: any = {
289
+ disposed: false,
290
+ props: {
291
+ value: { id: 10 },
292
+ onChange,
293
+ },
294
+ context: {
295
+ blockModel: formBlock,
296
+ },
297
+ };
298
+
299
+ const ctx: any = {
300
+ model,
301
+ flowKey: 'selectSettings',
302
+ };
303
+
304
+ const filter = {
305
+ logic: '$and',
306
+ items: [{ path: 'orgId', operator: '$eq', value: '{{ ctx.item.value.org_m2o.id }}' }],
307
+ };
308
+
309
+ ensureFormValueDrivenDataScopeClear(ctx, filter);
310
+
311
+ emitter.emit('formValuesChange', {
312
+ changedPaths: [['staff_m2o']],
313
+ allValues: {
314
+ org_m2o: { id: 1 },
315
+ staff_m2o: { id: 10 },
316
+ },
317
+ });
318
+ expect(onChange).not.toHaveBeenCalled();
319
+
320
+ emitter.emit('formValuesChange', {
321
+ changedPaths: [['org_m2o']],
322
+ allValues: {
323
+ org_m2o: { id: 2 },
324
+ staff_m2o: { id: 10 },
325
+ },
326
+ });
327
+ expect(onChange).toHaveBeenCalledWith(null);
328
+ });
329
+
228
330
  it('does not clear a row field when a sibling field changes but the item dependency value is unchanged', () => {
229
331
  const emitter = new EventEmitter();
230
332
  const formBlock = {
@@ -2174,17 +2174,30 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
2174
2174
  }
2175
2175
  : props;
2176
2176
 
2177
- // 存储原始值,用于恢复
2178
- if (!model.__originalProps) {
2179
- model.__originalProps = {
2180
- hiddenModel: model.hidden,
2181
- hiddenText: undefined,
2182
- disabled: undefined,
2183
- required: undefined,
2184
- hidden: undefined,
2185
- ...model.props,
2186
- };
2187
- }
2177
+ // 只记录联动实际控制的属性,避免恢复状态时覆盖标题等无关的最新配置。
2178
+ const originalProps = model.__originalProps || (model.__originalProps = {});
2179
+ const rememberOriginalProp = (key: string, value: unknown) => {
2180
+ if (!Object.prototype.hasOwnProperty.call(originalProps, key)) {
2181
+ originalProps[key] = value;
2182
+ }
2183
+ };
2184
+ Object.keys(normalizedProps || {}).forEach((key) => {
2185
+ if (key === 'hiddenModel') {
2186
+ rememberOriginalProp(
2187
+ key,
2188
+ Object.prototype.hasOwnProperty.call(model.props || {}, key) ? model.props?.[key] : model.hidden,
2189
+ );
2190
+ return;
2191
+ }
2192
+
2193
+ rememberOriginalProp(key, model.props?.[key]);
2194
+ if (key === 'hiddenText' && normalizedProps[key]) {
2195
+ rememberOriginalProp('title', model.props?.title);
2196
+ }
2197
+ if (key === 'required') {
2198
+ rememberOriginalProp('rules', model.props?.rules);
2199
+ }
2200
+ });
2188
2201
 
2189
2202
  // 临时存起来,遍历完所有规则后,再统一处理
2190
2203
  patchPropsByModel.set(model, {
@@ -2242,7 +2255,6 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
2242
2255
  const newProps = { ...model.__originalProps, ...patchProps };
2243
2256
  const prevHidden = !!model.hidden;
2244
2257
  const nextHidden = !!newProps.hiddenModel;
2245
-
2246
2258
  model.setProps(_.omit(newProps, ['hiddenModel', 'value', 'hiddenText', LINKAGE_ASSIGN_MODE_PROP]));
2247
2259
  syncFieldOptionsToForks(model, patchProps);
2248
2260
  if (typeof model.setHidden === 'function') {
@@ -150,7 +150,7 @@ export const BlockItemCard = React.forwardRef(
150
150
  ) => {
151
151
  const { t } = useTranslation();
152
152
  const { token } = theme.useToken();
153
- const { title: blockTitle, description, children, className, heightMode, ...rest } = props;
153
+ const { title: blockTitle, description, children, className, heightMode, style, ...rest } = props;
154
154
  const cardRef = useRef<HTMLDivElement | null>(null);
155
155
  const setCardRef = useCallback(
156
156
  (node: HTMLDivElement | null) => {
@@ -185,7 +185,7 @@ export const BlockItemCard = React.forwardRef(
185
185
  <Card
186
186
  ref={setCardRef as any}
187
187
  title={title}
188
- style={{ display: 'flex', flexDirection: 'column', height: height }}
188
+ style={{ display: 'flex', flexDirection: 'column', ...style, height: height ?? style?.height }}
189
189
  styles={{
190
190
  body: { flex: 1, display: 'flex', flexDirection: 'column', overflow: 'auto' },
191
191
  header: {
@@ -8,8 +8,11 @@
8
8
  */
9
9
 
10
10
  import { type FlowEngine, useFlowContext, useFlowEngine } from '@nocobase/flow-engine';
11
+ import { Button, Result } from 'antd';
11
12
  import React, { useEffect, useMemo, useRef, useState } from 'react';
12
- import { useParams } from 'react-router-dom';
13
+ import { useTranslation } from 'react-i18next';
14
+ import { useLocation, useParams } from 'react-router-dom';
15
+ import { getModernClientPrefix, stripModernClientPrefix } from '../../authRedirect';
13
16
  import { useApp } from '../../hooks/useApp';
14
17
  import { NocoBaseDesktopRouteType } from '../../flow-compat';
15
18
  import { resolveAdminRouteRuntimeTarget } from '../admin-shell/admin-layout/resolveAdminRouteRuntimeTarget';
@@ -24,6 +27,7 @@ type FlowRouteGuardState = {
24
27
  pending: boolean;
25
28
  allowBridge: boolean;
26
29
  notFound: boolean;
30
+ legacyPageUnsupported?: boolean;
27
31
  };
28
32
 
29
33
  export type LegacyPageBehavior = 'redirect' | 'notFound' | 'bridge';
@@ -111,6 +115,53 @@ const BridgeFlowRoute = ({
111
115
  return <div ref={layoutContentRef} />;
112
116
  };
113
117
 
118
+ type RouteLocation = {
119
+ pathname: string;
120
+ search: string;
121
+ hash: string;
122
+ };
123
+
124
+ const getLegacyPageHref = (app: { getPublicPath: () => string }, location: RouteLocation) => {
125
+ const modernPublicPath = app.getPublicPath();
126
+ const browserLocationMatchesPublicPath = window.location.pathname.startsWith(modernPublicPath);
127
+ const currentLocation = browserLocationMatchesPublicPath ? window.location : location;
128
+ const pathWithinModernClient = currentLocation.pathname.startsWith(modernPublicPath)
129
+ ? currentLocation.pathname.slice(modernPublicPath.length)
130
+ : currentLocation.pathname.replace(/^\/+/, '');
131
+ return `${stripModernClientPrefix(modernPublicPath)}${pathWithinModernClient}${currentLocation.search}${
132
+ currentLocation.hash
133
+ }`;
134
+ };
135
+
136
+ const LegacyPageUnsupported = ({
137
+ app,
138
+ location,
139
+ }: {
140
+ app: { getPublicPath: () => string };
141
+ location: RouteLocation;
142
+ }) => {
143
+ const { t } = useTranslation();
144
+ const modernClientPath = `/${getModernClientPrefix()}/`;
145
+ const withModernClientPath = (message: string) => message.replaceAll('{{modernClientPath}}', modernClientPath);
146
+
147
+ return (
148
+ <Result
149
+ status="warning"
150
+ title={withModernClientPath(t('This page is not supported in the {{modernClientPath}} branch'))}
151
+ subTitle={withModernClientPath(
152
+ t(
153
+ 'The {{modernClientPath}} branch only supports new pages. This page is a legacy page. Please open it from the original entry.',
154
+ ),
155
+ )}
156
+ extra={
157
+ <Button href={getLegacyPageHref(app, location)} type="primary">
158
+ {t('Open from the original entry')}
159
+ </Button>
160
+ }
161
+ />
162
+ );
163
+ };
164
+
114
165
  /**
115
166
  * 管理后台动态页面路由组件。
116
167
  *
@@ -136,6 +187,7 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
136
187
  const legacyPageBehavior = props.legacyPageBehavior || getDefaultLegacyPageBehavior(flowEngine, contextLayout);
137
188
  const app = useApp();
138
189
  const routeRepository = flowEngine.context.routeRepository;
190
+ const location = useLocation();
139
191
  const params = useParams();
140
192
  const pageUid = props.pageUid || params?.name;
141
193
  const skipRouteRepositoryCheck = !routeRepository;
@@ -174,7 +226,9 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
174
226
  }
175
227
 
176
228
  const route = skipRouteRepositoryCheck ? undefined : routeRepository?.getRouteBySchemaUid?.(pageUid);
177
- if (!route && legacyPageBehavior === 'notFound') {
229
+ const shouldCheckFlowModel =
230
+ legacyPageBehavior === 'notFound' || (!skipRouteRepositoryCheck && legacyPageBehavior === 'redirect');
231
+ if (!route && shouldCheckFlowModel) {
178
232
  const flowModelExists = await hasFlowModel(flowEngine, pageUid);
179
233
  if (active && requestId === requestIdRef.current) {
180
234
  setGuardState({ pending: false, allowBridge: flowModelExists, notFound: !flowModelExists });
@@ -212,7 +266,7 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
212
266
 
213
267
  if (target.reason === 'unsupportedV2Runtime') {
214
268
  if (active && requestId === requestIdRef.current) {
215
- setGuardState({ pending: false, allowBridge: false, notFound: true });
269
+ setGuardState({ pending: false, allowBridge: false, notFound: false, legacyPageUnsupported: true });
216
270
  }
217
271
  return;
218
272
  }
@@ -239,12 +293,26 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
239
293
  return <AppNotFound />;
240
294
  }
241
295
 
296
+ if (guardState.legacyPageUnsupported) {
297
+ return <LegacyPageUnsupported app={app} location={location} />;
298
+ }
299
+
242
300
  if (!guardState.allowBridge) {
243
301
  return null;
244
302
  }
245
303
 
246
304
  return <BridgeFlowRoute pageUid={pageUid} active={props.active} getLayoutModel={getLayoutModel} />;
247
- }, [getLayoutModel, guardState.allowBridge, guardState.notFound, guardState.pending, pageUid, props.active]);
305
+ }, [
306
+ app,
307
+ getLayoutModel,
308
+ guardState.allowBridge,
309
+ guardState.legacyPageUnsupported,
310
+ guardState.notFound,
311
+ guardState.pending,
312
+ location,
313
+ pageUid,
314
+ props.active,
315
+ ]);
248
316
 
249
317
  return content;
250
318
  };
@@ -14,8 +14,10 @@ import {
14
14
  FlowSettingsButton,
15
15
  buildSubModelGroups,
16
16
  buildSubModelItems,
17
+ type FlowModel,
17
18
  type SubModelItem,
18
19
  type SubModelItemsType,
20
+ VIEW_ACTIVATED_EVENT,
19
21
  } from '@nocobase/flow-engine';
20
22
  import React from 'react';
21
23
  import { FilterManager } from '../blocks/filter-manager/FilterManager';
@@ -24,6 +26,8 @@ import { GridModel } from './GridModel';
24
26
  const SELECT_SCENE_ALLOWED_OTHER_BLOCK_MODELS = ['JSBlockModel', 'IframeBlockModel', 'MarkdownBlockModel'] as const;
25
27
 
26
28
  export class BlockGridModel extends GridModel {
29
+ private viewActivatedListener?: () => void;
30
+
27
31
  dragOverlayConfig: DragOverlayConfig = {
28
32
  // 列内插入
29
33
  columnInsert: {
@@ -50,6 +54,28 @@ export class BlockGridModel extends GridModel {
50
54
  });
51
55
  }
52
56
 
57
+ onMount() {
58
+ super.onMount();
59
+ if (this.context.view?.inputArgs?.scene !== 'select' || this.viewActivatedListener) {
60
+ return;
61
+ }
62
+
63
+ this.viewActivatedListener = () => {
64
+ this.mapSubModels('items', (item) => {
65
+ (item as FlowModel & { onActive?: () => void }).onActive?.();
66
+ });
67
+ };
68
+ this.flowEngine.emitter.on(VIEW_ACTIVATED_EVENT, this.viewActivatedListener);
69
+ }
70
+
71
+ protected onUnmount() {
72
+ if (this.viewActivatedListener) {
73
+ this.flowEngine.emitter.off(VIEW_ACTIVATED_EVENT, this.viewActivatedListener);
74
+ this.viewActivatedListener = undefined;
75
+ }
76
+ super.onUnmount();
77
+ }
78
+
53
79
  get subModelBaseClasses() {
54
80
  const inputArgs = this.context.view?.inputArgs ?? {};
55
81
  if (inputArgs.collectionName && !inputArgs.filterByTk) {
@@ -0,0 +1,124 @@
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 { FlowEngine, type FlowModelContext, MultiRecordResource, VIEW_ACTIVATED_EVENT } from '@nocobase/flow-engine';
11
+ import { beforeEach, describe, expect, it } from 'vitest';
12
+ import { BlockGridModel } from '../BlockGridModel';
13
+ import { CollectionBlockModel } from '../CollectionBlockModel';
14
+
15
+ class TestBlockGridModel extends BlockGridModel {
16
+ mountForTest() {
17
+ super.onMount();
18
+ }
19
+
20
+ unmountForTest() {
21
+ super.onUnmount();
22
+ }
23
+ }
24
+
25
+ class TestMultiRecordResource extends MultiRecordResource<Record<string, unknown>> {
26
+ refreshCalls = 0;
27
+
28
+ override async refresh(): Promise<void> {
29
+ this.refreshCalls += 1;
30
+ this.emit('refresh');
31
+ }
32
+ }
33
+
34
+ class TestCollectionBlockModel extends CollectionBlockModel {
35
+ createResource(ctx: FlowModelContext) {
36
+ return ctx.createResource(TestMultiRecordResource);
37
+ }
38
+
39
+ mountForTest() {
40
+ super.onMount();
41
+ }
42
+ }
43
+
44
+ describe('BlockGridModel - select scene activation', () => {
45
+ let engine: FlowEngine;
46
+
47
+ beforeEach(() => {
48
+ engine = new FlowEngine();
49
+ engine.context.defineProperty('location', { value: { search: '' } });
50
+ engine.registerModels({ TestBlockGridModel, TestCollectionBlockModel });
51
+
52
+ engine.dataSourceManager.getDataSource('main').addCollection({
53
+ name: 'roles',
54
+ filterTargetKey: 'id',
55
+ fields: [
56
+ { name: 'id', type: 'integer', interface: 'number' },
57
+ { name: 'name', type: 'string', interface: 'input' },
58
+ ],
59
+ });
60
+ });
61
+
62
+ function createModels(scene = 'select') {
63
+ engine.context.defineProperty('view', { value: { inputArgs: { scene } } });
64
+ const grid = engine.createModel<TestBlockGridModel>({ use: 'TestBlockGridModel' });
65
+ const block = grid.addSubModel<TestCollectionBlockModel>('items', {
66
+ use: 'TestCollectionBlockModel',
67
+ stepParams: {
68
+ resourceSettings: {
69
+ init: {
70
+ dataSourceKey: 'main',
71
+ collectionName: 'roles',
72
+ },
73
+ },
74
+ },
75
+ });
76
+ const resource = block.context.resource as TestMultiRecordResource;
77
+
78
+ block.mountForTest();
79
+ grid.mountForTest();
80
+ return { grid, resource };
81
+ }
82
+
83
+ it('refreshes dirty collection blocks once when a nested view closes', async () => {
84
+ const { grid, resource } = createModels();
85
+
86
+ engine.markDataSourceDirty('main', 'roles');
87
+ engine.emitter.emit(VIEW_ACTIVATED_EVENT);
88
+ await Promise.resolve();
89
+
90
+ expect(resource.refreshCalls).toBe(1);
91
+
92
+ engine.emitter.emit(VIEW_ACTIVATED_EVENT);
93
+ await Promise.resolve();
94
+
95
+ expect(resource.refreshCalls).toBe(1);
96
+ grid.unmountForTest();
97
+ });
98
+
99
+ it('does not refresh without dirty data or after the grid unmounts', async () => {
100
+ const { grid, resource } = createModels();
101
+
102
+ engine.emitter.emit(VIEW_ACTIVATED_EVENT);
103
+ await Promise.resolve();
104
+ expect(resource.refreshCalls).toBe(0);
105
+
106
+ grid.unmountForTest();
107
+ engine.markDataSourceDirty('main', 'roles');
108
+ engine.emitter.emit(VIEW_ACTIVATED_EVENT);
109
+ await Promise.resolve();
110
+
111
+ expect(resource.refreshCalls).toBe(0);
112
+ });
113
+
114
+ it('does not handle activation outside the select scene', async () => {
115
+ const { grid, resource } = createModels('list');
116
+
117
+ engine.markDataSourceDirty('main', 'roles');
118
+ engine.emitter.emit(VIEW_ACTIVATED_EVENT);
119
+ await Promise.resolve();
120
+
121
+ expect(resource.refreshCalls).toBe(0);
122
+ grid.unmountForTest();
123
+ });
124
+ });
@@ -15,8 +15,10 @@ import {
15
15
  DndProvider,
16
16
  DragHandler,
17
17
  Droppable,
18
+ isCtxDateExpression,
18
19
  isRunJSValue,
19
20
  normalizeRunJSValue,
21
+ parseCtxDateExpression,
20
22
  runjsWithSafeGlobals,
21
23
  tExpr,
22
24
  FlowModelRenderer,
@@ -45,6 +47,15 @@ import { normalizeFilterValueByOperator } from './valueNormalization';
45
47
 
46
48
  const RELATION_FIELD_TYPES = ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany', 'belongsToArray'];
47
49
  const NUMERIC_FIELD_TYPES = ['integer', 'float', 'double', 'decimal'];
50
+ const DATE_FILTER_OPERATORS = new Set([
51
+ '$dateOn',
52
+ '$dateNotOn',
53
+ '$dateBefore',
54
+ '$dateAfter',
55
+ '$dateNotBefore',
56
+ '$dateNotAfter',
57
+ '$dateBetween',
58
+ ]);
48
59
 
49
60
  function getFilterFormFieldMetaType(field: CollectionField) {
50
61
  if (RELATION_FIELD_TYPES.includes(field.type)) {
@@ -67,6 +78,14 @@ function getFilterFormFieldMetaType(field: CollectionField) {
67
78
  }
68
79
  }
69
80
 
81
+ function parseDateFilterDefaultValue(operator: string | undefined, rawValue: unknown) {
82
+ if (!operator || !DATE_FILTER_OPERATORS.has(operator) || !isCtxDateExpression(rawValue)) {
83
+ return undefined;
84
+ }
85
+
86
+ return parseCtxDateExpression(rawValue);
87
+ }
88
+
70
89
  function shouldShowFilterFormFieldMeta(field: CollectionField) {
71
90
  return Boolean(field?.interface);
72
91
  }
@@ -490,7 +509,7 @@ export class FilterFormBlockModel extends FilterBlockModel<{
490
509
  const rules = (params?.value || []) as any[];
491
510
  if (!Array.isArray(rules) || rules.length === 0) return appliedValues;
492
511
 
493
- const resolveValue = async (raw: any) => {
512
+ const resolveValue = async (raw: any, operator?: string) => {
494
513
  // RunJS support
495
514
  if (isRunJSValue(raw)) {
496
515
  const { code, version } = normalizeRunJSValue(raw);
@@ -498,6 +517,11 @@ export class FilterFormBlockModel extends FilterBlockModel<{
498
517
  return ret?.success ? ret.value : undefined;
499
518
  }
500
519
 
520
+ const parsedDateFilterValue = parseDateFilterDefaultValue(operator, raw);
521
+ if (typeof parsedDateFilterValue !== 'undefined') {
522
+ return parsedDateFilterValue;
523
+ }
524
+
501
525
  return await (this.context as any).resolveJsonTemplate?.(raw);
502
526
  };
503
527
 
@@ -521,11 +545,11 @@ export class FilterFormBlockModel extends FilterBlockModel<{
521
545
 
522
546
  const current = (form as any).getFieldValue?.(name);
523
547
 
524
- const resolved = await resolveValue(rule.value);
548
+ const operator = getDefaultOperator(itemModel as any);
549
+ const resolved = await resolveValue(rule.value, operator);
525
550
  if (options?.refreshSeq && options.refreshSeq !== this.defaultValuesRefreshSeq) return appliedValues;
526
551
  if (typeof resolved === 'undefined') continue;
527
552
 
528
- const operator = getDefaultOperator(itemModel as any);
529
553
  const normalized = normalizeFilterValueByOperator(operator, resolved);
530
554
  const mode = this.normalizeFieldValueMode(rule.mode);
531
555
  if (mode === 'default' && !this.canApplyFormDefaultValue(String(name), current, force)) continue;
@@ -17,7 +17,15 @@ import { FilterFormBlockModel } from '../FilterFormBlockModel';
17
17
  function resolveTemplateValue(raw: any, values: Record<string, any>): any {
18
18
  if (typeof raw === 'string') {
19
19
  const matched = raw.match(/^\{\{\s*ctx\.formValues\.([^}]+?)\s*\}\}$/);
20
- return matched ? values[matched[1]] : raw;
20
+ if (matched) {
21
+ return values[matched[1]];
22
+ }
23
+
24
+ if (/^\{\{\s*ctx\.date\.relative\.past\.day\.n7\s*\}\}$/.test(raw)) {
25
+ return '2026-06-15';
26
+ }
27
+
28
+ return raw;
21
29
  }
22
30
  if (Array.isArray(raw)) {
23
31
  return raw.map((item) => resolveTemplateValue(item, values));
@@ -30,7 +38,7 @@ function resolveTemplateValue(raw: any, values: Record<string, any>): any {
30
38
 
31
39
  function createFilterFormDefaultValuesModel(rules: any[], initialValues: Record<string, any> = {}) {
32
40
  const values = { ...initialValues };
33
- const createItem = (fieldPath: string, uid: string) => ({
41
+ const createItem = (fieldPath: string, uid: string, operator?: string) => ({
34
42
  uid,
35
43
  fieldPath,
36
44
  props: { name: `${fieldPath}_${uid}` },
@@ -44,7 +52,7 @@ function createFilterFormDefaultValuesModel(rules: any[], initialValues: Record<
44
52
  return undefined;
45
53
  },
46
54
  subModels: {
47
- field: {},
55
+ field: operator ? { operator } : {},
48
56
  },
49
57
  });
50
58
  const model = {
@@ -76,7 +84,11 @@ function createFilterFormDefaultValuesModel(rules: any[], initialValues: Record<
76
84
  subModels: {
77
85
  grid: {
78
86
  subModels: {
79
- items: [createItem('nickname', 'nick'), createItem('username', 'user')],
87
+ items: [
88
+ createItem('nickname', 'nick'),
89
+ createItem('username', 'user'),
90
+ createItem('birthdate_tz', 'birthdate', '$dateOn'),
91
+ ],
80
92
  },
81
93
  },
82
94
  },
@@ -354,6 +366,22 @@ describe('filter-form defaultValues wiring', () => {
354
366
  expect(values.username_user).toBe('Bob');
355
367
  });
356
368
 
369
+ it('preserves relative date descriptors for date filter default values', async () => {
370
+ const { model, values } = createFilterFormDefaultValuesModel([
371
+ {
372
+ key: 'birthdate-default',
373
+ enable: true,
374
+ targetPath: 'birthdate_tz',
375
+ mode: 'assign',
376
+ value: '{{ ctx.date.relative.past.day.n7 }}',
377
+ },
378
+ ]);
379
+
380
+ await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any);
381
+
382
+ expect(values.birthdate_tz_birthdate).toEqual({ type: 'past', unit: 'day', number: 7 });
383
+ });
384
+
357
385
  it('applies override values until the target filter field is changed by user', async () => {
358
386
  const { model, values } = createFilterFormDefaultValuesModel(
359
387
  [
@@ -235,7 +235,7 @@ export class JSBlockModel extends BlockModel {
235
235
  const cardProps = {
236
236
  ...rest,
237
237
  height,
238
- style,
238
+ ...(style === undefined ? {} : { style }),
239
239
  ...(beforeContent === undefined ? {} : { beforeContent }),
240
240
  ...(afterContent === undefined ? {} : { afterContent }),
241
241
  };