mirror of
https://github.com/nocobase/nocobase.git
synced 2026-10-29 18:16:18 +08:00
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:
@@ -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'];
|
||||
},
|
||||
}}
|
||||
|
||||
+121
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+180
@@ -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);
|
||||
});
|
||||
});
|
||||
+38
-28
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+59
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+2
-5
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
-10
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user