@nocobase/client-v2 2.1.27 → 2.1.29

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.1.27",
3
+ "version": "2.1.29",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -27,11 +27,11 @@
27
27
  "@formily/antd-v5": "1.2.3",
28
28
  "@formily/react": "^2.2.27",
29
29
  "@formily/shared": "^2.2.27",
30
- "@nocobase/evaluators": "2.1.27",
31
- "@nocobase/flow-engine": "2.1.27",
32
- "@nocobase/sdk": "2.1.27",
33
- "@nocobase/shared": "2.1.27",
34
- "@nocobase/utils": "2.1.27",
30
+ "@nocobase/evaluators": "2.1.29",
31
+ "@nocobase/flow-engine": "2.1.29",
32
+ "@nocobase/sdk": "2.1.29",
33
+ "@nocobase/shared": "2.1.29",
34
+ "@nocobase/utils": "2.1.29",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -44,8 +44,9 @@
44
44
  "json5": "^2.2.3",
45
45
  "jsqr": "^1.4.0",
46
46
  "lodash": "4.17.21",
47
+ "react-device-detect": "2.2.3",
47
48
  "react-i18next": "^11.15.1",
48
49
  "react-router-dom": "^6.30.1"
49
50
  },
50
- "gitHead": "c1311c48cf65f8eeac17411d3380ebdd0918f1e2"
51
+ "gitHead": "d9848c5df07cef806cb938bb14b3f4ef5f3d0129"
51
52
  }
@@ -112,6 +112,7 @@ describe('FlowRoute', () => {
112
112
  }),
113
113
  );
114
114
  });
115
+ expect(engine.context.deviceType).toBe('computer');
115
116
 
116
117
  rerender(
117
118
  <FlowEngineProvider engine={engine}>
@@ -0,0 +1,58 @@
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 { createMockClient, PluginFlowEngine } from '@nocobase/client-v2';
11
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
12
+
13
+ const { detectedDeviceType } = vi.hoisted(() => ({
14
+ detectedDeviceType: { value: 'mobile' },
15
+ }));
16
+
17
+ vi.mock('react-device-detect', () => ({
18
+ get deviceType() {
19
+ return detectedDeviceType.value;
20
+ },
21
+ }));
22
+
23
+ describe('PluginFlowEngine', () => {
24
+ beforeEach(() => {
25
+ detectedDeviceType.value = 'mobile';
26
+ });
27
+
28
+ it('should register the current device type before shared flow components', async () => {
29
+ const app = createMockClient({
30
+ router: {
31
+ type: 'memory',
32
+ initialEntries: ['/'],
33
+ },
34
+ });
35
+ const plugin = new PluginFlowEngine({}, app);
36
+ const addComponents = app.addComponents.bind(app);
37
+ const deviceTypesBeforeComponentRegistration: string[] = [];
38
+ vi.spyOn(app, 'addComponents').mockImplementation((components) => {
39
+ deviceTypesBeforeComponentRegistration.push(app.flowEngine.context.deviceType);
40
+ return addComponents(components);
41
+ });
42
+
43
+ await plugin.load();
44
+
45
+ expect(app.flowEngine.context.deviceType).toBe('mobile');
46
+ expect(deviceTypesBeforeComponentRegistration).toEqual(['mobile']);
47
+ });
48
+
49
+ it('should normalize the browser device type to computer', async () => {
50
+ detectedDeviceType.value = 'browser';
51
+ const app = createMockClient();
52
+ const plugin = new PluginFlowEngine({}, app);
53
+
54
+ await plugin.load();
55
+
56
+ expect(app.flowEngine.context.deviceType).toBe('computer');
57
+ });
58
+ });
@@ -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: {
@@ -9,7 +9,6 @@
9
9
 
10
10
  import { type FlowEngine, useFlowContext, useFlowEngine } from '@nocobase/flow-engine';
11
11
  import React, { useEffect, useMemo, useRef, useState } from 'react';
12
- import { deviceType } from 'react-device-detect';
13
12
  import { useParams } from 'react-router-dom';
14
13
  import { useApp } from '../../hooks/useApp';
15
14
  import { NocoBaseDesktopRouteType } from '../../flow-compat';
@@ -19,6 +18,7 @@ import { getLayoutModel, type BaseLayoutModel } from '../admin-shell/BaseLayoutM
19
18
  import { useLayoutRoutePage } from '../admin-shell/useLayoutRoutePage';
20
19
  import { AppNotFound } from '../../components';
21
20
  import { useKeepAlive } from '../../components/KeepAlive';
21
+ import { registerDeviceTypeContext } from '../internal/registerDeviceTypeContext';
22
22
 
23
23
  type FlowRouteGuardState = {
24
24
  pending: boolean;
@@ -94,31 +94,9 @@ const BridgeFlowRoute = ({
94
94
  const layoutContentRef = useRef<HTMLDivElement>(null);
95
95
 
96
96
  useEffect(() => {
97
- flowEngine.context.defineProperty('deviceType', {
98
- get: () => (deviceType === 'browser' ? 'computer' : deviceType),
99
- cache: false,
100
- meta: {
101
- type: 'string',
102
- title: flowEngine.translate('Current device type'),
103
- interface: 'select',
104
- uiSchema: {
105
- enum: [
106
- { label: flowEngine.translate('Computer'), value: 'computer' },
107
- { label: flowEngine.translate('Mobile'), value: 'mobile' },
108
- { label: flowEngine.translate('Tablet'), value: 'tablet' },
109
- { label: flowEngine.translate('SmartTv'), value: 'smarttv' },
110
- { label: flowEngine.translate('Console'), value: 'console' },
111
- { label: flowEngine.translate('Wearable'), value: 'wearable' },
112
- { label: flowEngine.translate('Embedded'), value: 'embedded' },
113
- ],
114
- 'x-component': 'Select',
115
- },
116
- },
117
- info: {
118
- description: 'Current device type (computer/mobile/tablet/...).',
119
- detail: 'string',
120
- },
121
- });
97
+ if (!flowEngine.context.getPropertyOptions('deviceType')) {
98
+ registerDeviceTypeContext(flowEngine);
99
+ }
122
100
  }, [flowEngine]);
123
101
 
124
102
  useLayoutRoutePage({
@@ -136,8 +114,8 @@ const BridgeFlowRoute = ({
136
114
  /**
137
115
  * 管理后台动态页面路由组件。
138
116
  *
139
- * 负责读取当前路由页面 UID,补充运行时设备变量,
140
- * 并把页面生命周期桥接到 AdminLayout host model。
117
+ * 负责读取当前路由页面 UID,并把页面生命周期桥接到 AdminLayout host model。
118
+ * 设备变量通常由 PluginFlowEngine 共享初始化提供;独立渲染时会在挂载后补充注册。
141
119
  *
142
120
  * @example
143
121
  * ```tsx
package/src/flow/index.ts CHANGED
@@ -22,12 +22,14 @@ import { Markdown } from './common/Markdown/Markdown';
22
22
  import { LiquidEngine } from './common/Liquid';
23
23
  import type { PreviewRunJSResult } from './components/code-editor/runjsDiagnostics';
24
24
  import { TextAreaWithContextSelector } from './components/TextAreaWithContextSelector';
25
+ import { registerDeviceTypeContext } from './internal/registerDeviceTypeContext';
25
26
 
26
27
  export class PluginFlowEngine<TApp extends BaseApplication<any> = BaseApplication<any>> extends Plugin<
27
28
  PluginOptions<any>,
28
29
  TApp
29
30
  > {
30
31
  async load() {
32
+ registerDeviceTypeContext(this.flowEngine);
31
33
  this.app.addComponents({ FlowRoute });
32
34
  this.app.flowEngine.setModelRepository(new FlowModelRepository(this.app));
33
35
  const filteredModels = Object.fromEntries(
@@ -0,0 +1,39 @@
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 type { FlowEngine } from '@nocobase/flow-engine';
11
+ import { deviceType } from 'react-device-detect';
12
+
13
+ export const registerDeviceTypeContext = (flowEngine: FlowEngine) => {
14
+ flowEngine.context.defineProperty('deviceType', {
15
+ get: () => (deviceType === 'browser' ? 'computer' : deviceType),
16
+ cache: false,
17
+ meta: {
18
+ type: 'string',
19
+ title: flowEngine.translate('Current device type'),
20
+ interface: 'select',
21
+ uiSchema: {
22
+ enum: [
23
+ { label: flowEngine.translate('Computer'), value: 'computer' },
24
+ { label: flowEngine.translate('Mobile'), value: 'mobile' },
25
+ { label: flowEngine.translate('Tablet'), value: 'tablet' },
26
+ { label: flowEngine.translate('SmartTv'), value: 'smarttv' },
27
+ { label: flowEngine.translate('Console'), value: 'console' },
28
+ { label: flowEngine.translate('Wearable'), value: 'wearable' },
29
+ { label: flowEngine.translate('Embedded'), value: 'embedded' },
30
+ ],
31
+ 'x-component': 'Select',
32
+ },
33
+ },
34
+ info: {
35
+ description: 'Current device type (computer/mobile/tablet/...).',
36
+ detail: 'string',
37
+ },
38
+ });
39
+ };
@@ -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
  };