feat: date time field value assignment improvement (#8630)

* feat: support date time assignment

* chore: improve code

* fix: date only field

* chore: restore MobileLazySelect to next version

* fix: exact date bug for dddd format date

* fix(client): narrow date variable picker option types

* fix: tz date time bug
This commit is contained in:
gchust
2026-02-14 08:52:20 +08:00
committed by GitHub
parent 1f3b4dfdc2
commit cf6c4705b1
19 changed files with 1803 additions and 164 deletions
@@ -89,6 +89,7 @@ const FormAssignRulesUI = observer(
showValueEditorWhenNoField
isTitleFieldCandidate={isTitleFieldCandidate}
onSyncAssociationTitleField={onSyncAssociationTitleField}
enableDateVariableAsConstant
/>
);
},
@@ -908,6 +908,7 @@ const FieldAssignRulesActionComponent: React.FC<
fixedMode={fixedMode}
isTitleFieldCandidate={isTitleFieldCandidate}
onSyncAssociationTitleField={onSyncAssociationTitleField}
enableDateVariableAsConstant
/>
);
};
@@ -0,0 +1,225 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useMemo, useState } from 'react';
import { DatePicker, Select, Space } from 'antd';
import { dayjs, getDateTimeFormat, getPickerFormat } from '@nocobase/utils/client';
import { useFlowContext } from '@nocobase/flow-engine';
import { inferPickerType } from '../../schema-component';
const stripTimeFromFormat = (format?: string) =>
format ? format.replace(/\s*HH?:mm(?::ss)?(?:\.SSS)?/g, '').trim() : format;
export type ExactDatePickerMode = 'year' | 'quarter' | 'month' | 'date';
export type ExactDatePickerValue = dayjs.Dayjs | [dayjs.Dayjs, dayjs.Dayjs] | null;
export interface FieldAssignExactDatePickerProps {
picker?: ExactDatePickerMode;
format?: string;
showTime?: boolean;
timeFormat?: string;
value?: unknown;
isRange?: boolean;
onChange?: (value: ExactDatePickerValue) => void;
style?: React.CSSProperties;
}
function getRawSingleValue(value: unknown, isRange: boolean): unknown {
if (!isRange) {
return value;
}
if (!Array.isArray(value)) return undefined;
return value[0];
}
function inferPickerFromRawValue(value: unknown, fallback: ExactDatePickerMode): ExactDatePickerMode {
if (typeof value === 'string') {
return inferPickerType(value, fallback) as ExactDatePickerMode;
}
return fallback;
}
function parseDateByFormat(value: string, format: string): dayjs.Dayjs | null {
const raw = String(value || '').trim();
if (!raw) return null;
const hasTimezone = /(?:Z|[+-]\d{2}:\d{2})$/i.test(raw);
if (hasTimezone) {
const parsed = dayjs(raw);
if (parsed.isValid()) {
return parsed;
}
}
if (format) {
const strict = dayjs(raw, format, true);
if (strict.isValid()) {
return strict;
}
}
const fallback = dayjs(raw);
if (fallback.isValid()) {
return fallback;
}
if (format) {
const loose = dayjs(raw, format);
if (loose.isValid()) {
return loose;
}
}
return null;
}
function parseDateFromRawValue(value: unknown, format: string): dayjs.Dayjs | null {
if (dayjs.isDayjs(value)) {
return value;
}
if (value instanceof Date) {
const parsedDate = dayjs(value);
return parsedDate.isValid() ? parsedDate : null;
}
if (typeof value === 'string') {
return parseDateByFormat(value, format);
}
return null;
}
function parseRangeValue(value: unknown, format: string): [dayjs.Dayjs, dayjs.Dayjs] | null {
if (!Array.isArray(value)) {
return null;
}
const left = parseDateFromRawValue(value[0], format);
const right = parseDateFromRawValue(value[1], format);
if (!left || !right) {
return null;
}
return [left, right];
}
export const FieldAssignExactDatePicker: React.FC<FieldAssignExactDatePickerProps> = (props) => {
const { picker = 'date', format, showTime, timeFormat, value, isRange = false, onChange, style } = props;
const flowCtx = useFlowContext();
const t = flowCtx.model.translate.bind(flowCtx.model);
const rawSingleValue = useMemo(() => getRawSingleValue(value, isRange), [isRange, value]);
const [targetPicker, setTargetPicker] = useState<ExactDatePickerMode>(() => {
return inferPickerFromRawValue(rawSingleValue, picker);
});
const getResolvedFormat = (nextPicker: ExactDatePickerMode) => {
const baseFormat = nextPicker === picker && format ? format : getPickerFormat(nextPicker);
const dateFormat = nextPicker === 'date' ? stripTimeFromFormat(baseFormat) : baseFormat;
return getDateTimeFormat(nextPicker, dateFormat, showTime, timeFormat);
};
const resolvedFormat = useMemo(
() => getResolvedFormat(targetPicker),
[targetPicker, format, picker, showTime, timeFormat],
);
const singleValue = useMemo(() => {
if (isRange) return null;
return parseDateFromRawValue(value, resolvedFormat);
}, [isRange, value, resolvedFormat]);
const rangeValue = useMemo(() => {
if (!isRange) return null;
return parseRangeValue(value, resolvedFormat);
}, [isRange, value, resolvedFormat]);
const pickerOptions = useMemo(
() => [
{ label: t('Date'), value: 'date' },
{ label: t('Month'), value: 'month' },
{ label: t('Quarter'), value: 'quarter' },
{ label: t('Year'), value: 'year' },
],
[t],
);
const handlePickerTypeChange = (nextPicker: ExactDatePickerMode) => {
setTargetPicker(nextPicker);
if (!onChange) {
return;
}
if (isRange) {
if (rangeValue) {
onChange(rangeValue);
}
return;
}
if (singleValue) {
onChange(singleValue);
}
};
const singlePickerProps: any = {
utc: true,
underFilter: true,
format: resolvedFormat,
picker: targetPicker,
inputReadOnly: flowCtx.isMobileLayout,
showTime: showTime ? { defaultValue: dayjs('00:00:00', 'HH:mm:ss') } : false,
value: singleValue,
onChange: (nextDate: dayjs.Dayjs | null) => {
if (nextDate && dayjs.isDayjs(nextDate)) {
onChange?.(nextDate);
return;
}
onChange?.(null);
},
style: { flex: 1 },
};
const rangePickerProps: any = {
utc: true,
underFilter: true,
format: resolvedFormat,
picker: targetPicker,
inputReadOnly: flowCtx.isMobileLayout,
showTime: showTime ? { defaultValue: [dayjs('00:00:00', 'HH:mm:ss'), dayjs('23:59:59', 'HH:mm:ss')] } : false,
value: rangeValue,
onChange: (nextDates: [dayjs.Dayjs | null, dayjs.Dayjs | null] | null) => {
if (Array.isArray(nextDates) && nextDates[0] && nextDates[1]) {
onChange?.([nextDates[0], nextDates[1]]);
return;
}
onChange?.(null);
},
style: { flex: 1 },
};
return (
<Space.Compact style={{ width: '100%', ...(style || {}) }}>
<Select
style={{ width: 100 }}
popupMatchSelectWidth={false}
value={targetPicker}
options={pickerOptions}
onChange={handlePickerTypeChange}
/>
{isRange ? <DatePicker.RangePicker {...rangePickerProps} /> : <DatePicker {...singlePickerProps} />}
</Space.Compact>
);
};
@@ -102,6 +102,8 @@ export interface FieldAssignRulesEditorProps {
isTitleFieldCandidate?: (field: any, targetCollection: any) => boolean;
/** 可选:点击同步按钮后,将选中的 title field 持久化到关系集合 */
onSyncAssociationTitleField?: (params: SyncAssociationTitleFieldParams) => Promise<void> | void;
/** 在日期字段下启用“日期变量替换 Constant 位”。 */
enableDateVariableAsConstant?: boolean;
}
export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (props) => {
@@ -119,6 +121,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
getValueInputProps,
isTitleFieldCandidate,
onSyncAssociationTitleField,
enableDateVariableAsConstant = false,
} = props;
const value = Array.isArray(rawValue) ? rawValue : [];
@@ -885,6 +888,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
onChange={(v) => patchItem(index, { value: v })}
extraMetaTree={extraMetaTree}
{...(getValueInputProps?.(item, index) || {})}
enableDateVariableAsConstant={enableDateVariableAsConstant}
associationFieldNamesOverride={
associationQuickContext && previewTitleField
? {
@@ -8,7 +8,9 @@
*/
import React from 'react';
import { Input } from 'antd';
import { css } from '@emotion/css';
import { Divider, Input, InputNumber, Select, Space, theme } from 'antd';
import { dayjs } from '@nocobase/utils/client';
import {
FlowModelRenderer,
VariableInput,
@@ -16,6 +18,9 @@ import {
isVariableExpression,
parseValueToPath,
isRunJSValue,
isCtxDateExpression,
parseCtxDateExpression,
serializeCtxDateValue,
type CollectionField,
type MetaTreeNode,
useFlowContext,
@@ -32,6 +37,282 @@ import { RunJSValueEditor } from './RunJSValueEditor';
import { resolveOperatorComponent } from '../internal/utils/operatorSchemaHelper';
import { InputFieldModel } from '../models/fields/InputFieldModel';
import { normalizeFilterValueByOperator } from '../models/blocks/filter-form/valueNormalization';
import { FieldAssignExactDatePicker, type ExactDatePickerMode } from './FieldAssignExactDatePicker';
const DATE_FIELD_INTERFACES = new Set(['date', 'datetime', 'datetimeNoTz', 'createdAt', 'updatedAt', 'unixTimestamp']);
const TZ_AWARE_DATE_INTERFACES = new Set(['datetime', 'createdAt', 'updatedAt', 'unixTimestamp']);
const DATE_ONLY_OUTPUT_FORMAT = 'YYYY-MM-DD';
const DATETIME_NO_TZ_OUTPUT_FORMAT = 'YYYY-MM-DD HH:mm:ss';
export type DateVariableExactNormalizeMode = 'none' | 'date' | 'datetimeNoTz' | 'iso';
const DATE_DYNAMIC_OPTION_KEYS = [
'exact',
'past',
'next',
'today',
'yesterday',
'tomorrow',
'thisWeek',
'lastWeek',
'nextWeek',
'thisMonth',
'lastMonth',
'nextMonth',
'thisQuarter',
'lastQuarter',
'nextQuarter',
'thisYear',
'lastYear',
'nextYear',
] as const;
type DateDynamicOptionValue = (typeof DATE_DYNAMIC_OPTION_KEYS)[number] | 'now';
const DATE_DYNAMIC_OPTION_LABELS: Record<(typeof DATE_DYNAMIC_OPTION_KEYS)[number], string> = {
exact: 'Exact day',
past: 'Past',
next: 'Next',
today: 'Today',
yesterday: 'Yesterday',
tomorrow: 'Tomorrow',
thisWeek: 'This Week',
lastWeek: 'Last Week',
nextWeek: 'Next Week',
thisMonth: 'This Month',
lastMonth: 'Last Month',
nextMonth: 'Next Month',
thisQuarter: 'This Quarter',
lastQuarter: 'Last Quarter',
nextQuarter: 'Next Quarter',
thisYear: 'This Year',
lastYear: 'Last Year',
nextYear: 'Next Year',
};
function buildDateDynamicOptions(t?: (key: string) => string, includeNow = false) {
const options: Array<{ value: DateDynamicOptionValue; label: string }> = DATE_DYNAMIC_OPTION_KEYS.map((key) => ({
value: key,
label: t?.(DATE_DYNAMIC_OPTION_LABELS[key]) ?? DATE_DYNAMIC_OPTION_LABELS[key],
}));
if (includeNow) {
options.splice(3, 0, { value: 'now', label: t?.('Now') ?? 'Now' });
}
return options;
}
function parseDateByFormat(value: string, format: string): dayjs.Dayjs | null {
const raw = String(value || '').trim();
if (!raw) return null;
const hasTimezone = /(?:Z|[+-]\d{2}:\d{2})$/i.test(raw);
if (hasTimezone) {
const parsed = dayjs(raw);
if (parsed.isValid()) {
return parsed;
}
}
if (format) {
const strict = dayjs(raw, format, true);
if (strict.isValid()) {
return strict;
}
}
const fallback = dayjs(raw);
if (fallback.isValid()) {
return fallback;
}
if (format) {
const loose = dayjs(raw, format);
if (loose.isValid()) {
return loose;
}
}
return null;
}
function parseDateFromRawValue(value: unknown, format: string): dayjs.Dayjs | null {
if (dayjs.isDayjs(value)) {
return value;
}
if (value instanceof Date) {
const parsedDate = dayjs(value);
return parsedDate.isValid() ? parsedDate : null;
}
if (typeof value === 'string') {
return parseDateByFormat(value, format);
}
return null;
}
function normalizeExactDateValue(
value: unknown,
options: {
format: string;
showTime: boolean;
exactNormalizeMode: DateVariableExactNormalizeMode;
},
): unknown {
const parsed = parseDateFromRawValue(value, options.format);
if (!parsed?.isValid()) {
return value;
}
switch (options.exactNormalizeMode) {
case 'date':
return parsed.format(DATE_ONLY_OUTPUT_FORMAT);
case 'datetimeNoTz':
return parsed.format(options.showTime ? DATETIME_NO_TZ_OUTPUT_FORMAT : DATE_ONLY_OUTPUT_FORMAT);
case 'iso':
return parsed.toISOString();
default:
return value;
}
}
function toExactPickerSingleValue(rawValue: unknown, format: string): dayjs.Dayjs | null {
const parsed = parseDateFromRawValue(rawValue, format);
return parsed?.isValid() ? parsed : null;
}
function toExactPickerRangeValue(rawValue: unknown, format: string): [dayjs.Dayjs, dayjs.Dayjs] | null {
if (!Array.isArray(rawValue)) return null;
const left = toExactPickerSingleValue(rawValue[0], format);
const right = toExactPickerSingleValue(rawValue[1], format);
if (!left || !right) return null;
return [left, right];
}
export function toExactPickerDisplayValue(
rawValue: unknown,
options: {
format: string;
isRange: boolean;
},
): dayjs.Dayjs | [dayjs.Dayjs, dayjs.Dayjs] | null {
if (options.isRange) {
return toExactPickerRangeValue(rawValue, options.format);
}
return toExactPickerSingleValue(rawValue, options.format);
}
function getDateVariableExactNormalizeMode(fieldInterface: string): DateVariableExactNormalizeMode {
if (fieldInterface === 'date') {
return 'date';
}
if (fieldInterface === 'datetimeNoTz') {
return 'datetimeNoTz';
}
if (TZ_AWARE_DATE_INTERFACES.has(fieldInterface)) {
return 'iso';
}
return 'none';
}
export function normalizeDateVariableExactValue(
rawValue: any,
options: {
exactNormalizeMode: DateVariableExactNormalizeMode;
format: string;
showTime: boolean;
},
): any {
if (options.exactNormalizeMode === 'none') {
return rawValue;
}
if (typeof rawValue === 'string') {
return normalizeExactDateValue(rawValue, options);
}
if (dayjs.isDayjs(rawValue) || rawValue instanceof Date) {
return normalizeExactDateValue(rawValue, options);
}
if (Array.isArray(rawValue)) {
return rawValue.map((item) => {
if (typeof item === 'string' || dayjs.isDayjs(item) || item instanceof Date) {
return normalizeExactDateValue(item, options);
}
return item;
});
}
return rawValue;
}
type DateVariableComponentProps = {
picker: ExactDatePickerMode;
showTime: boolean;
timeFormat: string;
format: string;
exactNormalizeMode: DateVariableExactNormalizeMode;
};
function normalizeExactDatePickerMode(value: unknown): ExactDatePickerMode {
if (value === 'year' || value === 'quarter' || value === 'month' || value === 'date') {
return value;
}
return 'date';
}
const DEFAULT_DATE_VARIABLE_COMPONENT_PROPS: DateVariableComponentProps = {
picker: 'date',
showTime: false,
timeFormat: 'HH:mm:ss',
format: 'YYYY-MM-DD',
exactNormalizeMode: 'none',
};
function getFieldInterface(field: any): string {
return typeof field?.interface === 'string' ? field.interface : '';
}
function getFieldComponentProps(field: any): Record<string, any> {
return (
(typeof field?.getComponentProps === 'function' ? field.getComponentProps() : null) ||
field?.uiSchema?.['x-component-props'] ||
{}
);
}
export function normalizeDateVariableOutput(rawValue: any, options: DateVariableComponentProps): any {
if (rawValue === null || isRunJSValue(rawValue)) {
return rawValue;
}
if (typeof rawValue === 'string' && isVariableExpression(rawValue) && !isCtxDateExpression(rawValue)) {
return rawValue;
}
if (rawValue === '' || typeof rawValue === 'undefined') {
return '';
}
const normalized = normalizeDateVariableExactValue(rawValue, {
exactNormalizeMode: options.exactNormalizeMode,
format: options.format || 'YYYY-MM-DD HH:mm:ss',
showTime: options.showTime,
});
const serialized = serializeCtxDateValue(normalized);
return serialized || normalized;
}
interface Props {
/** 赋值目标路径,例如 `title` / `users.nickname` / `user.name` */
@@ -52,6 +333,11 @@ interface Props {
label?: string;
value?: string;
};
/**
* 在日期字段场景下,用日期变量编辑器替换 Constant 位。
* 默认 false,保持历史行为。
*/
enableDateVariableAsConstant?: boolean;
}
type ResolvedFieldContext = {
@@ -183,6 +469,7 @@ export const FieldAssignValueInput: React.FC<Props> = ({
operatorMetaList,
preferFormItemFieldModel,
associationFieldNamesOverride,
enableDateVariableAsConstant = false,
}) => {
const flowCtx = useFlowContext<FlowModelContext>();
const normalizeEventValue = React.useCallback((eventOrValue: unknown) => {
@@ -316,12 +603,19 @@ export const FieldAssignValueInput: React.FC<Props> = ({
}, [flowCtx.model, itemModel, resolveNestedAssociationField, targetPath]);
const { collection, dataSource, blockModel, fieldPath, fieldName, collectionField: cf } = resolved;
const itemCollectionField = (resolved?.itemModel as any)?.context?.collectionField;
const sourceInterface = React.useMemo(() => {
return getFieldInterface(cf) || getFieldInterface(itemCollectionField);
}, [cf, itemCollectionField]);
const sourceCollectionField = (cf as any) || itemCollectionField;
const isArrayValueField = React.useMemo(() => {
if (isToManyAssociationField(cf)) return true;
// 部分字段组件(如 Cascader / CascadeSelectList)期望 array 值;若 uiSchema 明确声明为 array,则视为 array 值字段
const fieldInterface = typeof (cf as any)?.interface === 'string' ? ((cf as any).interface as string) : undefined;
const fieldInterface = getFieldInterface(cf);
if (fieldInterface === 'multipleSelect' || fieldInterface === 'checkboxGroup') return true;
const schemaType = cf?.uiSchema?.type;
@@ -330,6 +624,71 @@ export const FieldAssignValueInput: React.FC<Props> = ({
return false;
}, [cf]);
const isDateLikeField = React.useMemo(() => {
if (sourceInterface && DATE_FIELD_INTERFACES.has(sourceInterface)) {
return true;
}
const leaf =
(typeof fieldName === 'string' && fieldName) ||
(typeof targetPath === 'string' ? targetPath.split('.').filter(Boolean).slice(-1)[0] : '');
return leaf === 'createdAt' || leaf === 'updatedAt';
}, [fieldName, sourceInterface, targetPath]);
const useDateVariableConstant = enableDateVariableAsConstant && isDateLikeField;
const dateVariableComponentProps = React.useMemo(() => {
if (!useDateVariableConstant) {
return DEFAULT_DATE_VARIABLE_COMPONENT_PROPS;
}
const componentProps = getFieldComponentProps(sourceCollectionField);
const picker = normalizeExactDatePickerMode(componentProps?.picker);
const inferredShowTime = ['datetime', 'datetimeNoTz', 'createdAt', 'updatedAt', 'unixTimestamp'].includes(
sourceInterface,
);
const showTime = typeof componentProps?.showTime === 'boolean' ? componentProps.showTime : inferredShowTime;
const dateFormat =
typeof componentProps?.dateFormat === 'string' && componentProps.dateFormat
? componentProps.dateFormat
: typeof componentProps?.format === 'string' && componentProps.format
? componentProps.format.split(' ')[0]
: 'YYYY-MM-DD';
const timeFormat =
typeof componentProps?.timeFormat === 'string' && componentProps.timeFormat
? componentProps.timeFormat
: 'HH:mm:ss';
const format =
typeof componentProps?.format === 'string' && componentProps.format
? componentProps.format
: showTime
? `${dateFormat} ${timeFormat}`
: dateFormat;
return {
picker,
showTime,
timeFormat,
format,
exactNormalizeMode: getDateVariableExactNormalizeMode(sourceInterface),
};
}, [sourceCollectionField, sourceInterface, useDateVariableConstant]);
const dateVariableDisplayProps = React.useMemo(() => {
const { exactNormalizeMode, ...rest } = dateVariableComponentProps;
return rest;
}, [dateVariableComponentProps]);
const dateVariableDisplayPropsRef = React.useRef(dateVariableDisplayProps);
dateVariableDisplayPropsRef.current = dateVariableDisplayProps;
const dateVariableTranslateRef = React.useRef(flowCtx.t);
dateVariableTranslateRef.current = flowCtx.t;
const coerceEmptyValueForRenderer = React.useCallback(
(v: any) => {
let out = v;
@@ -586,6 +945,158 @@ export const FieldAssignValueInput: React.FC<Props> = ({
return C;
}, [placeholder, tempRoot, coerceEmptyValueForRenderer, normalizeEventValue]);
const DateVariableConstantEditor = React.useMemo(() => {
const C: React.FC<any> = (inputProps) => {
const wrapperStyle = pickStyle(inputProps?.style);
const raw = inputProps?.value;
const parsed = isCtxDateExpression(raw) ? parseCtxDateExpression(raw) : raw;
const parsedValue = typeof parsed === 'undefined' ? undefined : parsed;
const { token } = theme.useToken();
const [open, setOpen] = React.useState(false);
const t = dateVariableTranslateRef.current;
const datePickerProps = dateVariableDisplayPropsRef.current;
const options = React.useMemo(() => buildDateDynamicOptions(t, true), [t]);
const dynamicType =
parsedValue && typeof parsedValue === 'object' && !Array.isArray(parsedValue)
? (parsedValue as any)?.type
: undefined;
const selectedType = typeof dynamicType === 'string' && dynamicType ? dynamicType : 'exact';
const isRange = Array.isArray(parsedValue);
const exactSingleValue = toExactPickerDisplayValue(parsedValue, {
format: datePickerProps.format,
isRange: false,
});
const exactRangeValue = toExactPickerDisplayValue(parsedValue, {
format: datePickerProps.format,
isRange: true,
});
const handleSelect = (val: string) => {
setOpen(false);
if (val === 'exact') {
inputProps?.onChange?.('');
return;
}
const next: any = { type: val };
if (val === 'past' || val === 'next') {
next.number = 1;
next.unit = 'day';
}
inputProps?.onChange?.(next);
};
const handleExactSingleChange = (nextValue: any) => {
inputProps?.onChange?.(nextValue || '');
};
const handleExactRangeChange = (nextValue: any) => {
inputProps?.onChange?.(nextValue || '');
};
const dropdownRender = () => {
const firstPart = options.slice(0, 3);
const secondPart = options.slice(3);
const optionStyle = css`
padding: 3px 10px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
background-color: ${token.colorFillSecondary};
}
`;
return (
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
{firstPart.map((opt) => (
<div key={opt.value} role="option" onClick={() => handleSelect(opt.value)} className={optionStyle}>
{opt.label}
</div>
))}
<Divider style={{ margin: '4px 0' }} />
{secondPart.map((opt) => (
<div
key={opt.value}
role="option"
className={optionStyle}
onClick={() => handleSelect(opt.value)}
title={opt.label}
>
{opt.label}
</div>
))}
</div>
);
};
return (
<Space.Compact style={withFullWidthStyle(wrapperStyle)}>
<Select
options={options}
open={open}
onDropdownVisibleChange={setOpen}
allowClear={false}
style={{
width: '100%',
minWidth: 100,
maxWidth: ['past', 'next', 'exact', undefined].includes(dynamicType) ? 100 : null,
}}
value={selectedType}
onChange={handleSelect}
dropdownRender={dropdownRender}
/>
{['past', 'next'].includes(selectedType) && [
<InputNumber
key="number"
style={{ flex: 1 }}
value={(parsedValue as any)?.number}
onChange={(nextNumber) => {
inputProps?.onChange?.({
...(parsedValue as any),
type: selectedType,
number: nextNumber,
unit: (parsedValue as any)?.unit || 'day',
});
}}
/>,
<Select
key="unit"
value={(parsedValue as any)?.unit}
style={{ minWidth: 130, maxWidth: 140 }}
onChange={(nextUnit) => {
inputProps?.onChange?.({
...(parsedValue as any),
type: selectedType,
unit: nextUnit,
number: (parsedValue as any)?.number || 1,
});
}}
options={[
{ value: 'day', label: t?.('Day') ?? 'Day' },
{ value: 'week', label: t?.('Calendar week') ?? 'Calendar week' },
{ value: 'month', label: t?.('Calendar Month') ?? 'Calendar Month' },
{ value: 'year', label: t?.('Calendar Year') ?? 'Calendar Year' },
]}
popupMatchSelectWidth
/>,
]}
{(selectedType === 'exact' || !selectedType) && (
<FieldAssignExactDatePicker
{...datePickerProps}
isRange={isRange}
value={isRange ? exactRangeValue : exactSingleValue}
onChange={isRange ? handleExactRangeChange : handleExactSingleChange}
style={{ flex: 1 }}
/>
)}
</Space.Compact>
);
};
return C;
}, []);
const NullComponent = React.useMemo(() => {
const N: React.FC = () => (
<Input placeholder={`<${flowCtx.t?.('Null') ?? 'Null'}>`} readOnly style={{ width: '100%' }} />
@@ -600,6 +1111,8 @@ export const FieldAssignValueInput: React.FC<Props> = ({
return C;
}, [flowCtx]);
const ConstantEditor = useDateVariableConstant ? DateVariableConstantEditor : ConstantValueEditor;
const metaTree = React.useMemo<() => Promise<any[]>>(() => {
return async () => {
const base = (await flowCtx.getPropertyMetaTree?.()) || [];
@@ -612,14 +1125,39 @@ export const FieldAssignValueInput: React.FC<Props> = ({
name: 'constant',
type: 'string',
paths: ['constant'],
render: ConstantValueEditor,
render: ConstantEditor,
},
{ title: tExpr('Null'), name: 'null', type: 'object', paths: ['null'], render: NullComponent },
{ title: tExpr('RunJS'), name: 'runjs', type: 'object', paths: ['runjs'], render: RunJSComponent },
...mergedBase,
];
};
}, [flowCtx, ConstantValueEditor, NullComponent, RunJSComponent]);
}, [flowCtx, ConstantEditor, NullComponent, RunJSComponent]);
const displayValue = React.useMemo(() => {
if (!useDateVariableConstant) {
return value;
}
if (isCtxDateExpression(value)) {
const parsed = parseCtxDateExpression(value);
return typeof parsed === 'undefined' ? '' : parsed;
}
return value;
}, [useDateVariableConstant, value]);
const handleVariableInputChange = React.useCallback(
(nextValue: any) => {
if (!useDateVariableConstant) {
onChange(nextValue);
return;
}
onChange(normalizeDateVariableOutput(nextValue, dateVariableComponentProps));
},
[dateVariableComponentProps, onChange, useDateVariableConstant],
);
if (!fieldPath) {
// 不可用占位
@@ -628,22 +1166,24 @@ export const FieldAssignValueInput: React.FC<Props> = ({
return (
<VariableInput
value={value}
onChange={onChange}
value={displayValue}
onChange={handleVariableInputChange}
metaTree={metaTree}
style={{ width: '100%' }}
clearValue={''}
converters={{
renderInputComponent: (meta) => {
const firstPath = meta?.paths?.[0];
if (firstPath === 'constant') return ConstantValueEditor;
if (firstPath === 'constant') return ConstantEditor;
if (firstPath === 'null') return NullComponent;
if (firstPath === 'runjs') return RunJSComponent;
return null;
},
resolveValueFromPath: (item) => {
const firstPath = item?.paths?.[0];
if (firstPath === 'constant') return '';
if (firstPath === 'constant') {
return useDateVariableConstant ? { type: 'today' } : '';
}
if (firstPath === 'null') return null;
if (firstPath === 'runjs') return { code: '', version: 'v1' };
return undefined;
@@ -651,6 +1191,9 @@ export const FieldAssignValueInput: React.FC<Props> = ({
resolvePathFromValue: (currentValue) => {
if (currentValue === null) return ['null'];
if (isRunJSValue(currentValue)) return ['runjs'];
if (useDateVariableConstant && isCtxDateExpression(currentValue)) {
return ['constant'];
}
return isVariableExpression(currentValue) ? parseValueToPath(currentValue) : ['constant'];
},
}}
@@ -0,0 +1,121 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render } from '@nocobase/test/client';
import { dayjs } from '@nocobase/utils/client';
import { FieldAssignExactDatePicker } from '../FieldAssignExactDatePicker';
let capturedDatePickerProps: any;
let capturedRangePickerProps: any;
let capturedSelectProps: any;
vi.mock('@nocobase/flow-engine', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nocobase/flow-engine')>();
return {
...actual,
useFlowContext: () => ({
isMobileLayout: false,
model: {
translate: (value: string) => value,
},
}),
};
});
vi.mock('antd', async (importOriginal) => {
const actual = await importOriginal<typeof import('antd')>();
return {
...actual,
DatePicker: Object.assign(
(props: any) => {
capturedDatePickerProps = props;
return <div data-testid="date-picker" />;
},
{
RangePicker: (props: any) => {
capturedRangePickerProps = props;
return <div data-testid="range-picker" />;
},
},
),
Select: (props: any) => {
capturedSelectProps = props;
return <div data-testid="select" />;
},
Space: {
Compact: ({ children }: { children: React.ReactNode }) => <div data-testid="compact">{children}</div>,
},
};
});
describe('FieldAssignExactDatePicker', () => {
beforeEach(() => {
capturedDatePickerProps = undefined;
capturedRangePickerProps = undefined;
capturedSelectProps = undefined;
});
it('keeps single ISO date semantic under weekday-only format', () => {
render(
<FieldAssignExactDatePicker value="2037-03-06T12:34:56.000Z" format="dddd HH:mm:ss" showTime picker="date" />,
);
expect(dayjs.isDayjs(capturedDatePickerProps?.value)).toBe(true);
expect(capturedDatePickerProps?.value?.year()).toBe(2037);
expect(capturedDatePickerProps?.value?.date()).toBe(6);
});
it('emits dayjs value on single picker change', () => {
const onChange = vi.fn();
render(<FieldAssignExactDatePicker value="2037-03-06T12:34:56.000Z" showTime onChange={onChange} />);
act(() => {
capturedDatePickerProps?.onChange?.(dayjs('2037-03-07T01:02:03.000Z'));
});
expect(onChange).toHaveBeenCalledTimes(1);
expect(dayjs.isDayjs(onChange.mock.calls[0][0])).toBe(true);
});
it('keeps range ISO date semantic under weekday-only format', () => {
render(
<FieldAssignExactDatePicker
isRange
value={['2037-03-06T12:34:56.000Z', '2037-03-07T01:02:03.000Z']}
format="dddd HH:mm:ss"
showTime
picker="date"
/>,
);
expect(Array.isArray(capturedRangePickerProps?.value)).toBe(true);
expect(dayjs.isDayjs(capturedRangePickerProps?.value?.[0])).toBe(true);
expect(dayjs.isDayjs(capturedRangePickerProps?.value?.[1])).toBe(true);
expect(capturedRangePickerProps?.value?.[0]?.year()).toBe(2037);
expect(capturedRangePickerProps?.value?.[1]?.year()).toBe(2037);
expect(capturedRangePickerProps?.value?.[0]?.date()).toBe(6);
expect(capturedRangePickerProps?.value?.[1]?.date()).toBe(7);
});
it('keeps semantic date when switching picker granularity', () => {
const onChange = vi.fn();
render(<FieldAssignExactDatePicker value="2037-03-06T12:34:56.000Z" showTime onChange={onChange} />);
act(() => {
capturedSelectProps?.onChange?.('month');
});
expect(onChange).toHaveBeenCalledTimes(1);
expect(dayjs.isDayjs(onChange.mock.calls[0][0])).toBe(true);
expect(onChange.mock.calls[0][0].year()).toBe(2037);
expect(onChange.mock.calls[0][0].date()).toBe(6);
});
});
@@ -22,6 +22,7 @@ const { mockFieldAssignValueInput } = vi.hoisted(() => ({
data-extra={Array.isArray(props?.extraMetaTree) ? 'yes' : 'no'}
data-assoc-label={String(props?.associationFieldNamesOverride?.label || '')}
data-assoc-value={String(props?.associationFieldNamesOverride?.value || '')}
data-date-constant={props?.enableDateVariableAsConstant ? 'yes' : 'no'}
/>
)),
}));
@@ -729,4 +730,29 @@ describe('FieldAssignRulesEditor', () => {
const merged = mergeItemMetaTreeForAssignValue(base, extra);
expect(merged).toEqual(base);
});
it('passes enableDateVariableAsConstant to value input', () => {
const value: FieldAssignRuleItem[] = [
{
key: '1',
enable: true,
targetPath: 'createdAt',
mode: 'assign',
},
];
const { getByTestId } = render(
wrap(
<FieldAssignRulesEditor
t={t}
fieldOptions={[]}
value={value}
showCondition={false}
enableDateVariableAsConstant
/>,
),
);
expect(getByTestId('mock-value-input').getAttribute('data-date-constant')).toBe('yes');
});
});
@@ -0,0 +1,180 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { describe, expect, it } from 'vitest';
import { parseCtxDateExpression } from '@nocobase/flow-engine';
import { dayjs } from '@nocobase/utils/client';
import {
normalizeDateVariableExactValue,
normalizeDateVariableOutput,
toExactPickerDisplayValue,
type DateVariableExactNormalizeMode,
} from '../FieldAssignValueInput';
function normalizeExact(rawValue: any, mode: DateVariableExactNormalizeMode, format: string, showTime = false) {
return normalizeDateVariableExactValue(rawValue, {
exactNormalizeMode: mode,
format,
showTime,
});
}
describe('FieldAssignValueInput date exact normalization', () => {
it('normalizes date exact value from MMMM Do YYYY to YYYY-MM-DD', () => {
const normalized = normalizeExact('February 12th 2026', 'date', 'MMMM Do YYYY');
expect(normalized).toBe('2026-02-12');
const serialized = normalizeDateVariableOutput(normalized, {
picker: 'date',
showTime: false,
timeFormat: 'HH:mm:ss',
format: 'MMMM Do YYYY',
exactNormalizeMode: 'date',
});
expect(typeof serialized).toBe('string');
expect(parseCtxDateExpression(serialized)).toBe('2026-02-12');
});
it('normalizes datetimeNoTz exact value to stable datetime string', () => {
const normalized = normalizeExact('12/02/2026 23:45:01', 'datetimeNoTz', 'DD/MM/YYYY HH:mm:ss', true);
expect(normalized).toBe('2026-02-12 23:45:01');
const serialized = normalizeDateVariableOutput(normalized, {
picker: 'date',
showTime: true,
timeFormat: 'HH:mm:ss',
format: 'DD/MM/YYYY HH:mm:ss',
exactNormalizeMode: 'datetimeNoTz',
});
expect(parseCtxDateExpression(serialized)).toBe('2026-02-12 23:45:01');
});
it('normalizes tz-aware datetime exact value to ISO string', () => {
const normalized = normalizeExact('12/02/2026 23:45:01', 'iso', 'DD/MM/YYYY HH:mm:ss', true);
expect(typeof normalized).toBe('string');
expect(normalized).toContain('T');
const serialized = normalizeDateVariableOutput(normalized, {
picker: 'date',
showTime: true,
timeFormat: 'HH:mm:ss',
format: 'DD/MM/YYYY HH:mm:ss',
exactNormalizeMode: 'iso',
});
const parsed = parseCtxDateExpression(serialized);
expect(typeof parsed).toBe('string');
expect(parsed).toContain('T');
});
it('keeps preset/relative objects unchanged', () => {
const preset = { type: 'today' };
const relative = { type: 'next', unit: 'day', number: 2 };
const serializedPreset = normalizeDateVariableOutput(preset, {
picker: 'date',
showTime: false,
timeFormat: 'HH:mm:ss',
format: 'YYYY-MM-DD',
exactNormalizeMode: 'date',
});
const serializedRelative = normalizeDateVariableOutput(relative, {
picker: 'date',
showTime: false,
timeFormat: 'HH:mm:ss',
format: 'YYYY-MM-DD',
exactNormalizeMode: 'date',
});
expect(parseCtxDateExpression(serializedPreset)).toEqual(preset);
expect(parseCtxDateExpression(serializedRelative)).toEqual(relative);
});
it('falls back to original value when parsing fails', () => {
const raw = 'Not a valid date';
const normalized = normalizeExact(raw, 'iso', 'MMMM Do YYYY', true);
expect(normalized).toBe(raw);
const serialized = normalizeDateVariableOutput(raw, {
picker: 'date',
showTime: true,
timeFormat: 'HH:mm:ss',
format: 'MMMM Do YYYY',
exactNormalizeMode: 'iso',
});
expect(parseCtxDateExpression(serialized)).toBe(raw);
});
it('supports weekday-only display by normalizing dayjs exact value', () => {
const normalized = normalizeExact(dayjs('2026-02-13', 'YYYY-MM-DD'), 'date', 'dddd');
expect(normalized).toBe('2026-02-13');
const serialized = normalizeDateVariableOutput(dayjs('2026-02-13', 'YYYY-MM-DD'), {
picker: 'date',
showTime: false,
timeFormat: 'HH:mm:ss',
format: 'dddd',
exactNormalizeMode: 'date',
});
expect(parseCtxDateExpression(serialized)).toBe('2026-02-13');
});
it('keeps exact picker display date with weekday-only format', () => {
const display = toExactPickerDisplayValue('2026-02-13 18:14:15', {
format: 'dddd HH:mm:ss',
isRange: false,
});
expect(dayjs.isDayjs(display)).toBe(true);
expect((display as any).format('YYYY-MM-DD HH:mm:ss')).toBe('2026-02-13 18:14:15');
});
it('keeps exact picker display date for ISO input with weekday-only format', () => {
const display = toExactPickerDisplayValue('2037-03-06T12:34:56.000Z', {
format: 'dddd HH:mm:ss',
isRange: false,
});
expect(dayjs.isDayjs(display)).toBe(true);
expect((display as any).year()).toBe(2037);
expect((display as any).date()).toBe(6);
});
it('keeps exact range picker display dates with weekday-only format', () => {
const display = toExactPickerDisplayValue(['2026-02-13 18:14:15', '2026-02-14 01:02:03'], {
format: 'dddd HH:mm:ss',
isRange: true,
});
expect(Array.isArray(display)).toBe(true);
expect(dayjs.isDayjs((display as any)?.[0])).toBe(true);
expect(dayjs.isDayjs((display as any)?.[1])).toBe(true);
expect((display as any)[0].format('YYYY-MM-DD HH:mm:ss')).toBe('2026-02-13 18:14:15');
expect((display as any)[1].format('YYYY-MM-DD HH:mm:ss')).toBe('2026-02-14 01:02:03');
});
it('keeps exact range picker display dates for ISO input with weekday-only format', () => {
const display = toExactPickerDisplayValue(['2037-03-06T12:34:56.000Z', '2037-03-07T01:02:03.000Z'], {
format: 'dddd HH:mm:ss',
isRange: true,
});
expect(Array.isArray(display)).toBe(true);
expect(dayjs.isDayjs((display as any)?.[0])).toBe(true);
expect(dayjs.isDayjs((display as any)?.[1])).toBe(true);
expect((display as any)[0].year()).toBe(2037);
expect((display as any)[1].year()).toBe(2037);
expect((display as any)[0].date()).toBe(6);
expect((display as any)[1].date()).toBe(7);
});
});
@@ -15,29 +15,37 @@ import { FilterRangePicker } from './FilterRangePicker';
import { FilterDatePicker } from './FilterDatePicker';
import { useFlowEngine } from '@nocobase/flow-engine';
const getOptions = (t) => [
{
value: 'exact',
label: t('Exact day'),
},
{ value: 'past', label: t('Past') },
{ value: 'next', label: t('Next') },
{ value: 'today', label: t('Today') },
{ value: 'yesterday', label: t('Yesterday') },
{ value: 'tomorrow', label: t('Tomorrow') },
{ value: 'thisWeek', label: t('This Week') },
{ value: 'lastWeek', label: t('Last Week') },
{ value: 'nextWeek', label: t('Next Week') },
{ value: 'thisMonth', label: t('This Month') },
{ value: 'lastMonth', label: t('Last Month') },
{ value: 'nextMonth', label: t('Next Month') },
{ value: 'thisQuarter', label: t('This Quarter') },
{ value: 'lastQuarter', label: t('Last Quarter') },
{ value: 'nextQuarter', label: t('Next Quarter') },
{ value: 'thisYear', label: t('This Year') },
{ value: 'lastYear', label: t('Last Year') },
{ value: 'nextYear', label: t('Next Year') },
];
const getOptions = (t, includeNow = false) => {
const options = [
{
value: 'exact',
label: t('Exact day'),
},
{ value: 'past', label: t('Past') },
{ value: 'next', label: t('Next') },
{ value: 'today', label: t('Today') },
{ value: 'yesterday', label: t('Yesterday') },
{ value: 'tomorrow', label: t('Tomorrow') },
{ value: 'thisWeek', label: t('This Week') },
{ value: 'lastWeek', label: t('Last Week') },
{ value: 'nextWeek', label: t('Next Week') },
{ value: 'thisMonth', label: t('This Month') },
{ value: 'lastMonth', label: t('Last Month') },
{ value: 'nextMonth', label: t('Next Month') },
{ value: 'thisQuarter', label: t('This Quarter') },
{ value: 'lastQuarter', label: t('Last Quarter') },
{ value: 'nextQuarter', label: t('Next Quarter') },
{ value: 'thisYear', label: t('This Year') },
{ value: 'lastYear', label: t('Last Year') },
{ value: 'nextYear', label: t('Next Year') },
];
if (includeNow) {
options.splice(3, 0, { value: 'now', label: t('Now') });
}
return options;
};
type SmartDatePickerProps = {
isRange?: boolean;
@@ -63,12 +71,12 @@ const SmartDatePicker: React.FC<SmartDatePickerProps> = (props) => {
};
export const DateFilterDynamicComponent = (props) => {
const { value, onChange } = props;
const { value, onChange, includeNow = false, ...restProps } = props;
const flowEngine = useFlowEngine();
const t = flowEngine.translate.bind(flowEngine);
const { token } = theme.useToken();
const [open, setOpen] = useState(false);
const options = useMemo(() => getOptions(t), [t]);
const options = useMemo(() => getOptions(t, includeNow), [t, includeNow]);
const handleSelect = (val) => {
setOpen(false);
if (val === 'exact') {
@@ -119,12 +127,12 @@ export const DateFilterDynamicComponent = (props) => {
);
};
return (
<Space.Compact style={{ ...props.style, width: '100%' }}>
<Space.Compact style={{ ...restProps.style, width: '100%' }}>
<Select
options={options}
open={open}
onDropdownVisibleChange={setOpen}
{...props}
{...restProps}
allowClear={false}
style={{
width: '100%',
@@ -168,7 +176,9 @@ export const DateFilterDynamicComponent = (props) => {
popupMatchSelectWidth
/>,
]}
{(value?.type === 'exact' || !value?.type) && <SmartDatePicker {...props} />}
{(value?.type === 'exact' || !value?.type) && (
<SmartDatePicker {...restProps} value={value} onChange={onChange} />
)}
</Space.Compact>
);
};
@@ -10,6 +10,7 @@
import { describe, it, expect, vi } from 'vitest';
import { waitFor } from '@testing-library/react';
import { EventEmitter } from 'events';
import dayjs from 'dayjs';
import { observable } from '@formily/reactive';
import { get as lodashGet, merge as lodashMerge, set as lodashSet } from 'lodash';
import { FlowContext, JSRunner } from '@nocobase/flow-engine';
@@ -311,6 +312,64 @@ describe('FormValueRuntime (form assign rules)', () => {
expect(rules.has(blockId)).toBe(true);
});
it('normalizes date-only value for unixTimestamp target to start-of-day datetime', async () => {
const engineEmitter = new EventEmitter();
const blockEmitter = new EventEmitter();
const formStub = createFormStub({});
const blockModel: any = {
uid: 'form-assign-unix-ts-1',
flowEngine: { emitter: engineEmitter },
emitter: blockEmitter,
dispatchEvent: vi.fn(),
getAclActionName: () => 'create',
};
const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
runtime.mount({ sync: true });
const blockCtx = createFieldContext(runtime);
blockModel.context = blockCtx;
runtime.syncAssignRules([
{
key: 'r1',
enable: true,
targetPath: 'a',
mode: 'assign',
condition: { logic: '$and', items: [] },
value: '2026-02-12',
},
]);
const fieldCtx = createFieldContext(runtime);
fieldCtx.defineProperty('blockModel', { value: blockModel });
fieldCtx.defineProperty('collectionField', { value: { interface: 'unixTimestamp' } });
const fieldModel: any = {
uid: 'field-a-unix-ts-1',
subModels: { field: {} },
getStepParams(flowKey: string, stepKey: string) {
if (flowKey === 'fieldSettings' && stepKey === 'init') {
return { fieldPath: 'a' };
}
return undefined;
},
};
fieldCtx.defineProperty('model', { value: fieldModel });
fieldModel.context = fieldCtx;
engineEmitter.emit('model:mounted', { model: fieldModel });
await waitFor(() => {
const assigned = formStub.getFieldValue(['a']);
expect(typeof assigned).toBe('string');
expect(assigned).not.toBe('2026-02-12');
expect(dayjs(assigned).isValid()).toBe(true);
expect(dayjs(assigned).format('YYYY-MM-DD')).toBe('2026-02-12');
});
});
it('supports RunJSValue and updates on formValues dependency change', async () => {
const engineEmitter = new EventEmitter();
const blockEmitter = new EventEmitter();
@@ -10,6 +10,7 @@
import { isObservable, reaction, toJS } from '@formily/reactive';
import { FlowContext, FlowModel, isRunJSValue, normalizeRunJSValue, runjsWithSafeGlobals } from '@nocobase/flow-engine';
import _ from 'lodash';
import { dayjs } from '@nocobase/utils/client';
import { evaluateCondition } from './conditions';
import { collectStaticDepsFromRunJSValue, collectStaticDepsFromTemplateValue, DepCollector, recordDep } from './deps';
import { namePathToPathKey, parsePathString, pathKeyToNamePath } from './path';
@@ -1134,10 +1135,12 @@ export class RuleEngine {
// Update dependencies
this.commitRuleDeps(rule, state, collector);
const normalizedResolved = this.normalizeResolvedValueForTarget(baseCtx, resolved);
// Apply default rule specific logic
if (rule.source === 'default') {
const shouldApply = this.checkDefaultRuleCanApply(
resolved,
normalizedResolved,
targetNamePath!,
targetKey!,
clearDeps,
@@ -1184,7 +1187,7 @@ export class RuleEngine {
// 若目标值本身无变化,则不应为了“初始化子表单对象/行”而产生额外写入
// (典型场景:assign 规则解析为 undefined,但当前值也是 undefined)
const currentValue = this.options.getFormValueAtPath(targetNamePath!);
const nextSnapshot = isObservable(resolved) ? toJS(resolved) : resolved;
const nextSnapshot = isObservable(normalizedResolved) ? toJS(normalizedResolved) : normalizedResolved;
if (_.isEqual(currentValue, nextSnapshot)) {
return;
}
@@ -1200,13 +1203,13 @@ export class RuleEngine {
if (modelForUi?.subModels?.field) {
const modelTarget = this.getModelTargetNamePath(modelForUi);
if (modelTarget && namePathToPathKey(modelTarget) === targetKey) {
modelForUi.setProps({ value: resolved });
modelForUi.setProps({ value: normalizedResolved });
}
}
}
const beforeWriteSeq = this.options.getWriteSeq();
const patches = [...initPatches, { path: targetNamePath, value: resolved }];
const patches = [...initPatches, { path: targetNamePath, value: normalizedResolved }];
await this.options.setFormValues(evalCtx, patches, {
source: rule.source,
txId: this.currentRuleTxId || undefined,
@@ -1221,6 +1224,41 @@ export class RuleEngine {
}
}
private isTzAwareTargetInterface(targetInterface: unknown): boolean {
if (typeof targetInterface !== 'string') {
return false;
}
return ['datetime', 'createdAt', 'updatedAt', 'unixTimestamp'].includes(targetInterface);
}
private normalizeDateOnlyToStartOfDayIso(value: string): string {
const raw = String(value || '').trim();
if (!raw) return value;
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return value;
const parsed = dayjs(raw, 'YYYY-MM-DD', true);
if (!parsed.isValid()) return value;
return parsed.startOf('day').toISOString();
}
private normalizeResolvedValueForTarget(baseCtx: any, resolved: any): any {
const targetInterface = baseCtx?.model?.context?.collectionField?.interface;
if (!this.isTzAwareTargetInterface(targetInterface)) {
return resolved;
}
if (typeof resolved === 'string') {
return this.normalizeDateOnlyToStartOfDayIso(resolved);
}
if (Array.isArray(resolved)) {
return resolved.map((item) => (typeof item === 'string' ? this.normalizeDateOnlyToStartOfDayIso(item) : item));
}
return resolved;
}
private prepareRuleContext(rule: RuntimeRule) {
const baseCtx = rule.getContext();
const rawTarget = rule.getTarget();
@@ -1222,6 +1222,22 @@ describe('getPropertyMetaTree with deep delegate meta', () => {
});
describe('FlowContext resolveOnServer selective server resolution', () => {
it('resolves ctx.date expressions on client context', async () => {
const engine = new FlowEngine();
const out = await (engine.context as any).resolveJsonTemplate({
today: '{{ ctx.date.preset.today }}',
next12: '{{ ctx.date.relative.next.day.n12 }}',
now: '{{ ctx.date.preset.now }}',
});
expect(typeof out.today).toBe('string');
expect(out.today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(typeof out.next12).toBe('string');
expect(out.next12).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(typeof out.now).toBe('string');
expect(out.now.length).toBeGreaterThan(0);
});
it('does not call server by default (no resolveOnServer set)', async () => {
const engine = new FlowEngine();
const api = { request: vi.fn() } as any;
+29 -51
View File
@@ -36,8 +36,11 @@ import {
escapeT,
extractPropertyPath,
extractUsedVariablePaths,
FlowExitException,
isCtxDatePathPrefix,
isCssFile,
prepareRunJsCode,
resolveCtxDatePath,
resolveDefaultParams,
resolveExpressions,
resolveModuleUrl,
@@ -1427,6 +1430,32 @@ export class FlowEngineContext extends BaseFlowEngineContext {
user: this.user,
}),
});
this.defineProperty('date', {
get: () => {
const createBranch = (prefix: string[]) => {
return new Proxy(
{},
{
get: (_target, prop) => {
if (typeof prop !== 'string') return undefined;
const nextPath = [...prefix, prop];
if (!isCtxDatePathPrefix(nextPath)) {
return undefined;
}
const resolved = resolveCtxDatePath(nextPath);
if (typeof resolved !== 'undefined') {
return resolved;
}
return createBranch(nextPath);
},
},
);
};
return createBranch(['date']);
},
cache: false,
});
this.defineMethod('loadCSS', async (href: string) => {
const url = resolveModuleUrl(href);
return new Promise((resolve, reject) => {
@@ -1492,57 +1521,6 @@ export class FlowEngineContext extends BaseFlowEngineContext {
return this.engine.getEvents();
});
// // Date variables (for variable selector meta tree)
// this.defineProperty('date', {
// get: () => {
// const vars = getDateVars() as Record<string, any>;
// // align with client options: add dayBeforeYesterday
// vars.dayBeforeYesterday = toUnit('day', -2);
// const now = new Date().toISOString();
// const out: Record<string, any> = {};
// for (const [k, v] of Object.entries(vars)) {
// try {
// out[k] = typeof v === 'function' ? v({ now }) : v;
// } catch (e) {
// // ignore
// }
// }
// return out;
// },
// meta: () => {
// const title = this.t('Date variables');
// const mk = (t: string) => ({ type: 'any', title: this.t(t) });
// return {
// type: 'object',
// title,
// properties: {
// now: mk('Current time'),
// dayBeforeYesterday: mk('Day before yesterday'),
// yesterday: mk('Yesterday'),
// today: mk('Today'),
// tomorrow: mk('Tomorrow'),
// lastIsoWeek: mk('Last week'),
// thisIsoWeek: mk('This week'),
// nextIsoWeek: mk('Next week'),
// lastMonth: mk('Last month'),
// thisMonth: mk('This month'),
// nextMonth: mk('Next month'),
// lastQuarter: mk('Last quarter'),
// thisQuarter: mk('This quarter'),
// nextQuarter: mk('Next quarter'),
// lastYear: mk('Last year'),
// thisYear: mk('This year'),
// nextYear: mk('Next year'),
// last7Days: mk('Last 7 days'),
// next7Days: mk('Next 7 days'),
// last30Days: mk('Last 30 days'),
// next30Days: mk('Next 30 days'),
// last90Days: mk('Last 90 days'),
// next90Days: mk('Next 90 days'),
// },
// } as PropertyMeta;
// },
// });
this.defineMethod(
'runAction',
async function (this: BaseFlowEngineContext, actionName: string, params?: Record<string, any>) {
@@ -0,0 +1,101 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { describe, expect, it } from 'vitest';
import {
decodeBase64Url,
encodeBase64Url,
isCompleteCtxDatePath,
isCtxDateExpression,
parseCtxDateExpression,
resolveCtxDatePath,
serializeCtxDateValue,
} from '../dateVariable';
describe('dateVariable utils', () => {
it('encodes and decodes base64url', () => {
const raw = '2026-02-12 10:11:12';
const encoded = encodeBase64Url(raw);
const decoded = decodeBase64Url(encoded);
expect(decoded).toBe(raw);
});
it('detects ctx.date expression', () => {
expect(isCtxDateExpression('{{ ctx.date.preset.today }}')).toBe(true);
expect(isCtxDateExpression('{{ ctx.user.name }}')).toBe(false);
expect(isCtxDateExpression('')).toBe(false);
});
it('serializes preset/relative/exact single/range', () => {
expect(serializeCtxDateValue({ type: 'today' })).toBe('{{ ctx.date.preset.today }}');
expect(serializeCtxDateValue({ type: 'next', unit: 'day', number: 12 })).toBe(
'{{ ctx.date.relative.next.day.n12 }}',
);
const single = serializeCtxDateValue('2026-02-12');
expect(single?.startsWith('{{ ctx.date.exact.single.date.v')).toBe(true);
const range = serializeCtxDateValue(['2026-02-12', '2026-02-20']);
expect(range?.startsWith('{{ ctx.date.exact.range.date.v')).toBe(true);
expect(range?.includes('.v')).toBe(true);
});
it('parses expression back to ui value', () => {
expect(parseCtxDateExpression('{{ ctx.date.preset.today }}')).toEqual({ type: 'today' });
expect(parseCtxDateExpression('{{ ctx.date.relative.past.month.n2 }}')).toEqual({
type: 'past',
unit: 'month',
number: 2,
});
const singleExpr = serializeCtxDateValue('2026-02-12')!;
expect(parseCtxDateExpression(singleExpr)).toBe('2026-02-12');
const rangeExpr = serializeCtxDateValue(['2026-02-12', '2026-02-20'])!;
expect(parseCtxDateExpression(rangeExpr)).toEqual(['2026-02-12', '2026-02-20']);
});
it('resolves preset/relative/exact path', () => {
expect(typeof resolveCtxDatePath(['date', 'preset', 'now'])).toBe('string');
const today = resolveCtxDatePath(['date', 'preset', 'today']);
expect(typeof today).toBe('string');
expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
const rel = resolveCtxDatePath(['date', 'relative', 'next', 'day', 'n12']);
expect(typeof rel).toBe('string');
expect(rel).toMatch(/^\d{4}-\d{2}-\d{2}$/);
const singleExpr = serializeCtxDateValue('2026-02-12')!;
const token = singleExpr.replace('{{ ctx.date.exact.single.date.', '').replace(' }}', '');
expect(resolveCtxDatePath(['date', 'exact', 'single', 'date', token])).toBe('2026-02-12');
const rangeExpr = serializeCtxDateValue(['2026-02-12', '2026-02-20'])!;
const parts = rangeExpr.replace('{{ ctx.date.exact.range.date.', '').replace(' }}', '').split('.');
expect(resolveCtxDatePath(['date', 'exact', 'range', 'date', parts[0], parts[1]])).toEqual([
'2026-02-12',
'2026-02-20',
]);
});
it('validates complete ctx.date path', () => {
expect(isCompleteCtxDatePath(['date', 'preset', 'today'])).toBe(true);
expect(isCompleteCtxDatePath(['date', 'relative', 'next', 'day', 'n12'])).toBe(true);
expect(isCompleteCtxDatePath(['date', 'exact', 'single', 'date', 'vabc'])).toBe(true);
expect(isCompleteCtxDatePath(['date', 'exact', 'range', 'date', 'vabc', 'vdef'])).toBe(true);
expect(isCompleteCtxDatePath(['date', 'relative', 'next', 'day'])).toBe(false);
expect(isCompleteCtxDatePath(['user', 'name'])).toBe(false);
});
it('handles invalid base64 and path gracefully', () => {
expect(decodeBase64Url('@@@')).toBeUndefined();
expect(parseCtxDateExpression('{{ ctx.date.exact.single.date.v@@@ }}')).toBeUndefined();
expect(resolveCtxDatePath(['date', 'exact', 'single', 'date', 'v@@@'])).toBeUndefined();
});
});
@@ -0,0 +1,397 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import dayjs from 'dayjs';
const CTX_DATE_REGEX = /^\{\{\s*ctx\.date(?:\.(.+?))?\s*\}\}$/;
const PRESET_KEYS = new Set([
'today',
'now',
'yesterday',
'tomorrow',
'thisWeek',
'lastWeek',
'nextWeek',
'thisMonth',
'lastMonth',
'nextMonth',
'thisQuarter',
'lastQuarter',
'nextQuarter',
'thisYear',
'lastYear',
'nextYear',
]);
const RELATIVE_DIRECTIONS = new Set(['next', 'past']);
const RELATIVE_UNITS = new Set(['day', 'week', 'month', 'year']);
function parseCtxDateSegments(value: string): string[] | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
const match = trimmed.match(CTX_DATE_REGEX);
if (!match) return null;
const rawPath = String(match[1] || '');
if (!rawPath) return [];
return rawPath
.split('.')
.map((seg) => seg.trim())
.filter(Boolean);
}
export function isCtxDatePathPrefix(pathSegments: string[]): boolean {
const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
if (segments[0] !== 'date') return false;
if (segments.length === 1) return true;
if (segments[1] === 'preset') {
if (segments.length === 2) return true;
return segments.length === 3 && PRESET_KEYS.has(segments[2]);
}
if (segments[1] === 'relative') {
if (segments.length === 2) return true;
if (segments.length === 3) return RELATIVE_DIRECTIONS.has(segments[2]);
if (segments.length === 4) {
return RELATIVE_DIRECTIONS.has(segments[2]) && RELATIVE_UNITS.has(segments[3]);
}
if (segments.length === 5) {
return (
RELATIVE_DIRECTIONS.has(segments[2]) &&
RELATIVE_UNITS.has(segments[3]) &&
typeof parseNumberToken(segments[4]) === 'number'
);
}
return false;
}
if (segments[1] === 'exact') {
if (segments.length === 2) return true;
if (segments[2] === 'single') {
if (segments.length === 3) return true;
if (segments.length === 4) return segments[3] === 'date';
if (segments.length === 5) return segments[3] === 'date' && /^v.+/.test(segments[4]);
return false;
}
if (segments[2] === 'range') {
if (segments.length === 3) return true;
if (segments.length === 4) return segments[3] === 'date';
if (segments.length === 5) return segments[3] === 'date' && /^v.+/.test(segments[4]);
if (segments.length === 6) return segments[3] === 'date' && /^v.+/.test(segments[4]) && /^v.+/.test(segments[5]);
return false;
}
return false;
}
return false;
}
function withDatePrefix(pathSegments: string[]): string[] {
if (pathSegments[0] === 'date') {
return pathSegments;
}
return ['date', ...pathSegments];
}
function toCtxDateExpression(pathSegments: string[]): string {
const segs = withDatePrefix(pathSegments);
return `{{ ctx.${segs.join('.')} }}`;
}
function utf8ToBase64(input: string): string {
const globalBuffer = (globalThis as any)?.Buffer;
if (globalBuffer && typeof globalBuffer.from === 'function') {
return globalBuffer.from(input, 'utf8').toString('base64');
}
if (typeof btoa === 'function') {
const encoded = encodeURIComponent(input).replace(/%([0-9A-F]{2})/g, (_m, p1) =>
String.fromCharCode(parseInt(p1, 16)),
);
return btoa(encoded);
}
throw new Error('No base64 encoder available');
}
function base64ToUtf8(input: string): string {
const globalBuffer = (globalThis as any)?.Buffer;
if (globalBuffer && typeof globalBuffer.from === 'function') {
return globalBuffer.from(input, 'base64').toString('utf8');
}
if (typeof atob === 'function') {
const binary = atob(input);
const encoded = Array.from(binary)
.map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
.join('');
return decodeURIComponent(encoded);
}
throw new Error('No base64 decoder available');
}
function normalizeToString(value: any): string | undefined {
if (value == null) return undefined;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length ? trimmed : undefined;
}
if (dayjs.isDayjs(value)) {
return value.toISOString();
}
if (value instanceof Date) {
return dayjs(value).toISOString();
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return undefined;
}
function parseNumberToken(token: string): number | undefined {
const match = String(token || '').match(/^n(\d+)$/);
if (!match) return undefined;
const parsed = Number(match[1]);
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
return parsed;
}
function startOfIsoWeek(date: dayjs.Dayjs): dayjs.Dayjs {
const day = date.day();
const offset = day === 0 ? -6 : 1 - day;
return date.add(offset, 'day').startOf('day');
}
function startOfQuarter(date: dayjs.Dayjs): dayjs.Dayjs {
const month = date.month();
const quarterStartMonth = Math.floor(month / 3) * 3;
return date.month(quarterStartMonth).startOf('month');
}
export function encodeBase64Url(input: string): string {
const base64 = utf8ToBase64(String(input || ''));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
export function decodeBase64Url(input: string): string | undefined {
try {
const raw = String(input || '').replace(/=+$/g, '');
if (!raw) return undefined;
if (!/^[A-Za-z0-9_-]+$/.test(raw)) return undefined;
const normalized = raw.replace(/-/g, '+').replace(/_/g, '/');
const padLength = normalized.length % 4 === 0 ? 0 : 4 - (normalized.length % 4);
const padded = `${normalized}${'='.repeat(padLength)}`;
const decoded = base64ToUtf8(padded);
if (encodeBase64Url(decoded) !== raw) return undefined;
return decoded;
} catch (_error) {
return undefined;
}
}
export function isCtxDateExpression(value: unknown): value is string {
if (typeof value !== 'string') return false;
return CTX_DATE_REGEX.test(value.trim());
}
export function isCompleteCtxDatePath(pathSegments: string[]): boolean {
if (!isCtxDatePathPrefix(pathSegments)) return false;
const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
if (segments[0] !== 'date') return false;
if (segments[1] === 'preset') {
return segments.length === 3 && PRESET_KEYS.has(segments[2]);
}
if (segments[1] === 'relative') {
if (segments.length !== 5) return false;
return (
RELATIVE_DIRECTIONS.has(segments[2]) &&
RELATIVE_UNITS.has(segments[3]) &&
typeof parseNumberToken(segments[4]) === 'number'
);
}
if (segments[1] === 'exact' && segments[2] === 'single' && segments[3] === 'date') {
return segments.length === 5 && /^v.+/.test(segments[4]);
}
if (segments[1] === 'exact' && segments[2] === 'range' && segments[3] === 'date') {
return segments.length === 6 && /^v.+/.test(segments[4]) && /^v.+/.test(segments[5]);
}
return false;
}
export function parseCtxDateExpression(value: unknown): any {
if (!isCtxDateExpression(value)) return undefined;
const segments = withDatePrefix(parseCtxDateSegments(value as string) || []);
if (segments[1] === 'preset' && segments.length === 3 && PRESET_KEYS.has(segments[2])) {
return { type: segments[2] };
}
if (
segments[1] === 'relative' &&
segments.length === 5 &&
RELATIVE_DIRECTIONS.has(segments[2]) &&
RELATIVE_UNITS.has(segments[3])
) {
const amount = parseNumberToken(segments[4]);
if (typeof amount === 'number') {
return { type: segments[2], unit: segments[3], number: amount };
}
return undefined;
}
if (segments[1] === 'exact' && segments[2] === 'single' && segments[3] === 'date' && segments.length === 5) {
const raw = String(segments[4] || '');
if (!raw.startsWith('v')) return undefined;
return decodeBase64Url(raw.slice(1));
}
if (segments[1] === 'exact' && segments[2] === 'range' && segments[3] === 'date' && segments.length === 6) {
const leftRaw = String(segments[4] || '');
const rightRaw = String(segments[5] || '');
if (!leftRaw.startsWith('v') || !rightRaw.startsWith('v')) return undefined;
const left = decodeBase64Url(leftRaw.slice(1));
const right = decodeBase64Url(rightRaw.slice(1));
if (typeof left === 'undefined' || typeof right === 'undefined') return undefined;
return [left, right];
}
return undefined;
}
export function serializeCtxDateValue(value: unknown): string | undefined {
if (isCtxDateExpression(value)) {
return String(value).trim();
}
if (value == null || value === '') {
return undefined;
}
if (Array.isArray(value)) {
const start = normalizeToString(value[0]);
const end = normalizeToString(value[1]);
if (start && end) {
return toCtxDateExpression([
'date',
'exact',
'range',
'date',
`v${encodeBase64Url(start)}`,
`v${encodeBase64Url(end)}`,
]);
}
if (start) {
return toCtxDateExpression(['date', 'exact', 'single', 'date', `v${encodeBase64Url(start)}`]);
}
return undefined;
}
if (typeof value === 'object' && value) {
const typed = value as { type?: unknown; unit?: unknown; number?: unknown };
const type = typeof typed.type === 'string' ? typed.type : '';
if (type === 'past' || type === 'next') {
const unit = typeof typed.unit === 'string' && RELATIVE_UNITS.has(typed.unit) ? typed.unit : 'day';
const rawNumber = Number(typed.number);
const number = Number.isFinite(rawNumber) && rawNumber > 0 ? Math.floor(rawNumber) : 1;
return toCtxDateExpression(['date', 'relative', type, unit, `n${number}`]);
}
if (PRESET_KEYS.has(type)) {
return toCtxDateExpression(['date', 'preset', type]);
}
}
const single = normalizeToString(value);
if (single) {
return toCtxDateExpression(['date', 'exact', 'single', 'date', `v${encodeBase64Url(single)}`]);
}
return undefined;
}
export function resolveCtxDatePath(pathSegments: string[]): any {
const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
if (segments[0] !== 'date') return undefined;
if (segments[1] === 'preset' && segments.length === 3) {
const key = segments[2];
if (key === 'now') {
return dayjs().toISOString();
}
const now = dayjs();
if (key === 'today') return now.format('YYYY-MM-DD');
if (key === 'yesterday') return now.add(-1, 'day').format('YYYY-MM-DD');
if (key === 'tomorrow') return now.add(1, 'day').format('YYYY-MM-DD');
if (key === 'thisWeek') return startOfIsoWeek(now).format('YYYY-MM-DD');
if (key === 'lastWeek') return startOfIsoWeek(now.add(-1, 'week')).format('YYYY-MM-DD');
if (key === 'nextWeek') return startOfIsoWeek(now.add(1, 'week')).format('YYYY-MM-DD');
if (key === 'thisMonth') return now.startOf('month').format('YYYY-MM-DD');
if (key === 'lastMonth') return now.add(-1, 'month').startOf('month').format('YYYY-MM-DD');
if (key === 'nextMonth') return now.add(1, 'month').startOf('month').format('YYYY-MM-DD');
if (key === 'thisQuarter') return startOfQuarter(now).format('YYYY-MM-DD');
if (key === 'lastQuarter') return startOfQuarter(now.add(-3, 'month')).format('YYYY-MM-DD');
if (key === 'nextQuarter') return startOfQuarter(now.add(3, 'month')).format('YYYY-MM-DD');
if (key === 'thisYear') return now.startOf('year').format('YYYY-MM-DD');
if (key === 'lastYear') return now.add(-1, 'year').startOf('year').format('YYYY-MM-DD');
if (key === 'nextYear') return now.add(1, 'year').startOf('year').format('YYYY-MM-DD');
return undefined;
}
if (
segments[1] === 'relative' &&
segments.length === 5 &&
RELATIVE_DIRECTIONS.has(segments[2]) &&
RELATIVE_UNITS.has(segments[3])
) {
const amount = parseNumberToken(segments[4]);
if (typeof amount !== 'number') return undefined;
const direction = segments[2] === 'past' ? -1 : 1;
const unit = segments[3] as dayjs.ManipulateType;
return dayjs()
.add(direction * amount, unit)
.format('YYYY-MM-DD');
}
if (segments[1] === 'exact' && segments[2] === 'single' && segments[3] === 'date' && segments.length === 5) {
const token = String(segments[4] || '');
if (!token.startsWith('v')) return undefined;
return decodeBase64Url(token.slice(1));
}
if (segments[1] === 'exact' && segments[2] === 'range' && segments[3] === 'date' && segments.length === 6) {
const leftToken = String(segments[4] || '');
const rightToken = String(segments[5] || '');
if (!leftToken.startsWith('v') || !rightToken.startsWith('v')) return undefined;
const left = decodeBase64Url(leftToken.slice(1));
const right = decodeBase64Url(rightToken.slice(1));
if (typeof left === 'undefined' || typeof right === 'undefined') return undefined;
return [left, right];
}
return undefined;
}
@@ -65,6 +65,16 @@ export { extractPropertyPath, formatPathToVariable, isVariableExpression } from
export { clearAutoFlowError, getAutoFlowError, setAutoFlowError, type AutoFlowError } from './autoFlowError';
export { parsePathnameToViewParams, type ViewParam } from './parsePathnameToViewParams';
export {
decodeBase64Url,
encodeBase64Url,
isCompleteCtxDatePath,
isCtxDatePathPrefix,
isCtxDateExpression,
parseCtxDateExpression,
resolveCtxDatePath,
serializeCtxDateValue,
} from './dateVariable';
// 安全全局对象(window/document)
export {
@@ -89,7 +89,7 @@ describe('ServerBaseContext', () => {
});
describe('GlobalContext', () => {
it('filters env and exposes now/timestamp/date', () => {
it('filters env and exposes now/timestamp', () => {
const env = {
PUBLIC_FOO: 'x',
NEXT_PUBLIC_BAR: 'y',
@@ -100,10 +100,7 @@ describe('GlobalContext', () => {
expect(g.env).toEqual({ PUBLIC_FOO: 'x', NEXT_PUBLIC_BAR: 'y', NCB_PUBLIC_Z: 'z' });
expect(typeof g.timestamp).toBe('number');
expect(typeof g.now).toBe('string');
// date variables: shape exists, with at least today and dayBeforeYesterday
expect(typeof g.date).toBe('object');
expect('today' in g.date).toBe(true);
expect('dayBeforeYesterday' in g.date).toBe(true);
expect('date' in g).toBe(false);
});
});
@@ -100,16 +100,6 @@ describe('variables resolver (no HTTP)', () => {
expect(out.t).toBe('undefined');
});
it('resolves date variables (shape existence)', async () => {
const { req } = makeCtx(1);
const tpl = { today: '{{ ctx.date.today }}', thisMonth: '{{ ctx.date.thisMonth }}' } as any;
const out = await resolveJsonTemplate(tpl, req);
expect(typeof out.today).toBe('string');
expect(out.today.length).toBeGreaterThan(0);
expect(typeof out.thisMonth).toBe('string');
expect(out.thisMonth.length).toBeGreaterThan(0);
});
it('supports custom ctx methods attached via registry', async () => {
if (!variables.get('twice')) {
variables.register({
@@ -8,7 +8,6 @@
*/
import _ from 'lodash';
import { getDateVars, toUnit } from '@nocobase/utils';
import { ResourcerContext } from '@nocobase/resourcer';
type Getter<T = any> = (ctx: ServerBaseContext) => T | Promise<T>;
@@ -148,7 +147,6 @@ export class ServerBaseContext {
* 全局上下文:
* - now/timestamp:时间值(不缓存)
* - env:环境变量(仅暴露白名单前缀)
* - date:日期快捷变量集合(包含前天等)
*/
export class GlobalContext extends ServerBaseContext {
constructor(env?: Record<string, any>) {
@@ -157,12 +155,6 @@ export class GlobalContext extends ServerBaseContext {
this.defineProperty('timestamp', { get: () => Date.now(), cache: false });
// 仅暴露经过白名单过滤的环境变量,避免泄露敏感信息
if (env) this.defineProperty('env', { value: filterEnv(env) });
// 日期变量集合:与历史 $nDate/$date 变量保持一致(并补充 dayBeforeYesterday)
this.defineProperty('date', {
get: (_ctx) => buildDateVariables(),
cache: false,
});
}
}
@@ -206,53 +198,3 @@ function filterEnv(env: Record<string, any>, allowedPrefixes: string[] = DEFAULT
return {};
}
}
/**
* 构建日期快捷变量集合(依据服务端时区)。
*/
function buildDateVariables() {
// 使用服务端时区
const timezone = getServerTimezone();
const now = new Date().toISOString();
// 基于现有工具获取日期变量集合
const base = getDateVars() as Record<string, any>;
// 补充 "前天"
base.dayBeforeYesterday = toUnit('day', -2);
const out: Record<string, any> = {};
for (const [key, val] of Object.entries(base)) {
try {
out[key] = typeof val === 'function' ? val({ timezone, now }) : val;
} catch (_) {
// 出错时跳过该变量,避免影响整体解析
}
}
return out;
}
/**
* 获取服务端时区:优先读取 DB_TIMEZONE/TZ,回退到本地时区偏移。
*/
function getServerTimezone(): string {
// 优先使用环境变量 DB_TIMEZONE 或 TZ
const tzEnv = process?.env?.DB_TIMEZONE || process?.env?.TZ;
if (tzEnv) {
// 支持 +08:00/-05:00 或 IANA 时区名(如 Asia/Shanghai)
const m = tzEnv.match(/^([+-])(\d{1,2}):(\d{2})$/);
if (m) {
const sign = m[1];
const hh = m[2].padStart(2, '0');
const mm = m[3];
return `${sign}${hh}:${mm}`;
}
return tzEnv; // 作为 IANA 时区名传递
}
// 回退:根据服务器本地偏移生成 +HH:MM
const offsetMinutes = -new Date().getTimezoneOffset(); // 东八区为 +480
const sign = offsetMinutes >= 0 ? '+' : '-';
const abs = Math.abs(offsetMinutes);
const hh = String(Math.floor(abs / 60)).padStart(2, '0');
const mm = String(abs % 60).padStart(2, '0');
return `${sign}${hh}:${mm}`;
}