feat(parse-variables): support to parse variables in filter params (#1558)

* fix: add field linkage on setting default datetime

* fix: fix dateonly timezone problem

* fix: improve test

* docs(DatePicker): add demos

* fix(DatePicker): should return the beginning of a second

* feat(DatePicker): support non-UTC

* refactor: rename

* fix(RangePicker): get correct end date

* test(mapDatePicker): add test

* test(mapRangePicker): add test

* feat(Filter): use non-UTC to filter

* feat(FilterBlock): use non-UTC to filter

* feat: add '$dateBetween' operator in datetime

* feat: use RangePicker on toggled to 'dateBetween' operator

* feat: set ranges for RangePicker

* feat: backend support to parse 'dateBetween' operator

* fix: fix build error

* fix: adaptive content width

* feat: support to use var on data scope

* feat: add parse-variables plugin

* feat: support to parse variables

* feat: support only to set system variables

* test: rename

* feat: cover all

* fix: fix build error

* feat(RangePicker): extend more shortcut keys

* feat(parse-variables): support more date var

* feat: support user variables

* feat: disable unmatched options

* fix: use component name to filter option

* fix: fix build error

* feat: remove some operator of id

* chore: remove useless operators

* fix: built in plugin

* refactor: move to core from plugin

* refactor: remove code of plugin

* refactor: remove useless code

* fix: should after acl

* Update server.ts

* fix: compatible with old version

* feat: test cases

* refactor: rename to 'is between'

* refactor: parse filter

* fix: improve code

* feat: test cases

* fix: fix error

* fix: improve parse date

* fix: date variables

* fix: day range

* fix: test error

* fix: typo

* fix: test error

* feat: $user variable

* fix: toDate

* fix: fix the value range of shortcuts

* feat: add quarter and test

* feat: support to use user's association fields to filter

* refactor: use maxDepth

* refactor: remove useless code

* fix: make AssociationSelect.Designer to support variables

* fix: getField

* fix: parse utc

* fix: remove only

* fix: filter by ctx.db.getFieldByPath

* fix: avoid error

* fix: add translation

* fix(RangePicker): can be set to empty

* feat(utils): add hasEmptyValue

* fix: should not save empty

* fix: last few days should include today

* fix: limit user variable type to display

* fix: parse filter error

* fix: empty

* test: [skip ci]

* fix: remove ';'

* feat: improve code

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
被雨水过滤的空气-Rairn 2023-03-30 23:49:57 +08:00 committed by GitHub
parent c2885ee1d6
commit 098140d511
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 3178 additions and 446 deletions

View File

@ -3,13 +3,14 @@ import { SchemaComponentOptions } from '../schema-component/core/SchemaComponent
import { RecordLink, useParamsFromRecord, useSourceIdFromParentRecord, useSourceIdFromRecord } from './BlockProvider';
import { CalendarBlockProvider, useCalendarBlockProps } from './CalendarBlockProvider';
import { DetailsBlockProvider, useDetailsBlockProps } from './DetailsBlockProvider';
import { FilterFormBlockProvider } from './FilterFormBlockProvider';
import { FormBlockProvider, useFormBlockProps } from './FormBlockProvider';
import { FormFieldProvider, useFormFieldProps } from './FormFieldProvider';
import * as bp from './hooks';
import { KanbanBlockProvider, useKanbanBlockProps } from './KanbanBlockProvider';
import { TableBlockProvider, useTableBlockProps } from './TableBlockProvider';
import { TableFieldProvider, useTableFieldProps } from './TableFieldProvider';
import { TableSelectorProvider, useTableSelectorProps } from './TableSelectorProvider';
import { FormFieldProvider, useFormFieldProps } from './FormFieldProvider';
export const BlockSchemaComponentProvider: React.FC = (props) => {
return (
@ -20,6 +21,7 @@ export const BlockSchemaComponentProvider: React.FC = (props) => {
TableBlockProvider,
TableSelectorProvider,
FormBlockProvider,
FilterFormBlockProvider,
FormFieldProvider,
DetailsBlockProvider,
KanbanBlockProvider,

View File

@ -0,0 +1,11 @@
import React from 'react';
import { DatePickerProvider } from '../schema-component';
import { FormBlockProvider } from './FormBlockProvider';
export const FilterFormBlockProvider = (props) => {
return (
<DatePickerProvider value={{ utc: false }}>
<FormBlockProvider {...props}></FormBlockProvider>
</DatePickerProvider>
);
};

View File

@ -94,7 +94,7 @@ export const useFormBlockProps = () => {
const addChild = fieldSchema?.['x-component-props']?.addChild;
useEffect(() => {
if (addChild) {
ctx.form.query('parent').take((field) => {
ctx.form?.query('parent').take((field) => {
field.disabled = true;
field.value = new Proxy({ ...record }, {});
});
@ -102,7 +102,7 @@ export const useFormBlockProps = () => {
});
useEffect(() => {
ctx.form.setInitialValues(ctx.service?.data?.data);
ctx.form?.setInitialValues(ctx.service?.data?.data);
}, []);
return {
form: ctx.form,

View File

@ -1,10 +1,12 @@
export * from './BlockProvider';
export * from './BlockSchemaComponentProvider';
export * from './CalendarBlockProvider';
export * from './FilterFormBlockProvider';
export * from './FormBlockProvider';
export * from './FormFieldProvider';
export * from './KanbanBlockProvider';
export * from './SharedFilterProvider';
export * from './TableBlockProvider';
export * from './TableFieldProvider';
export * from './TableSelectorProvider';
export * from './FormFieldProvider';
export * from './SharedFilterProvider';

View File

@ -28,6 +28,24 @@ const getSchema = (schema: IField, record: any, compile) => {
properties['defaultValue'] = cloneDeep(schema?.default?.uiSchema);
properties['defaultValue']['title'] = compile('{{ t("Default value") }}');
properties['defaultValue']['x-decorator'] = 'FormItem';
properties['defaultValue']['x-reactions'] = {
dependencies: [
'uiSchema.x-component-props.gmt',
'uiSchema.x-component-props.showTime',
'uiSchema.x-component-props.dateFormat',
'uiSchema.x-component-props.timeFormat',
],
fulfill: {
state: {
componentProps: {
gmt: '{{$deps[0]}}',
showTime: '{{$deps[1]}}',
dateFormat: '{{$deps[2]}}',
timeFormat: '{{$deps[3]}}',
},
},
},
};
}
const initialValue: any = {
name: `f_${uid()}`,

View File

@ -25,6 +25,24 @@ const getSchema = (schema: IField, record: any, compile, getContainer): ISchema
properties['defaultValue'] = cloneDeep(schema.default.uiSchema);
properties['defaultValue']['title'] = compile('{{ t("Default value") }}');
properties['defaultValue']['x-decorator'] = 'FormItem';
properties['defaultValue']['x-reactions'] = {
dependencies: [
'uiSchema.x-component-props.gmt',
'uiSchema.x-component-props.showTime',
'uiSchema.x-component-props.dateFormat',
'uiSchema.x-component-props.timeFormat',
],
fulfill: {
state: {
componentProps: {
gmt: '{{$deps[0]}}',
showTime: '{{$deps[1]}}',
dateFormat: '{{$deps[2]}}',
timeFormat: '{{$deps[3]}}',
},
},
},
};
}
return {
@ -115,7 +133,7 @@ export const EditCollectionField = (props) => {
};
export const EditFieldAction = (props) => {
const { scope, getContainer, item: record,children } = props;
const { scope, getContainer, item: record, children } = props;
const { getInterface } = useCollectionManager();
const [visible, setVisible] = useState(false);
const [schema, setSchema] = useState({});
@ -155,7 +173,7 @@ export const EditFieldAction = (props) => {
setVisible(true);
}}
>
{children||t('Edit')}
{children || t('Edit')}
</a>
<SchemaComponent
schema={schema}

View File

@ -52,6 +52,7 @@ export const datetime = [
{ label: "{{ t('is after') }}", value: '$dateAfter' },
{ label: "{{ t('is on or after') }}", value: '$dateNotBefore' },
{ label: "{{ t('is on or before') }}", value: '$dateNotAfter' },
{ label: "{{ t('is between') }}", value: '$dateBetween', schema: { 'x-component': 'DatePicker.RangePicker' } },
{ label: "{{ t('is empty') }}", value: '$empty', noValue: true },
{ label: "{{ t('is not empty') }}", value: '$notEmpty', noValue: true },
];
@ -70,30 +71,6 @@ export const number = [
export const id = [
{ label: '{{t("is")}}', value: '$eq', selected: true },
{ label: '{{t("is not")}}', value: '$ne' },
{
label: '{{t("is variable")}}',
value: '$isVar',
schema: {
'x-component': 'VariableCascader',
'x-component-props': {},
},
},
{
label: '{{t("is current logged-in user")}}',
value: '$isCurrentUser',
noValue: true,
visible(field) {
return field.collectionName === 'users';
},
},
{
label: '{{t("is not current logged-in user")}}',
value: '$isNotCurrentUser',
noValue: true,
visible(field) {
return field.collectionName === 'users';
},
},
{ label: '{{t("exists")}}', value: '$exists', noValue: true },
{ label: '{{t("not exists")}}', value: '$notExists', noValue: true },
];

View File

@ -57,7 +57,10 @@ export const FilterBlockRecord = ({
const associatedFields = useAssociatedFields();
const container = useRef(null);
const shouldApplyFilter = field.decoratorType !== 'FormBlockProvider' && field.decoratorProps.blockType !== 'filter';
const shouldApplyFilter =
field.decoratorType !== 'FilterFormBlockProvider' &&
field.decoratorType !== 'FormBlockProvider' &&
field.decoratorProps.blockType !== 'filter';
const addBlockToDataBlocks = () => {
recordDataBlocks({

View File

@ -6,8 +6,28 @@ export default {
"{{count}} more items": "{{count}} more items",
"Total {{count}} items": "Total {{count}} items",
"Today": "Today",
"Yesterday": "Yesterday",
"Tomorrow": "Tomorrow",
"Month": "Month",
"Week": "Week",
"This week": "This week",
"This month": "This month",
"This year": "This year",
"Next year": "Next year",
"Last week": "Last week",
"Next week": "Next week",
"Last month": "Last month",
"Next month": "Next month",
"Last quarter": "Last quarter",
"This quarter": "This quarter",
"Next quarter": "Next quarter",
"Last year": "Last year",
"Last 7 days": "Last 7 days",
"Last 30 days": "Last 30 days",
"Last 90 days": "Last 90 days",
"Next 7 days": "Next 7 days",
"Next 30 days": "Next 30 days",
"Next 90 days": "Next 90 days",
"Work week": "Work week",
"Day": "Day",
"Agenda": "Agenda",
@ -261,7 +281,6 @@ export default {
"Comparision": "Comparision",
"is": "is",
"is not": "is not",
"is variable": "is variable",
"contains": "contains",
"does not contain": "does not contain",
"starts with": "starts with",
@ -326,6 +345,7 @@ export default {
"is after": "is after",
"is on or after": "is on or after",
"is on or before": "is on or before",
"is between": "is between",
"Upload": "Upload",
"Select level": "Select level",
"Province": "Province",
@ -470,8 +490,6 @@ export default {
"Add condition group": "Add condition group",
"exists": "exists",
"not exists": "not exists",
"is current logged-in user": "is current logged-in user",
"is not current logged-in user": "is not current logged-in user",
"=": "=",
"≠": "≠",
">": ">",
@ -565,6 +583,8 @@ export default {
"Current user": "Current user",
"Current record": "Current record",
"Current time": "Current time",
"System variables": "System variables",
"Date variables": "Date variables",
"Popup close method": "Popup close method",
"Automatic close": "Automatic close",
"Manually close": "Manually close",

View File

@ -6,8 +6,28 @@ export default {
"{{count}} more items": "{{count}} 件以上",
"Total {{count}} items": "合計 {{count}} 件",
"Today": "今日",
"Yesterday": "昨日",
"Tomorrow": "明日",
"Month": "月",
"Week": "週",
"This week": "今週",
"Next week": "来週",
"This month": "今月",
"Next month": "来月",
"Last quarter": "前四半期",
"This quarter": "今四半期",
"Next quarter": "来四半期",
"This year": "今年",
"Next year": "来年",
"Last week": "先週",
"Last month": "先月",
"Last year": "去年",
"Last 7 days": "過去 7 日間",
"Last 30 days": "過去 30 日間",
"Last 90 days": "過去 90 日間",
"Next 7 days": "次の 7 日間",
"Next 30 days": "次の 30 日間",
"Next 90 days": "次の 90 日間",
"Work week": "稼働日",
"Day": "日",
"Agenda": "アジェンダ",
@ -226,7 +246,6 @@ export default {
"Comparision": "比較",
"is": "が同じである",
"is not": "が同じではない",
"is variable": "が変数である",
"contains": "を含む",
"does not contain": "を含まない",
"starts with": "で始まる",
@ -268,6 +287,7 @@ export default {
"is after": "より後",
"is on or after": "以降",
"is on or before": "以前",
"is between": "範囲",
"Upload": "アップロード",
"Select level": "レベルを選択",
"Province": "州",
@ -394,7 +414,6 @@ export default {
"Add condition group": "条件グループの追加",
"exists": "が存在する",
"not exists": "が存在しない",
"is current logged-in user": "が現在ログインしているユーザー",
"=": "=",
"≠": "≠",
">": ">",

View File

@ -6,8 +6,28 @@ export default {
"{{count}} more items": "{{count}} больше элементов",
"Total {{count}} items": "Всего {{count}} элементов",
"Today": "Сегодня",
"Yesterday": "Вчера",
"Tomorrow": "Завтра",
"Month": "Месяц",
"Week": "Неделя",
"This week": "Эта неделя",
"Next week": "Следующая неделя",
"This month": "Этот месяц",
"Next month": "Следующий месяц",
"Last quarter": "Прошлый квартал",
"This quarter": "Этот квартал",
"Next quarter": "Следующий квартал",
"This year": "Этот год",
"Next year": "Следующий год",
"Last week": "Прошлая неделя",
"Last month": "Прошлый месяц",
"Last year": "Прошлый год",
"Last 7 days": "Последние 7 дней",
"Last 30 days": "Последние 30 дней",
"Last 90 days": "Последние 90 дней",
"Next 7 days": "Следующие 7 дней",
"Next 30 days": "Следующие 30 дней",
"Next 90 days": "Следующие 90 дней",
"Work week": "Рабочая неделя",
"Day": "День",
"Agenda": "Расписание",
@ -185,7 +205,6 @@ export default {
"Comparision": "Сравнение",
"is": "соответствует",
"is not": "не соответствует",
"is variable": "это переменная",
"contains": "содержит",
"does not contain": "не содержит",
"starts with": "начинается с",
@ -227,6 +246,7 @@ export default {
"is after": "находится после",
"is on or after": "находится на или после",
"is on or before": "находится на или до",
"is between": "находится в диапазоне",
"Upload": "Закачать",
"Select level": "Выберите уровень",
"Province": "Область",
@ -353,7 +373,6 @@ export default {
"Add condition group": "Добавить группу правил",
"exists": "существуют",
"not exists": "не существуют",
"is current logged-in user": "текущий пользователь",
"=": "=",
"≠": "≠",
">": ">",

View File

@ -6,8 +6,28 @@ export default {
"{{count}} more items": "{{count}} öğe daha",
"Total {{count}} items": "Toplam {{count}} adet öğe",
"Today": "Bugün",
"Yesterday": "Dün",
"Tomorrow": "Yarın",
"Month": "Ay",
"Week": "Hafta",
"This week": "Bu Hafta",
"Next week": "Gelecek Hafta",
"This month": "Bu Ay",
"Next month": "Gelecek Ay",
"Last quarter": "Geçen Çeyrek",
"This quarter": "Bu Çeyrek",
"Next quarter": "Gelecek Çeyrek",
"This year": "Bu Yıl",
"Next year": "Gelecek Yıl",
"Last week": "Geçen Hafta",
"Last month": "Geçen Ay",
"Last year": "Geçen Yıl",
"Last 7 days": "Son 7 Gün",
"Last 30 days": "Son 30 Gün",
"Last 90 days": "Son 90 Gün",
"Next 7 days": "Sonraki 7 Gün",
"Next 30 days": "Sonraki 30 Gün",
"Next 90 days": "Sonraki 90 Gün",
"Work week": "Çalışma Haftası",
"Day": "Gün",
"Agenda": "Ajanda",
@ -184,7 +204,6 @@ export default {
"Comparision": "Karşılaştırma",
"is": "eşittir",
"is not": "eşit değildir",
"is variable": "is variable",
"contains": "içerir",
"does not contain": "içermez",
"starts with": "ile başlar",
@ -226,6 +245,7 @@ export default {
"is after": "sonra",
"is on or after": "açık veya sonra",
"is on or before": "açık veya önce",
"is between": "aralık",
"Upload": "Yükle",
"Select level": "Seviye seç",
"Province": "Bölge",
@ -352,7 +372,6 @@ export default {
"Add condition group": "Koşul grubu ekle",
"exists": "var olanlar",
"not exists": "var olmayanlar",
"is current logged-in user": "mevcut oturum açmış kullanıcı",
"=": "=",
"≠": "≠",
">": ">",

View File

@ -6,8 +6,28 @@ export default {
"{{count}} more items": "还有 {{count}} 项",
"Total {{count}} items": "总共 {{count}} 条",
"Today": "今天",
"Yesterday": "昨天",
"Tomorrow": "明天",
"Month": "月",
"Week": "周",
"This week": "本周",
"Next week": "下周",
"This month": "本月",
"Next month": "下月",
"Last quarter": "上季度",
"This quarter": "本季度",
"Next quarter": "下季度",
"This year": "今年",
"Next year": "明年",
"Last week": "上周",
"Last month": "上月",
"Last year": "去年",
"Last 7 days": "最近 7 天",
"Last 30 days": "最近 30 天",
"Last 90 days": "最近 90 天",
"Next 7 days": "未来 7 天",
"Next 30 days": "未来 30 天",
"Next 90 days": "未来 90 天",
"Work week": "工作日",
"Day": "天",
"Agenda": "列表",
@ -277,7 +297,6 @@ export default {
"Comparision": "值比较",
"is": "等于",
"is not": "不等于",
"is variable": "为动态变量",
"contains": "包含",
"does not contain": "不包含",
"starts with": "开头是",
@ -344,6 +363,7 @@ export default {
"is after": "晚于",
"is on or after": "不早于",
"is on or before": "不晚于",
"is between": "介于",
"Upload": "上传",
@ -504,8 +524,6 @@ export default {
'Add condition group': '添加条件分组',
'exists': '存在',
'not exists': '不存在',
'is current logged-in user': '为当前登录用户',
'is not current logged-in user': '不为当前登录用户',
'=': '=',
'≠': '≠',
'>': '>',
@ -604,6 +622,7 @@ export default {
'Current user': '当前用户',
'Current record': '当前记录',
'Current time': '当前时间',
'Now': '现在',
'Popup close method': '弹窗关闭方式',
'Automatic close': '自动关闭',
'Manually close': '手动关闭',
@ -687,6 +706,8 @@ export default {
'Column width': '列宽',
'Sortable': '可排序的',
'Constant': '常量',
'System variables': '系统变量',
'Date variables': '日期变量',
'Use variable': '使用变量',
'True': '真',
'False': '假',

View File

@ -4,19 +4,21 @@ import { Field } from '@formily/core';
import { connect, ISchema, mapProps, mapReadPretty, useField, useFieldSchema } from '@formily/react';
import { uid } from '@formily/shared';
import _ from 'lodash';
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormBlockContext, useFilterByTk } from '../../../block-provider';
import { useFilterByTk, useFormBlockContext } from '../../../block-provider';
import {
useCollectionManager,
useCollection,
useSortFields,
useCollectionFilterOptions,
useCollectionManager,
useSortFields
} from '../../../collection-manager';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useDesignable, useCompile, useFieldComponentOptions, useFieldTitle } from '../../hooks';
import { useCompile, useDesignable, useFieldComponentOptions, useFieldTitle } from '../../hooks';
import { removeNullCondition } from '../filter';
import { RemoteSelect, RemoteSelectProps } from '../remote-select';
import { defaultFieldNames } from '../select';
import { FilterDynamicComponent } from '../table-v2/FilterDynamicComponent';
import { ReadPretty } from './ReadPretty';
import useServiceOptions from './useServiceOptions';
@ -102,7 +104,7 @@ AssociationSelect.Designer = () => {
const compile = useCompile();
const collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']);
const fieldComponentOptions = useFieldComponentOptions();
const isSubFormAssocitionField = field.address.segments.includes('__form_grid');
const isSubFormAssociationField = field.address.segments.includes('__form_grid');
const interfaceConfig = getInterface(collectionField?.interface);
const validateSchema = interfaceConfig?.['validateSchema']?.(fieldSchema);
const originalTitle = collectionField?.uiSchema?.title;
@ -423,7 +425,7 @@ AssociationSelect.Designer = () => {
}}
/>
)}
{form && !isSubFormAssocitionField && fieldComponentOptions && (
{form && !isSubFormAssociationField && fieldComponentOptions && (
<SchemaSettings.SelectItem
title={t('Field component')}
options={fieldComponentOptions}
@ -483,12 +485,15 @@ AssociationSelect.Designer = () => {
// title: '数据范围',
enum: dataSource,
'x-component': 'Filter',
'x-component-props': {},
'x-component-props': {
dynamicComponent: (props) => FilterDynamicComponent({ ...props }),
},
},
},
} as ISchema
}
onSubmit={({ filter }) => {
filter = removeNullCondition(filter);
_.set(field.componentProps, 'service.params.filter', filter);
fieldSchema['x-component-props'] = field.componentProps;
dn.emit('patch', {
@ -692,13 +697,9 @@ AssociationSelect.FilterDesigner = () => {
const field = useField<Field>();
const fieldSchema = useFieldSchema();
const { t } = useTranslation();
const tk = useFilterByTk();
const {} = useCollection();
const { dn, refresh, insertAdjacent } = useDesignable();
const { dn, refresh } = useDesignable();
const compile = useCompile();
const collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']);
const fieldComponentOptions = useFieldComponentOptions();
const isSubFormAssocitionField = field.address.segments.includes('__form_grid');
const interfaceConfig = getInterface(collectionField?.interface);
const validateSchema = interfaceConfig?.['validateSchema']?.(fieldSchema);
const originalTitle = collectionField?.uiSchema?.title;
@ -1012,12 +1013,15 @@ AssociationSelect.FilterDesigner = () => {
// title: '数据范围',
enum: dataSource,
'x-component': 'Filter',
'x-component-props': {},
'x-component-props': {
dynamicComponent: (props) => FilterDynamicComponent({ ...props }),
},
},
},
} as ISchema
}
onSubmit={({ filter }) => {
filter = removeNullCondition(filter);
_.set(field.componentProps, 'service.params.filter', filter);
fieldSchema['x-component-props'] = field.componentProps;
dn.emit('patch', {

View File

@ -1,12 +1,13 @@
import { ISchema, useField, useFieldSchema } from '@formily/react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { FixedBlockDesignerItem, useCompile, useDesignable } from '../..';
import { FixedBlockDesignerItem, removeNullCondition, useDesignable } from '../..';
import { useCalendarBlockContext } from '../../../block-provider';
import { useCollection, useCollectionManager } from '../../../collection-manager';
import { useCollectionFilterOptions } from '../../../collection-manager/action-hooks';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useSchemaTemplate } from '../../../schema-templates';
import { FilterDynamicComponent } from '../table-v2/FilterDynamicComponent';
export const CalendarDesigner = () => {
const field = useField();
@ -115,7 +116,9 @@ export const CalendarDesigner = () => {
default: defaultFilter,
enum: dataSource,
'x-component': 'Filter',
'x-component-props': {},
'x-component-props': {
dynamicComponent: (props) => FilterDynamicComponent({ ...props }),
},
},
},
} as ISchema
@ -127,6 +130,7 @@ export const CalendarDesigner = () => {
}
}
onSubmit={({ filter }) => {
filter = removeNullCondition(filter);
const params = field.decoratorProps.params || {};
params.filter = filter;
field.decoratorProps.params = params;

View File

@ -5,23 +5,68 @@ import type {
RangePickerProps as AntdRangePickerProps
} from 'antd/lib/date-picker';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ReadPretty } from './ReadPretty';
import { mapDateFormat } from './util';
import { getDateRanges, mapDatePicker, mapRangePicker } from './util';
interface IDatePickerProps {
utc?: boolean;
}
type ComposedDatePicker = React.FC<AntdDatePickerProps> & {
RangePicker?: React.FC<AntdRangePickerProps>;
};
export const DatePicker: ComposedDatePicker = connect(
const DatePickerContext = React.createContext<IDatePickerProps>({ utc: true });
export const useDatePickerContext = () => React.useContext(DatePickerContext);
export const DatePickerProvider = DatePickerContext.Provider;
const _DatePicker: ComposedDatePicker = connect(
AntdDatePicker,
mapProps(mapDateFormat()),
mapProps(mapDatePicker()),
mapReadPretty(ReadPretty.DatePicker),
);
DatePicker.RangePicker = connect(
const _RangePicker = connect(
AntdDatePicker.RangePicker,
mapProps(mapDateFormat()),
mapProps(mapRangePicker()),
mapReadPretty(ReadPretty.DateRangePicker),
);
export const DatePicker = (props) => {
const { utc = true } = useDatePickerContext();
props = { utc, ...props };
return <_DatePicker {...props} />;
};
DatePicker.RangePicker = (props) => {
const { t } = useTranslation();
const { utc = true } = useDatePickerContext();
const rangesValue = getDateRanges();
const ranges = {
[t('Today')]: rangesValue.today,
[t('Last week')]: rangesValue.lastWeek,
[t('This week')]: rangesValue.thisWeek,
[t('Next week')]: rangesValue.nextWeek,
[t('Last month')]: rangesValue.lastMonth,
[t('This month')]: rangesValue.thisMonth,
[t('Next month')]: rangesValue.nextMonth,
[t('Last quarter')]: rangesValue.lastQuarter,
[t('This quarter')]: rangesValue.thisQuarter,
[t('Next quarter')]: rangesValue.nextQuarter,
[t('Last year')]: rangesValue.lastYear,
[t('This year')]: rangesValue.thisYear,
[t('Next year')]: rangesValue.nextYear,
[t('Last 7 days')]: rangesValue.last7Days,
[t('Next 7 days')]: rangesValue.next7Days,
[t('Last 30 days')]: rangesValue.last30Days,
[t('Next 30 days')]: rangesValue.next30Days,
[t('Last 90 days')]: rangesValue.last90Days,
[t('Next 90 days')]: rangesValue.next90Days,
};
props = { utc, ranges, ...props };
return <_RangePicker {...props} />;
};
export default DatePicker;

View File

@ -0,0 +1,120 @@
import moment from 'moment';
import { getDateRanges } from '../util';
describe('getDateRanges', () => {
const dateRanges = getDateRanges();
it('today', () => {
const [start, end] = dateRanges.today();
expect(start.toISOString()).toBe(moment().startOf('day').toISOString());
expect(end.toISOString()).toBe(moment().endOf('day').toISOString());
});
it('lastWeek', () => {
const [start, end] = dateRanges.lastWeek();
expect(start.toISOString()).toBe(moment().add(-1, 'week').startOf('isoWeek').toISOString());
expect(end.toISOString()).toBe(moment().add(-1, 'week').endOf('isoWeek').toISOString());
});
it('thisWeek', () => {
const [start, end] = dateRanges.thisWeek();
expect(start.toISOString()).toBe(moment().startOf('isoWeek').toISOString());
expect(end.toISOString()).toBe(moment().endOf('isoWeek').toISOString());
});
it('nextWeek', () => {
const [start, end] = dateRanges.nextWeek();
expect(start.toISOString()).toBe(moment().add(1, 'week').startOf('isoWeek').toISOString());
expect(end.toISOString()).toBe(moment().add(1, 'week').endOf('isoWeek').toISOString());
});
it('lastMonth', () => {
const [start, end] = dateRanges.lastMonth();
expect(start.toISOString()).toBe(moment().add(-1, 'month').startOf('month').toISOString());
expect(end.toISOString()).toBe(moment().add(-1, 'month').endOf('month').toISOString());
});
it('thisMonth', () => {
const [start, end] = dateRanges.thisMonth();
expect(start.toISOString()).toBe(moment().startOf('month').toISOString());
expect(end.toISOString()).toBe(moment().endOf('month').toISOString());
});
it('nextMonth', () => {
const [start, end] = dateRanges.nextMonth();
expect(start.toISOString()).toBe(moment().add(1, 'month').startOf('month').toISOString());
expect(end.toISOString()).toBe(moment().add(1, 'month').endOf('month').toISOString());
});
it('lastQuarter', () => {
const [start, end] = dateRanges.lastQuarter();
expect(start.toISOString()).toBe(moment().add(-1, 'quarter').startOf('quarter').toISOString());
expect(end.toISOString()).toBe(moment().add(-1, 'quarter').endOf('quarter').toISOString());
});
it('thisQuarter', () => {
const [start, end] = dateRanges.thisQuarter();
expect(start.toISOString()).toBe(moment().startOf('quarter').toISOString());
expect(end.toISOString()).toBe(moment().endOf('quarter').toISOString());
});
it('nextQuarter', () => {
const [start, end] = dateRanges.nextQuarter();
expect(start.toISOString()).toBe(moment().add(1, 'quarter').startOf('quarter').toISOString());
expect(end.toISOString()).toBe(moment().add(1, 'quarter').endOf('quarter').toISOString());
});
it('lastYear', () => {
const [start, end] = dateRanges.lastYear();
expect(start.toISOString()).toBe(moment().add(-1, 'year').startOf('year').toISOString());
expect(end.toISOString()).toBe(moment().add(-1, 'year').endOf('year').toISOString());
});
it('thisYear', () => {
const [start, end] = dateRanges.thisYear();
expect(start.toISOString()).toBe(moment().startOf('year').toISOString());
expect(end.toISOString()).toBe(moment().endOf('year').toISOString());
});
it('nextYear', () => {
const [start, end] = dateRanges.nextYear();
expect(start.toISOString()).toBe(moment().add(1, 'year').startOf('year').toISOString());
expect(end.toISOString()).toBe(moment().add(1, 'year').endOf('year').toISOString());
});
it('last7Days', () => {
const [start, end] = dateRanges.last7Days();
expect(start.toISOString()).toBe(moment().add(-6, 'days').startOf('days').toISOString());
expect(end.toISOString()).toBe(moment().endOf('days').toISOString());
});
it('next7Days', () => {
const [start, end] = dateRanges.next7Days();
expect(start.toISOString()).toBe(moment().add(1, 'day').startOf('day').toISOString());
expect(end.toISOString()).toBe(moment().add(7, 'days').endOf('days').toISOString());
});
it('last30Days', () => {
const [start, end] = dateRanges.last30Days();
expect(start.toISOString()).toBe(moment().add(-29, 'days').startOf('days').toISOString());
expect(end.toISOString()).toBe(moment().endOf('days').toISOString());
});
it('next30Days', () => {
const [start, end] = dateRanges.next30Days();
expect(start.toISOString()).toBe(moment().add(1, 'day').startOf('day').toISOString());
expect(end.toISOString()).toBe(moment().add(30, 'days').endOf('days').toISOString());
});
it('last90Days', () => {
const [start, end] = dateRanges.last90Days();
expect(start.toISOString()).toBe(moment().add(-89, 'days').startOf('days').toISOString());
expect(end.toISOString()).toBe(moment().endOf('days').toISOString());
});
it('next90Days', () => {
const [start, end] = dateRanges.next90Days();
expect(start.toISOString()).toBe(moment().add(1, 'day').startOf('day').toISOString());
expect(end.toISOString()).toBe(moment().add(90, 'days').endOf('days').toISOString());
});
});

View File

@ -0,0 +1,220 @@
import moment from 'moment';
import { mapDatePicker } from '../util';
describe('mapDatePicker', () => {
it('showTime is true and gmt is true', () => {
const props = {
value: '2022-02-22T22:22:22.000Z',
showTime: true,
gmt: true,
};
const result = mapDatePicker()(props);
expect(result.value.format('YYYY-MM-DD HH:mm:ss')).toBe('2022-02-22 22:22:22');
});
it('showTime is true and gmt is false', () => {
const props = {
value: '2022-02-22T22:22:22.000Z',
showTime: true,
gmt: false,
};
const result = mapDatePicker()(props);
expect(result.value.format('YYYY-MM-DD HH:mm:ss')).toBe(
moment('2022-02-22T22:22:22.000Z').format('YYYY-MM-DD HH:mm:ss'),
);
});
it('showTime is false and gmt is true', () => {
const props = {
value: '2022-02-22T00:00:00.000Z',
showTime: false,
gmt: true,
};
const result = mapDatePicker()(props);
expect(result.value.format('YYYY-MM-DD HH:mm:ss')).toBe('2022-02-22 00:00:00');
});
it('showTime is false and gmt is false', () => {
const props = {
value: '2022-02-22',
showTime: false,
gmt: false,
};
const result = mapDatePicker()(props);
expect(result.value.format('YYYY-MM-DD HH:mm:ss')).toBe('2022-02-22 00:00:00');
});
it('should call onChange with correct value when showTime is true and gmt is true', () => {
const props = {
showTime: true,
gmt: true,
onChange: jest.fn(),
};
const result = mapDatePicker()(props);
result.onChange(moment.utc('2022-02-22 22:22:22'));
expect(props.onChange).toHaveBeenCalledWith('2022-02-22T22:22:22.000Z');
});
it('should call onChange with correct value when showTime is true and gmt is false', () => {
const props = {
showTime: true,
gmt: false,
onChange: jest.fn(),
};
const result = mapDatePicker()(props);
const m = moment('2022-02-22 22:22:22');
result.onChange(m);
expect(props.onChange).toHaveBeenCalledWith(m.toISOString());
});
it('should call onChange with correct value when showTime is false and gmt is true', () => {
const props = {
showTime: false,
gmt: true,
onChange: jest.fn(),
};
const result = mapDatePicker()(props);
result.onChange(moment.utc('2022-02-22'));
expect(props.onChange).toHaveBeenCalledWith('2022-02-22T00:00:00.000Z');
});
it('should call onChange with correct value when showTime is false and gmt is false', () => {
const props = {
showTime: false,
gmt: false,
onChange: jest.fn(),
};
const result = mapDatePicker()(props);
const m = moment('2022-02-22');
result.onChange(m);
expect(props.onChange).toHaveBeenCalledWith(m.toISOString());
});
it('should call onChange with correct value when picker is year and gmt is true', () => {
const props = {
picker: 'year',
gmt: true,
onChange: jest.fn(),
};
const result = mapDatePicker()(props);
result.onChange(moment.utc('2022-01-01T00:00:00.000Z'));
expect(props.onChange).toHaveBeenCalledWith('2022-01-01T00:00:00.000Z');
});
it('should call onChange with correct value when picker is year and gmt is false', () => {
const props = {
picker: 'year',
gmt: false,
onChange: jest.fn(),
};
const result = mapDatePicker()(props);
const m = moment('2022-02-01 00:00:00');
result.onChange(m);
expect(props.onChange).toHaveBeenCalledWith(m.startOf('year').toISOString());
});
it('should call onChange with correct value when picker is month and gmt is true', () => {
const props = {
picker: 'month',
gmt: true,
onChange: jest.fn(),
};
const result = mapDatePicker()(props);
result.onChange(moment.utc('2022-02-22T00:00:00.000Z'));
expect(props.onChange).toHaveBeenCalledWith('2022-02-01T00:00:00.000Z');
});
it('should call onChange with correct value when picker is month and gmt is false', () => {
const props = {
picker: 'month',
gmt: false,
onChange: jest.fn(),
};
const result = mapDatePicker()(props);
const m = moment('2022-02-01 00:00:00');
result.onChange(m);
expect(props.onChange).toHaveBeenCalledWith(m.startOf('month').toISOString());
});
it('should call onChange with correct value when picker is quarter and gmt is true', () => {
const props = {
picker: 'quarter',
gmt: true,
onChange: jest.fn(),
};
const result = mapDatePicker()(props);
result.onChange(moment.utc('2022-02-22T00:00:00.000Z'));
expect(props.onChange).toHaveBeenCalledWith('2022-01-01T00:00:00.000Z');
});
it('should call onChange with correct value when picker is quarter and gmt is false', () => {
const props = {
picker: 'quarter',
gmt: false,
onChange: jest.fn(),
};
const result = mapDatePicker()(props);
const m = moment('2022-02-01 00:00:00');
result.onChange(m);
expect(props.onChange).toHaveBeenCalledWith(m.startOf('quarter').toISOString());
});
it('should call onChange with correct value when picker is week and gmt is true', () => {
const props = {
picker: 'week',
gmt: true,
onChange: jest.fn(),
};
const result = mapDatePicker()(props);
const m = moment.utc('2022-02-21T00:00:00.000Z');
result.onChange(m);
expect(props.onChange).toHaveBeenCalledWith(m.startOf('week').add(1, 'day').toISOString());
});
it('should call onChange with correct value when picker is week and gmt is false', () => {
const props = {
picker: 'week',
gmt: false,
onChange: jest.fn(),
};
const result = mapDatePicker()(props);
const m = moment('2022-02-21 00:00:00');
result.onChange(m);
expect(props.onChange).toHaveBeenCalledWith(m.startOf('week').add(1, 'day').toISOString());
});
it('should call onChange with correct value when utc is false', () => {
const props = {
showTime: true,
gmt: true,
utc: false,
onChange: jest.fn(),
};
const result = mapDatePicker()(props);
result.onChange(moment('2022-02-22 22:22:22'));
expect(props.onChange).toHaveBeenCalledWith('2022-02-22 22:22:22');
});
it('should call onChange with correct value when picker is year and utc is false', () => {
const props = {
showTime: false,
gmt: true,
utc: false,
onChange: jest.fn(),
};
const result = mapDatePicker()(props);
result.onChange(moment('2022-01-01 23:00:00'));
expect(props.onChange).toHaveBeenCalledWith('2022-01-01');
});
it('utc is false and gmt is true', () => {
const props = {
value: '2022-01-01 23:00:00',
showTime: true,
gmt: true,
utc: false,
};
const result = mapDatePicker()(props);
expect(result.value.format('YYYY-MM-DD HH:mm:ss')).toBe('2022-01-01 23:00:00');
});
});

View File

@ -0,0 +1,43 @@
import moment from 'moment';
import { mapRangePicker } from '../util';
describe('mapRangePicker', () => {
it('should work with showTime=false, gmt=true, utc=true', () => {
const props = {
showTime: false,
gmt: true,
utc: true,
onChange: jest.fn(),
};
const { onChange } = mapRangePicker()(props);
const value = [moment.utc('2023-01-01T00:00:00.000Z'), moment.utc('2023-01-02T00:00:00.000Z')];
onChange(value);
expect(props.onChange).toHaveBeenCalledWith(['2023-01-01T00:00:00.000Z', '2023-01-02T23:59:59.999Z']);
});
it('should work with showTime=true, gmt=true, utc=true', () => {
const props = {
showTime: true,
gmt: true,
utc: true,
onChange: jest.fn(),
};
const { onChange } = mapRangePicker()(props);
const value = [moment.utc('2023-01-01T00:00:00.000Z'), moment.utc('2023-01-02T00:00:00.000Z')];
onChange(value);
expect(props.onChange).toHaveBeenCalledWith(['2023-01-01T00:00:00.000Z', '2023-01-02T00:00:00.000Z']);
});
it('should work with showTime=false, gmt=true, utc=false', () => {
const props = {
showTime: false,
gmt: true,
utc: false,
onChange: jest.fn(),
};
const { onChange } = mapRangePicker()(props);
const value = [moment.utc('2023-01-01T00:00:00.000Z'), moment.utc('2023-01-02T00:00:00.000Z')];
onChange(value);
expect(props.onChange).toHaveBeenCalledWith(['2023-01-01', '2023-01-02']);
});
});

View File

@ -44,33 +44,93 @@ describe('str2moment', () => {
describe('moment2str', () => {
test('gmt date', () => {
const m = moment('2022-06-21 10:10:00');
const m = moment('2023-06-21 10:10:00');
const str = moment2str(m, { showTime: true, gmt: true });
expect(str).toBe('2022-06-21T10:10:00.000Z');
expect(str).toBe('2023-06-21T10:10:00.000Z');
});
test('gmt date only', () => {
const m = moment('2022-06-21 10:10:00');
const str = moment2str(m);
expect(str).toBe('2022-06-21T00:00:00.000Z');
test('showTime is true, gmt is false', () => {
const m = moment('2023-06-21 10:10:00');
const str = moment2str(m, { showTime: true, gmt: false });
expect(str).toBe(m.toISOString());
});
test('gmt is true', () => {
const m = moment('2023-06-21 10:10:00');
const str = moment2str(m, { gmt: true });
expect(str).toBe('2023-06-21T10:10:00.000Z');
});
test('gmt is false', () => {
const m = moment('2023-06-21 10:10:00');
const str = moment2str(m, { gmt: false });
expect(str).toBe(moment('2023-06-21 10:10:00').toISOString());
});
test('with time', () => {
const m = moment('2022-06-21 10:10:00');
const m = moment('2023-06-21 10:10:00');
const str = moment2str(m, { showTime: true });
expect(str).toBe(m.toISOString());
});
test('picker is year, gmt is false', () => {
const m = moment('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'year', gmt: false });
expect(str).toBe(moment('2023-01-01 00:00:00').toISOString());
});
test('picker is year, gmt is true', () => {
const m = moment('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'year', gmt: true });
expect(str).toBe('2023-01-01T00:00:00.000Z');
});
test('picker is year', () => {
const m = moment('2022-06-21 10:10:00');
const m = moment('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'year' });
expect(str).toBe('2022-01-01T00:00:00.000Z');
expect(str).toBe('2023-01-01T00:00:00.000Z');
});
test('picker is quarter, gmt is false', () => {
const m = moment('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'quarter', gmt: false });
expect(str).toBe(moment('2023-04-01 00:00:00').toISOString());
});
test('picker is quarter, gmt is true', () => {
const m = moment('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'quarter', gmt: true });
expect(str).toBe('2023-04-01T00:00:00.000Z');
});
test('picker is month, gmt is false', () => {
const m = moment('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'month', gmt: false });
expect(str).toBe(moment('2023-06-01 00:00:00').toISOString());
});
test('picker is month, gmt is true', () => {
const m = moment('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'month', gmt: true });
expect(str).toBe('2023-06-01T00:00:00.000Z');
});
test('picker is month', () => {
const m = moment('2022-06-21 10:10:00');
const m = moment('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'month' });
expect(str).toBe('2022-06-01T00:00:00.000Z');
expect(str).toBe('2023-06-01T00:00:00.000Z');
});
test('picker is week, gmt is false', () => {
const m = moment('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'week', gmt: false });
expect(str).toBe(moment('2023-06-19 00:00:00').toISOString());
});
test('picker is week, gmt is true', () => {
const m = moment('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'week', gmt: true });
expect(str).toBe('2023-06-19T00:00:00.000Z');
});
test('value is null', async () => {

View File

@ -2,7 +2,7 @@
* title: DatePicker.RangePicker
*/
import { FormItem } from '@formily/antd';
import { DatePicker, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
const schema = {
@ -13,28 +13,52 @@ const schema = {
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-reactions': {
target: 'read',
'x-component-props': {
gmt: true,
},
'x-reactions': [
{
target: 'read1',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
{
target: 'read2',
fulfill: {
state: {
value: '{{$self.value && $self.value.join(" ~ ")}}',
},
read: {
},
},
],
},
read1: {
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
gmt: true,
},
},
read2: {
type: 'string',
title: `Value`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ DatePicker, FormItem }}>
<SchemaComponentProvider components={{ Input, DatePicker, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);

View File

@ -0,0 +1,58 @@
/**
* title: DatePicker
*/
import { FormItem } from '@formily/antd';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
const schema = {
type: 'object',
properties: {
input: {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: false,
utc: false,
},
'x-reactions': {
target: '*(read1,read2)',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
},
read1: {
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: true,
},
},
read2: {
type: 'string',
title: `Value`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Input, DatePicker, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -0,0 +1,62 @@
/**
* title: DatePicker.RangePicker
*/
import { FormItem } from '@formily/antd';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
const schema = {
type: 'object',
properties: {
input: {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
gmt: false,
},
'x-reactions': [
{
target: 'read1',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
{
target: 'read2',
fulfill: {
state: {
value: '{{$self.value && $self.value.join(" ~ ")}}',
},
},
},
],
},
read1: {
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
},
read2: {
type: 'string',
title: `Value`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Input, DatePicker, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -0,0 +1,62 @@
/**
* title: DatePicker.RangePicker
*/
import { FormItem } from '@formily/antd';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
const schema = {
type: 'object',
properties: {
input: {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
utc: false,
},
'x-reactions': [
{
target: 'read1',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
{
target: 'read2',
fulfill: {
state: {
value: '{{$self.value && $self.value.join(" ~ ")}}',
},
},
},
],
},
read1: {
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
},
read2: {
type: 'string',
title: `Value`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Input, DatePicker, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -17,10 +17,22 @@ group:
<code src="./demos/demo3.tsx" />
### RangePicker
### DatePicker (non-UTC)
<code src="./demos/demo5.tsx" />
### RangePicker (GMT)
<code src="./demos/demo2.tsx" />
### RangePicker (non-GMT)
<code src="./demos/demo6.tsx" />
### RangePicker (non-UTC)
<code src="./demos/demo7.tsx" />
## API
基于 antd 的 [DatePicker](https://ant.design/components/date-picker/#API),新增了以下扩展属性,用于支持 NocoBase 的日期字段设置。

View File

@ -1,7 +1,13 @@
import { getDefaultFormat, str2moment, toGmt, toLocal } from '@nocobase/utils/client';
import moment from 'moment';
const toStringByPicker = (value, picker) => {
const toStringByPicker = (value, picker, timezone: 'gmt' | 'local') => {
if (!moment.isMoment(value)) return value;
if (timezone === 'local') {
const offset = new Date().getTimezoneOffset();
return moment(toStringByPicker(value, picker, 'gmt')).add(offset, 'minutes').toISOString();
}
if (picker === 'year') {
return value.format('YYYY') + '-01-01T00:00:00.000Z';
}
@ -9,52 +15,63 @@ const toStringByPicker = (value, picker) => {
return value.format('YYYY-MM') + '-01T00:00:00.000Z';
}
if (picker === 'quarter') {
return value.format('YYYY-MM') + '-01T00:00:00.000Z';
return value.startOf('quarter').format('YYYY-MM') + '-01T00:00:00.000Z';
}
if (picker === 'week') {
return value.format('YYYY-MM-DD') + 'T00:00:00.000Z';
return value.startOf('week').add(1, 'day').format('YYYY-MM-DD') + 'T00:00:00.000Z';
}
return value.format('YYYY-MM-DD') + 'T00:00:00.000Z';
return value.format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z';
};
const toGmtByPicker = (value: moment.Moment | moment.Moment[], picker?: any) => {
if (!value) {
const toGmtByPicker = (value: moment.Moment, picker?: any) => {
if (!value || !moment.isMoment(value)) {
return value;
}
if (Array.isArray(value)) {
return value.map((val) => toStringByPicker(val, picker));
}
if (moment.isMoment(value)) {
return toStringByPicker(value, picker);
return toStringByPicker(value, picker, 'gmt');
};
const toLocalByPicker = (value: moment.Moment, picker?: any) => {
if (!value || !moment.isMoment(value)) {
return value;
}
return toStringByPicker(value, picker, 'local');
};
export interface Moment2strOptions {
showTime?: boolean;
gmt?: boolean;
utc?: boolean;
picker?: 'year' | 'month' | 'week' | 'quarter';
}
export const moment2str = (value?: moment.Moment | moment.Moment[], options: Moment2strOptions = {}) => {
const { showTime, gmt, picker } = options;
export const moment2str = (value?: moment.Moment, options: Moment2strOptions = {}) => {
const { showTime, gmt, picker, utc = true } = options;
if (!value) {
return value;
}
if (!utc) {
const format = showTime ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD';
return value.format(format);
}
if (showTime) {
return gmt ? toGmt(value) : toLocal(value);
}
if (typeof gmt === 'boolean') {
return gmt ? toGmtByPicker(value, picker) : toLocalByPicker(value, picker);
}
return toGmtByPicker(value, picker);
};
export const mapDateFormat = function () {
return (props: any, field) => {
export const mapDatePicker = function () {
return (props: any) => {
const format = getDefaultFormat(props) as any;
const onChange = props.onChange;
return {
...props,
format: format,
value: str2moment(props.value, props),
onChange: (value: moment.Moment | moment.Moment[]) => {
onChange: (value: moment.Moment) => {
if (onChange) {
onChange(moment2str(value, props));
}
@ -62,3 +79,77 @@ export const mapDateFormat = function () {
};
};
};
export const mapRangePicker = function () {
return (props: any) => {
const format = getDefaultFormat(props) as any;
const onChange = props.onChange;
return {
...props,
format: format,
value: str2moment(props.value, props),
onChange: (value: moment.Moment[]) => {
if (onChange) {
onChange(
value
? [moment2str(getRangeStart(value[0], props), props), moment2str(getRangeEnd(value[1], props), props)]
: [],
);
}
},
} as any;
};
};
function getRangeStart(value: moment.Moment, options: Moment2strOptions) {
const { showTime } = options;
if (showTime) {
return value;
}
return value.startOf('day');
}
function getRangeEnd(value: moment.Moment, options: Moment2strOptions) {
const { showTime } = options;
if (showTime) {
return value;
}
return value.endOf('day');
}
const getStart = (offset: any, unit: moment.unitOfTime.StartOf) => {
return moment()
.add(offset, unit === 'isoWeek' ? 'week' : unit)
.startOf(unit);
};
const getEnd = (offset: any, unit: moment.unitOfTime.StartOf) => {
return moment()
.add(offset, unit === 'isoWeek' ? 'week' : unit)
.endOf(unit);
};
export const getDateRanges = () => {
return {
today: () => [getStart(0, 'day'), getEnd(0, 'day')],
lastWeek: () => [getStart(-1, 'isoWeek'), getEnd(-1, 'isoWeek')],
thisWeek: () => [getStart(0, 'isoWeek'), getEnd(0, 'isoWeek')],
nextWeek: () => [getStart(1, 'isoWeek'), getEnd(1, 'isoWeek')],
lastMonth: () => [getStart(-1, 'month'), getEnd(-1, 'month')],
thisMonth: () => [getStart(0, 'month'), getEnd(0, 'month')],
nextMonth: () => [getStart(1, 'month'), getEnd(1, 'month')],
lastQuarter: () => [getStart(-1, 'quarter'), getEnd(-1, 'quarter')],
thisQuarter: () => [getStart(0, 'quarter'), getEnd(0, 'quarter')],
nextQuarter: () => [getStart(1, 'quarter'), getEnd(1, 'quarter')],
lastYear: () => [getStart(-1, 'year'), getEnd(-1, 'year')],
thisYear: () => [getStart(0, 'year'), getEnd(0, 'year')],
nextYear: () => [getStart(1, 'year'), getEnd(1, 'year')],
last7Days: () => [getStart(-6, 'days'), getEnd(0, 'days')],
next7Days: () => [getStart(1, 'day'), getEnd(7, 'days')],
last30Days: () => [getStart(-29, 'days'), getEnd(0, 'days')],
next30Days: () => [getStart(1, 'day'), getEnd(30, 'days')],
last90Days: () => [getStart(-89, 'days'), getEnd(0, 'days')],
next90Days: () => [getStart(1, 'day'), getEnd(90, 'days')],
};
};

View File

@ -1,56 +1,15 @@
import { css } from '@emotion/css';
import { createForm, onFieldValueChange } from '@formily/core';
import { connect, FieldContext, FormContext } from '@formily/react';
import { FieldContext, FormContext } from '@formily/react';
import { merge } from '@formily/shared';
import { Cascader } from 'antd';
import React, { useContext, useMemo } from 'react';
import { SchemaComponent } from '../../core';
import { useCompile, useComponent } from '../../hooks';
import { useComponent } from '../../hooks';
import { FilterContext } from './context';
import { useFilterOptions } from './useFilterActionProps';
const VariableCascader = connect((props) => {
const fields = useFilterOptions('users');
const compile = useCompile();
const { value, onChange } = props;
return (
<Cascader
className={css`
width: 160px;
`}
value={value ? value.split('.') : []}
fieldNames={{
label: 'title',
value: 'name',
children: 'children',
}}
onChange={(value) => {
onChange(value ? value.join('.') : null);
}}
options={compile([
{
title: '{{t("Current user")}}',
name: 'currentUser',
children: fields
.filter((field) => {
if (!field.target) {
return true;
}
return field.type === 'belongsTo';
})
.map((field) => {
if (field.children) {
field.children = field.children.filter((child) => {
return !child.target;
});
}
return field;
}),
},
])}
/>
);
});
const isDateComponent = {
'DatePicker.RangePicker': true,
DatePicker: true,
};
export const DynamicComponent = (props) => {
const { dynamicComponent, disabled } = useContext(FilterContext);
@ -85,9 +44,6 @@ export const DynamicComponent = (props) => {
'x-validator': undefined,
'x-decorator': undefined,
}}
components={{
VariableCascader,
}}
/>
</FieldContext.Provider>
);

View File

@ -3,6 +3,7 @@ import { observer, useField, useFieldSchema } from '@formily/react';
import React from 'react';
import { useRequest } from '../../../api-client';
import { useProps } from '../../hooks/useProps';
import { DatePickerProvider } from '../date-picker';
import { FilterContext } from './context';
import { FilterActionDesigner } from './Filter.Action.Designer';
import { FilterAction } from './FilterAction';
@ -26,12 +27,20 @@ export const Filter: any = observer((props: any) => {
});
return (
<div className={className}>
<DatePickerProvider value={{ utc: false }}>
<FilterContext.Provider
value={{ field, fieldSchema, dynamicComponent, options: options || field.dataSource || [], disabled: props.disabled }}
value={{
field,
fieldSchema,
dynamicComponent,
options: options || field.dataSource || [],
disabled: props.disabled,
}}
>
<FilterGroup {...props} bordered={false} />
{/* <pre>{JSON.stringify(field.value, null, 2)}</pre> */}
</FilterContext.Provider>
</DatePickerProvider>
</div>
);
});

View File

@ -7,7 +7,7 @@ export interface FilterContextProps {
fieldSchema?: Schema;
dynamicComponent?: any;
options?: any[];
disabled?: boolean
disabled?: boolean;
}
export const RemoveConditionContext = createContext(null);

View File

@ -62,7 +62,7 @@ export const useValues = () => {
const s2 = cloneDeep(operator?.schema);
field.data.schema = merge(s1, s2);
field.data.dataIndex = dataIndex;
field.data.value = operator.noValue ? operator.default || true : null;
field.data.value = operator?.noValue ? operator.default || true : null;
data2value();
},
setOperator(operatorValue) {

View File

@ -2,24 +2,19 @@ import { ArrayItems } from '@formily/antd';
import { ISchema, useField, useFieldSchema } from '@formily/react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useFormBlockContext } from '../../../block-provider';
import { useDetailsBlockContext } from '../../../block-provider/DetailsBlockProvider';
import { useCollection } from '../../../collection-manager';
import { useCollectionFilterOptions, useSortFields } from '../../../collection-manager/action-hooks';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useSchemaTemplate } from '../../../schema-templates';
import { useDesignable } from '../../hooks';
import { useActionContext } from '../action';
import { removeNullCondition } from '../filter';
import { FilterDynamicComponent } from '../table-v2/FilterDynamicComponent';
export const FormDesigner = () => {
const { name, title } = useCollection();
const template = useSchemaTemplate();
const ctx = useFormBlockContext();
const field = useField();
const fieldSchema = useFieldSchema();
const { dn } = useDesignable();
const { t } = useTranslation();
const { visible } = useActionContext();
const defaultResource = fieldSchema?.['x-decorator-props']?.resource;
return (
@ -108,12 +103,15 @@ export const DetailsDesigner = () => {
// title: '数据范围',
enum: dataSource,
'x-component': 'Filter',
'x-component-props': {},
'x-component-props': {
dynamicComponent: (props) => FilterDynamicComponent({ ...props }),
},
},
},
} as ISchema
}
onSubmit={({ filter }) => {
filter = removeNullCondition(filter);
const params = field.decoratorProps.params || {};
params.filter = filter;
field.decoratorProps.params = params;

View File

@ -7,7 +7,9 @@ import { useCollectionFilterOptions } from '../../../collection-manager/action-h
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useSchemaTemplate } from '../../../schema-templates';
import { useDesignable } from '../../hooks';
import { removeNullCondition } from '../filter';
import { FixedBlockDesignerItem } from '../page';
import { FilterDynamicComponent } from '../table-v2/FilterDynamicComponent';
export const KanbanDesigner = () => {
const { name, title } = useCollection();
@ -35,12 +37,15 @@ export const KanbanDesigner = () => {
default: defaultFilter,
enum: dataSource,
'x-component': 'Filter',
'x-component-props': {},
'x-component-props': {
dynamicComponent: (props) => FilterDynamicComponent({ ...props }),
},
},
},
} as ISchema
}
onSubmit={({ filter }) => {
filter = removeNullCondition(filter);
const params = field.decoratorProps.params || {};
params.filter = filter;
field.decoratorProps.params = params;

View File

@ -31,7 +31,7 @@ Radio.Group = connect(
if (!isValid(props.value)) {
return <div></div>;
}
const { options = [], value } = props;
const { value } = props;
const field = useField<any>();
const dataSource = field.dataSource || [];
return (

View File

@ -0,0 +1,197 @@
import React from 'react';
import { useCompile } from '../..';
import { useValues } from '../filter/useValues';
import { Variable } from '../variable';
import { useUserVariable } from './hooks/useUserVariable';
const useVariableTypes = () => {
const { operator, schema } = useValues();
const operatorValue = operator?.value || '';
const userVariable = useUserVariable({ schema, operator });
if (!operator || !schema) return [];
const systemOptions = [
{
key: 'now',
value: 'now',
label: `{{t("Now")}}`,
disabled: schema['x-component'] !== 'DatePicker' || operatorValue === '$dateBetween',
},
];
const disabled = !['DatePicker', 'DatePicker.RangePicker'].includes(schema['x-component']);
const dateOptions = [
{
key: 'now',
value: 'now',
label: `{{t("Now")}}`,
disabled: schema['x-component'] !== 'DatePicker' || operatorValue === '$dateBetween',
},
{
key: 'yesterday',
value: 'yesterday',
label: `{{t("Yesterday")}}`,
disabled,
},
{
key: 'today',
value: 'today',
label: `{{t("Today")}}`,
disabled,
},
{
key: 'tomorrow',
value: 'tomorrow',
label: `{{t("Tomorrow")}}`,
disabled,
},
{
key: 'lastIsoWeek',
value: 'lastIsoWeek',
label: `{{t("Last week")}}`,
disabled,
},
{
key: 'thisIsoWeek',
value: 'thisIsoWeek',
label: `{{t("This week")}}`,
disabled,
},
{
key: 'nextIsoWeek',
value: 'nextIsoWeek',
label: `{{t("Next week")}}`,
disabled,
},
{
key: 'lastMonth',
value: 'lastMonth',
label: `{{t("Last month")}}`,
disabled,
},
{
key: 'thisMonth',
value: 'thisMonth',
label: `{{t("This month")}}`,
disabled,
},
{
key: 'nextMonth',
value: 'nextMonth',
label: `{{t("Next month")}}`,
disabled,
},
{
key: 'lastQuarter',
value: 'lastQuarter',
label: `{{t("Last quarter")}}`,
disabled,
},
{
key: 'thisQuarter',
value: 'thisQuarter',
label: `{{t("This quarter")}}`,
disabled,
},
{
key: 'nextQuarter',
value: 'nextQuarter',
label: `{{t("Next quarter")}}`,
disabled,
},
{
key: 'lastYear',
value: 'lastYear',
label: `{{t("Last year")}}`,
disabled,
},
{
key: 'thisYear',
value: 'thisYear',
label: `{{t("This year")}}`,
disabled,
},
{
key: 'nextYear',
value: 'nextYear',
label: `{{t("Next year")}}`,
disabled,
},
{
key: 'last7Days',
value: 'last7Days',
label: `{{t("Last 7 days")}}`,
disabled,
},
{
key: 'next7Days',
value: 'next7Days',
label: `{{t("Next 7 days")}}`,
disabled,
},
{
key: 'last30Days',
value: 'last30Days',
label: `{{t("Last 30 days")}}`,
disabled,
},
{
key: 'next30Days',
value: 'next30Days',
label: `{{t("Next 30 days")}}`,
disabled,
},
{
key: 'last90Days',
value: 'last90Days',
label: `{{t("Last 90 days")}}`,
disabled,
},
{
key: 'next90Days',
value: 'next90Days',
label: `{{t("Next 90 days")}}`,
disabled,
},
];
return [
userVariable,
// {
// title: `{{t("System variables")}}`,
// value: '$system',
// disabled: systemOptions.every((option) => option.disabled),
// options: systemOptions,
// },
{
title: `{{t("Date variables")}}`,
value: '$date',
disabled: dateOptions.every((option) => option.disabled),
options: dateOptions,
},
];
};
const useVariableOptions = () => {
const compile = useCompile();
const options = useVariableTypes().map((item) => {
return {
label: compile(item.title),
value: item.value,
key: item.value,
children: compile(item.options),
disabled: item.disabled,
};
});
return options;
};
export function FilterDynamicComponent(props) {
const { value, onChange, renderSchemaComponent } = props;
const scope = useVariableOptions();
return (
<Variable.Input value={value} onChange={onChange} scope={scope}>
{renderSchemaComponent()}
</Variable.Input>
);
}

View File

@ -10,7 +10,9 @@ import { FilterBlockType } from '../../../filter-provider/utils';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useSchemaTemplate } from '../../../schema-templates';
import { useDesignable } from '../../hooks';
import { removeNullCondition } from '../filter';
import { FixedBlockDesignerItem } from '../page';
import { FilterDynamicComponent } from './FilterDynamicComponent';
export const TableBlockDesigner = () => {
const { name, title, sortable } = useCollection();
@ -94,12 +96,15 @@ export const TableBlockDesigner = () => {
// title: '数据范围',
enum: dataSource,
'x-component': 'Filter',
'x-component-props': {},
'x-component-props': {
dynamicComponent: (props) => FilterDynamicComponent({ ...props }),
},
},
},
} as ISchema
}
onSubmit={({ filter }) => {
filter = removeNullCondition(filter);
const params = field.decoratorProps.params || {};
params.filter = filter;
field.decoratorProps.params = params;

View File

@ -9,6 +9,8 @@ import { useCollectionFilterOptions, useSortFields } from '../../../collection-m
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useSchemaTemplate } from '../../../schema-templates';
import { useDesignable } from '../../hooks';
import { removeNullCondition } from '../filter';
import { FilterDynamicComponent } from './FilterDynamicComponent';
export const TableSelectorDesigner = () => {
const { name, title } = useCollection();
@ -49,12 +51,15 @@ export const TableSelectorDesigner = () => {
// title: '数据范围',
enum: dataSource,
'x-component': 'Filter',
'x-component-props': {},
'x-component-props': {
dynamicComponent: (props) => FilterDynamicComponent({ ...props }),
},
},
},
} as ISchema
}
onSubmit={({ filter }) => {
filter = removeNullCondition(filter);
const params = field.decoratorProps.params || {};
params.filter = filter;
field.decoratorProps.params = params;

View File

@ -0,0 +1,55 @@
import { useFilterOptions } from '../../filter';
interface GetOptionsParams {
schema: any;
operator: string;
maxDepth: number;
count?: number;
}
const useOptions = (collectionName: string, { schema, operator, maxDepth, count = 1 }: GetOptionsParams) => {
if (count > maxDepth) {
return [];
}
const result = useFilterOptions(collectionName).map((option) => {
if ((option.type !== 'belongsTo' && option.type !== 'hasOne') || !option.target) {
return {
key: option.name,
value: option.name,
label: option.title,
// TODO: 现在是通过组件的名称来过滤能够被选择的选项,这样的坏处是不够精确,后续可以优化
disabled: schema?.['x-component'] !== option.schema['x-component'],
};
}
const children =
useOptions(option.target, {
schema,
operator,
maxDepth,
count: count + 1,
}) || [];
return {
key: option.name,
value: option.name,
label: option.title,
children,
disabled: children.every((child) => child.disabled),
};
});
return result;
};
export const useUserVariable = ({ schema, operator }) => {
const options = useOptions('users', { schema, operator, maxDepth: 3 }) || [];
return {
title: `{{t("Current user")}}`,
value: '$user',
disabled: options.every((option) => option.disabled),
options: options,
};
};

View File

@ -1,10 +1,10 @@
import React from 'react';
import { useForm } from '@formily/react';
import { Cascader, Input as AntInput, Button, Tag, InputNumber, Select, DatePicker } from 'antd';
import { CloseCircleFilled } from '@ant-design/icons';
import { cx, css } from '@emotion/css';
import { useTranslation } from 'react-i18next';
import { css, cx } from '@emotion/css';
import { useForm } from '@formily/react';
import { Button, Cascader, DatePicker, Input as AntInput, InputNumber, Select, Tag } from 'antd';
import moment from 'moment';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useCompile } from '../..';
@ -136,7 +136,7 @@ export function Input(props) {
const variableText = variable
?.reduce((opts, key, i) => {
const option = (i ? (opts[i - 1] as VariableOptions).children : options)?.find((item) => item.value === key);
const option = (i ? (opts[i - 1] as VariableOptions)?.children : options)?.find((item) => item.value === key);
return option ? opts.concat(option) : opts;
}, [] as VariableOptions[])
.map((item) => item.label)

View File

@ -931,7 +931,7 @@ export const createFilterFormBlockSchema = (options) => {
const resourceName = resource || association || collection;
const schema: ISchema = {
type: 'void',
'x-decorator': 'FormBlockProvider',
'x-decorator': 'FilterFormBlockProvider',
'x-decorator-props': {
...others,
action,

View File

@ -1,56 +1,10 @@
import { css } from '@emotion/css';
import { createForm, onFieldValueChange } from '@formily/core';
import { connect, FieldContext, FormContext } from '@formily/react';
import { FieldContext, FormContext } from '@formily/react';
import { merge } from '@formily/shared';
import { Cascader } from 'antd';
import React, { useContext, useMemo } from 'react';
import { SchemaComponent } from '../../schema-component/core';
import { useCompile, useComponent } from '../../schema-component/hooks';
import { useComponent } from '../../schema-component/hooks';
import { FilterContext } from './context';
import { useFilterOptions } from '../../schema-component/antd/filter/useFilterActionProps';
const VariableCascader = connect((props) => {
const fields = useFilterOptions('users');
const compile = useCompile();
const { value, onChange } = props;
return (
<Cascader
className={css`
width: 160px;
`}
value={value ? value.split('.') : []}
fieldNames={{
label: 'title',
value: 'name',
children: 'children',
}}
onChange={(value) => {
onChange(value ? value.join('.') : null);
}}
options={compile([
{
title: '{{t("Current user")}}',
name: 'currentUser',
children: fields
.filter((field) => {
if (!field.target) {
return true;
}
return field.type === 'belongsTo';
})
.map((field) => {
if (field.children) {
field.children = field.children.filter((child) => {
return !child.target;
});
}
return field;
}),
},
])}
/>
);
});
export const DynamicComponent = (props) => {
const { dynamicComponent, disabled } = useContext(FilterContext) || {};
@ -86,9 +40,6 @@ export const DynamicComponent = (props) => {
'x-validator': undefined,
'x-decorator': undefined,
}}
components={{
VariableCascader,
}}
/>
</FieldContext.Provider>
);

View File

@ -775,7 +775,7 @@ SchemaSettings.ModalItem = (props) => {
onSubmit,
asyncGetInitialValues,
initialValues,
width,
width = 'fit-content',
...others
} = props;
const options = useContext(SchemaOptionsContext);
@ -792,7 +792,7 @@ SchemaSettings.ModalItem = (props) => {
return (
<CollectionManagerContext.Provider value={cm}>
<SchemaComponentOptions scope={options.scope} components={options.components}>
<FormLayout layout={'vertical'}>
<FormLayout layout={'vertical'} style={{ minWidth: 520 }}>
<SchemaComponent components={components} scope={scope} schema={schema} />
</FormLayout>
</SchemaComponentOptions>

View File

@ -0,0 +1,75 @@
import { mockDatabase } from '../';
import { Database } from '../../database';
import { Repository } from '../../repository';
describe('date-field', () => {
let db: Database;
let repository: Repository;
beforeEach(async () => {
db = mockDatabase();
db.collection({
name: 'tests',
fields: [
{ name: 'date1', type: 'date' },
],
});
await db.sync();
repository = db.getRepository('tests');
});
afterEach(async () => {
await db.close();
});
const createExpectToBe = async (key, actual, expected) => {
const instance = await repository.create({
values: {
[key]: actual,
},
});
return expect(instance.get(key).toISOString()).toEqual(expected);
};
test('create', async () => {
// sqlite 时区不能自定义,只有 +00:00postgres 和 mysql 可以自定义 DB_TIMEZONE
await createExpectToBe('date1', '2023-03-24', '2023-03-24T00:00:00.000Z');
await createExpectToBe('date1', '2023-03-24T16:00:00.000Z', '2023-03-24T16:00:00.000Z');
});
// dateXX 相关 Operator 都是去 time 比较的
describe('dateOn', () => {
test('dateOn operator', async () => {
console.log('timezone', db.options.timezone);
// 默认的情况,时区为 db.options.timezone
await repository.find({
filter: {
date1: {
// 由 db.options.timezone 来处理日期转换,假设是 +08:00 的时区
// 2023-03-24表示的范围2023-03-23T16:00:00 ~ 2023-03-24T16:00:00
$dateOn: '2023-03-24',
},
},
});
await repository.find({
filter: {
date1: {
// +06:00 时区 2023-03-24 的范围2023-03-23T18:00:00 ~ 2023-03-24T18:00:00
$dateOn: '2023-03-24+06:00',
},
},
});
await repository.find({
filter: {
date1: {
// 2023-03-23T20:00:00+08:00 在 +08:00 时区的时间是2023-03-24 04:00:00
// 也就是 +08:00 时区 2023-03-24 这一天的范围2023-03-23T16:00:00 ~ 2023-03-24T16:00:00
$dateOn: '2023-03-23T20:00:00+08:00',
},
},
});
});
});
});

View File

@ -47,14 +47,14 @@ describe('filter', () => {
},
});
const response = await UserCollection.repository.find({
const count = await PostCollection.repository.count({
filter: {
'posts.createdAt': {
'user.createdAt': {
$dateOn: user.get('createdAt'),
},
},
});
expect(response).toHaveLength(1);
expect(count).toBe(2);
});
});

View File

@ -1,24 +1,26 @@
import { mockDatabase } from '../index';
import { Collection } from '../../collection';
import Database from '../../database';
import { Repository } from '../../repository';
import { mockDatabase } from '../index';
describe('date operator test', () => {
let db: Database;
let User: Collection;
let repository: Repository;
afterEach(async () => {
await db.close();
});
beforeEach(async () => {
db = mockDatabase();
User = db.collection({
name: 'users',
db = mockDatabase({
timezone: '+00:00',
});
await db.clean({ drop: true });
const Test = db.collection({
name: 'tests',
fields: [
{
name: 'birthday',
name: 'date1',
type: 'date',
},
{
@ -27,139 +29,283 @@ describe('date operator test', () => {
},
],
});
await db.sync({ force: true, alter: { drop: false } });
repository = Test.repository;
await db.sync();
});
test('$dateOn', async () => {
const u0 = await User.repository.create({
values: {
birthday: '1990-01-02 12:03:02',
await repository.create({
values: [
{
date1: '2023-01-01T00:00:00.000Z',
name: 'u0',
},
{
date1: '2023-01-01T00:00:00.001Z',
name: 'u1',
},
{
date1: '2022-12-31T16:00:00.000Z',
name: 'u2',
},
{
date1: '2022-12-31T16:00:00.001Z',
name: 'u3',
},
],
});
const user1 = await User.repository.create({
values: {
birthday: '1990-01-01 12:03:02',
name: 'user1',
let count: number;
count = await repository.count({
filter: {
'date1.$dateOn': '2023',
},
});
const user = await User.repository.findOne({
filter: { 'birthday.$dateOn': '1990-01-01' },
expect(count).toBe(2);
count = await repository.count({
filter: {
'date1.$dateOn': '2023+08:00',
},
});
expect(user.get('id')).toEqual(user1.get('id'));
expect(count).toBe(4);
});
test('$dateNotOn', async () => {
const u0 = await User.repository.create({
values: {
birthday: '1990-01-02 12:03:02',
await repository.create({
values: [
{
date1: '2023-01-01T00:00:00.000Z',
name: 'u0',
},
{
date1: '2023-01-01T00:00:00.001Z',
name: 'u1',
},
{
date1: '2022-12-31T16:00:00.000Z',
name: 'u2',
},
{
date1: '2022-12-31T16:00:00.001Z',
name: 'u3',
},
],
});
const user1 = await User.repository.create({
values: {
birthday: '1990-01-01 12:03:02',
name: 'user1',
let count: number;
count = await repository.count({
filter: {
'date1.$dateNotOn': '2023',
},
});
const user = await User.repository.findOne({
filter: { 'birthday.$dateNotOn': '1990-01-01' },
expect(count).toBe(2);
count = await repository.count({
filter: {
'date1.$dateNotOn': '2023+08:00',
},
});
expect(user.get('id')).toEqual(u0.get('id'));
expect(count).toBe(0);
});
test('$dateBefore', async () => {
const u0 = await User.repository.create({
values: {
birthday: '1990-05-01 12:03:02',
await repository.create({
values: [
{
date1: '2023-01-01T00:00:00.000Z',
name: 'u0',
},
{
date1: '2023-01-01T00:00:00.001Z',
name: 'u1',
},
{
date1: '2022-12-31T16:00:00.000Z',
name: 'u2',
},
{
date1: '2022-12-31T16:00:00.001Z',
name: 'u3',
},
{
date1: '2022-12-30T15:59:59.999Z',
name: 'u4',
},
],
});
const user1 = await User.repository.create({
values: {
birthday: '1990-01-01 12:03:02',
name: 'user1',
let count: number;
count = await repository.count({
filter: {
'date1.$dateBefore': '2023',
},
});
const user = await User.repository.findOne({
filter: { 'birthday.$dateBefore': '1990-04-01' },
expect(count).toBe(3);
count = await repository.count({
filter: {
'date1.$dateBefore': '2023+08:00',
},
});
expect(user.get('id')).toEqual(user1.get('id'));
expect(count).toBe(1);
});
test('$dateNotBefore', async () => {
const u0 = await User.repository.create({
values: {
birthday: '1990-05-01 12:03:02',
await repository.create({
values: [
{
date1: '2023-01-01T00:00:00.000Z',
name: 'u0',
},
{
date1: '2023-01-01T00:00:00.001Z',
name: 'u1',
},
{
date1: '2022-12-31T16:00:00.000Z',
name: 'u2',
},
{
date1: '2022-12-31T16:00:00.001Z',
name: 'u3',
},
{
date1: '2022-12-30T15:59:59.999Z',
name: 'u4',
},
],
});
const user1 = await User.repository.create({
values: {
birthday: '1990-01-01 12:03:02',
name: 'user1',
let count: number;
count = await repository.count({
filter: {
'date1.$dateNotBefore': '2023',
},
});
const user = await User.repository.findOne({
filter: { 'birthday.$dateNotBefore': '1990-04-01' },
expect(count).toBe(2);
count = await repository.count({
filter: {
'date1.$dateNotBefore': '2023+08:00',
},
});
expect(user.get('id')).toEqual(u0.get('id'));
expect(count).toBe(4);
});
test('$dateAfter', async () => {
const u0 = await User.repository.create({
values: {
birthday: '1990-05-01 12:03:02',
await repository.create({
values: [
{
date1: '2023-01-01T00:00:00.000Z',
name: 'u0',
},
{
date1: '2023-01-01T00:00:00.001Z',
name: 'u1',
},
{
date1: '2022-12-31T16:00:00.000Z',
name: 'u2',
},
{
date1: '2022-12-31T16:00:00.001Z',
name: 'u3',
},
{
date1: '2022-12-30T15:59:59.999Z',
name: 'u4',
},
],
});
const user1 = await User.repository.create({
values: {
birthday: '1990-01-01 12:03:02',
name: 'user1',
let count: number;
count = await repository.count({
filter: {
'date1.$dateAfter': '2022',
},
});
const user = await User.repository.findOne({
filter: { 'birthday.$dateAfter': '1990-04-01' },
expect(count).toBe(2);
count = await repository.count({
filter: {
'date1.$dateAfter': '2022+08:00',
},
});
expect(user.get('id')).toEqual(u0.get('id'));
expect(count).toBe(4);
});
test('$dateNotAfter', async () => {
const u0 = await User.repository.create({
values: {
birthday: '1990-05-01 12:03:02',
await repository.create({
values: [
{
date1: '2023-01-01T00:00:00.000Z',
name: 'u0',
},
{
date1: '2023-01-01T00:00:00.001Z',
name: 'u1',
},
{
date1: '2022-12-31T16:00:00.000Z',
name: 'u2',
},
{
date1: '2022-12-31T16:00:00.001Z',
name: 'u3',
},
{
date1: '2022-12-30T15:59:59.999Z',
name: 'u4',
},
],
});
const user1 = await User.repository.create({
values: {
birthday: '1990-01-01 12:03:02',
name: 'user1',
let count: number;
count = await repository.count({
filter: {
'date1.$dateNotAfter': '2022',
},
});
const user = await User.repository.findOne({
filter: { 'birthday.$dateNotAfter': '1990-04-01' },
expect(count).toBe(3);
count = await repository.count({
filter: {
'date1.$dateNotAfter': '2022+08:00',
},
});
expect(count).toBe(1);
});
expect(user.get('id')).toEqual(user1.get('id'));
test('$dateBetween', async () => {
await repository.create({
values: [
{
date1: '2023-01-01T00:00:00.000Z',
name: 'u0',
},
{
date1: '2023-01-05T16:00:00.000Z',
name: 'u1',
},
{
date1: '2022-12-31T16:00:00.000Z',
name: 'u2',
},
{
date1: '2022-12-31T16:00:00.001Z',
name: 'u3',
},
{
date1: '2022-12-30T15:59:59.999Z',
name: 'u4',
},
{
date1: '2023-01-04T16:00:00.000Z',
name: 'u1',
},
],
});
let count: number;
count = await repository.count({
filter: {
'date1.$dateBetween': '[2023-01-01,2023-01-05]',
},
});
expect(count).toBe(3);
count = await repository.count({
filter: {
'date1.$dateBetween': '[2023-01-01,2023-01-05]+08:00',
},
});
expect(count).toBe(4);
});
});

View File

@ -6,6 +6,10 @@ export class DateField extends Field {
return DataTypes.DATE(3);
}
get timezone() {
return this.isGMT() ? '+00:00' : null;
}
getProps() {
return this.options?.uiSchema?.['x-component-props'] || {};
}
@ -17,7 +21,7 @@ export class DateField extends Field {
isGMT() {
const props = this.getProps();
return props.gmt || !props.showTime;
return props.gmt;
}
}

View File

@ -1,43 +1,125 @@
import moment, { MomentInput } from 'moment';
import { parseDate } from '@nocobase/utils';
import { Op } from 'sequelize';
function stringToDate(value: string): Date {
return moment(value).toDate();
function isDate(input) {
return input instanceof Date || Object.prototype.toString.call(input) === '[object Date]';
}
function getNextDay(value: MomentInput): Date {
return moment(value).add(1, 'd').toDate();
}
const toDate = (date) => {
if (isDate(date)) {
return date;
}
return new Date(date);
};
export default {
$dateOn(value, ctx) {
// const field = ctx.db.getFieldByPath(ctx.fieldPath);
// console.log(field.isDateOnly());
const r = parseDate(value, {
timezone: ctx.db.options.timezone,
});
if (typeof r === 'string') {
return {
[Op.and]: [{ [Op.gte]: stringToDate(value) }, { [Op.lt]: getNextDay(value) }],
[Op.eq]: toDate(r),
};
},
$dateNotOn(value) {
}
if (Array.isArray(r)) {
return {
[Op.or]: [{ [Op.lt]: stringToDate(value) }, { [Op.gte]: getNextDay(value) }],
[Op.and]: [{ [Op.gte]: toDate(r[0]) }, { [Op.lt]: toDate(r[1]) }],
};
}
throw new Error(`Invalid Date ${JSON.stringify(value)}`);
},
$dateBefore(value) {
return { [Op.lt]: stringToDate(value) };
},
$dateNotBefore(value) {
$dateNotOn(value, ctx) {
const r = parseDate(value, {
timezone: ctx.db.options.timezone,
});
if (typeof r === 'string') {
return {
[Op.gte]: stringToDate(value),
[Op.ne]: toDate(r),
};
}
if (Array.isArray(r)) {
return {
[Op.or]: [{ [Op.lt]: toDate(r[0]) }, { [Op.gte]: toDate(r[1]) }],
};
}
throw new Error(`Invalid Date ${JSON.stringify(value)}`);
},
$dateAfter(value) {
return { [Op.gte]: getNextDay(value) };
$dateBefore(value, ctx) {
const r = parseDate(value, {
timezone: ctx.db.options.timezone,
});
if (typeof r === 'string') {
return {
[Op.lt]: toDate(r),
};
} else if (Array.isArray(r)) {
return {
[Op.lt]: toDate(r[0]),
};
}
throw new Error(`Invalid Date ${JSON.stringify(value)}`);
},
$dateNotAfter(value) {
return { [Op.lt]: getNextDay(value) };
$dateNotBefore(value, ctx) {
const r = parseDate(value, {
timezone: ctx.db.options.timezone,
});
if (typeof r === 'string') {
return {
[Op.gte]: toDate(r),
};
} else if (Array.isArray(r)) {
return {
[Op.gte]: toDate(r[0]),
};
}
throw new Error(`Invalid Date ${JSON.stringify(value)}`);
},
$dateAfter(value, ctx) {
const r = parseDate(value, {
timezone: ctx.db.options.timezone,
});
if (typeof r === 'string') {
return {
[Op.gt]: toDate(r),
};
} else if (Array.isArray(r)) {
return {
[Op.gte]: toDate(r[1]),
};
}
throw new Error(`Invalid Date ${JSON.stringify(value)}`);
},
$dateNotAfter(value, ctx) {
const r = parseDate(value, {
timezone: ctx.db.options.timezone,
});
if (typeof r === 'string') {
return {
[Op.lte]: toDate(r),
};
} else if (Array.isArray(r)) {
return {
[Op.lt]: toDate(r[1]),
};
}
throw new Error(`Invalid Date ${JSON.stringify(value)}`);
},
$dateBetween(value, ctx) {
const r = parseDate(value, {
timezone: ctx.db.options.timezone,
});
if (r) {
return {
[Op.and]: [{ [Op.gte]: toDate(r[0]) }, { [Op.lt]: toDate(r[1]) }],
};
}
throw new Error(`Invalid Date ${JSON.stringify(value)}`);
},
} as Record<string, any>;

View File

@ -13,6 +13,7 @@
"@nocobase/database": "0.9.1-alpha.2",
"@nocobase/logger": "0.9.1-alpha.2",
"@nocobase/resourcer": "0.9.1-alpha.2",
"@nocobase/utils": "0.9.1-alpha.2",
"chalk": "^4.1.1",
"commander": "^9.2.0",
"find-package-json": "^1.2.0",
@ -22,7 +23,8 @@
"koa-static": "^5.0.0",
"lodash": "^4.17.21",
"semver": "^7.3.7",
"xpipe": "^1.0.5"
"xpipe": "^1.0.5",
"moment": "^2.29.1"
},
"devDependencies": {
"@types/semver": "^7.3.9"

View File

@ -4,6 +4,7 @@ import Resourcer from '@nocobase/resourcer';
import i18next from 'i18next';
import bodyParser from 'koa-bodyparser';
import Application, { ApplicationOptions } from './application';
import { parseVariables } from './middlewares';
import { dataWrapping } from './middlewares/data-wrapping';
import { db2resource } from './middlewares/db2resource';
import { i18n } from './middlewares/i18n';
@ -70,6 +71,8 @@ export function registerMiddlewares(app: Application, options: ApplicationOption
app.use(dataWrapping(), { tag: 'dataWrapping', after: 'i18n' });
}
app.resourcer.use(parseVariables, { tag: 'parseVariables', after: 'acl' });
app.use(db2resource, { tag: 'db2resource', after: 'dataWrapping' });
app.use(app.resourcer.restApiMiddleware(), { tag: 'restApi', after: 'db2resource' });
}

View File

@ -1,3 +1,3 @@
export * from './data-wrapping';
export * from './db2resource';
export { parseVariables } from './parse-variables';

View File

@ -0,0 +1,55 @@
import { getDateVars, parseFilter } from '@nocobase/utils';
function getUser(ctx) {
return async ({ fields }) => {
const userFields = fields.filter((f) => f && ctx.db.getFieldByPath('users.' + f));
ctx.logger?.info('filter-parse: ', { userFields });
if (!ctx.state.currentUser) {
return;
}
if (!userFields.length) {
return;
}
const user = await ctx.db.getRepository('users').findOne({
filterByTk: ctx.state.currentUser.id,
fields: userFields,
});
ctx.logger?.info('filter-parse: ', {
$user: user?.toJSON(),
});
return user;
};
}
function isNumeric(str: any) {
if (typeof str === 'number') return true;
if (typeof str != 'string') return false;
return !isNaN(str as any) && !isNaN(parseFloat(str));
}
export const parseVariables = async (ctx, next) => {
const filter = ctx.action.params.filter;
if (!filter) {
return next();
}
ctx.action.params.filter = await parseFilter(filter, {
timezone: ctx.get('x-timezone'),
now: new Date().toISOString(),
getField: (path) => {
const fieldPath = path
.split('.')
.filter((p) => !p.startsWith('$') && !isNumeric(p))
.join('.');
const { resourceName } = ctx.action;
return ctx.db.getFieldByPath(`${resourceName}.${fieldPath}`);
},
vars: {
$system: {
now: new Date().toISOString(),
},
$date: getDateVars(),
$user: getUser(ctx),
},
});
await next();
};

View File

@ -0,0 +1,73 @@
import { hasEmptyValue } from '../client';
describe('hasEmptyValue', () => {
it('should return false when there is no empty value', () => {
const obj = {
a: 1,
b: 'hello',
c: [1, 2, 3],
d: {
e: 'world',
f: [4, 5, 6],
g: {
h: 'foo',
i: 'bar',
},
},
};
expect(hasEmptyValue(obj)).toBe(false);
});
it('should return true when there is an empty value in an object', () => {
const obj = {
a: 1,
b: '',
c: [1, 2, 3],
d: {
e: 'world',
f: [4, 5, 6],
g: {
h: 'foo',
i: null,
},
},
};
expect(hasEmptyValue(obj)).toBe(true);
});
it('should return true when there is an empty value in an array', () => {
const arr = [1, '', 3, [4, 5, 6], { a: 'foo', b: null }];
expect(hasEmptyValue(arr)).toBe(true);
});
it('should return true when there is an empty value in an array and an object', () => {
const obj = {
a: 1,
b: '',
c: [1, 2, 3],
d: {
e: 'world',
f: [4, 5, 6],
g: {
h: 'foo',
i: null,
},
},
h: [1, '', 3, [4, 5, 6], { a: 'foo', b: null }],
};
expect(hasEmptyValue(obj)).toBe(true);
});
it('should return false when the input is an empty object', () => {
expect(hasEmptyValue({})).toBe(true);
});
it('should return false when the input is an empty array', () => {
expect(hasEmptyValue([])).toBe(true);
});
it('should return true when the input is an object with an empty value', () => {
const obj = { $and: [{ f_rto697s6udb: { $dateOn: null } }] };
expect(hasEmptyValue(obj)).toBe(true);
});
});

View File

@ -0,0 +1,21 @@
import { forEach } from '../forEach';
describe('forEach', () => {
test('array', () => {
const arr = [1, 2, 3];
const result = [];
forEach(arr, (value, index) => {
result.push(value);
});
expect(result).toEqual(arr);
});
test('object', () => {
const obj = { a: 1, b: 2, c: 3 };
const result = [];
forEach(obj, (value, key) => {
result.push(value);
});
expect(result).toEqual([1, 2, 3]);
});
});

View File

@ -0,0 +1,193 @@
import moment from 'moment';
import { parseDate } from '../parse-date';
describe('parse date', () => {
const expectDate = (date: any, options?: any) => {
const r = parseDate(date, options);
console.log(date, r);
return expect(r);
};
it('should parse empty', async () => {
expectDate(null).toBeUndefined();
expectDate('').toBeUndefined();
});
it('should parse year', async () => {
expectDate('2023').toEqual(['2023-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z']);
expectDate('2023+08:00').toEqual(['2022-12-31T16:00:00.000Z', '2023-12-31T16:00:00.000Z']);
expectDate('2023', { timezone: '+08:00' }).toEqual(['2022-12-31T16:00:00.000Z', '2023-12-31T16:00:00.000Z']);
});
it('should parse quarter', async () => {
expectDate('2023Q1').toEqual(['2023-01-01T00:00:00.000Z', '2023-04-01T00:00:00.000Z']);
expectDate('2023Q1+08:00').toEqual(['2022-12-31T16:00:00.000Z', '2023-03-31T16:00:00.000Z']);
expectDate('2023Q1', { timezone: '+08:00' }).toEqual(['2022-12-31T16:00:00.000Z', '2023-03-31T16:00:00.000Z']);
});
it('should parse iso week', async () => {
expectDate('2023W01').toEqual(['2023-01-02T00:00:00.000Z', '2023-01-09T00:00:00.000Z']);
expectDate('2023W01+08:00').toEqual(['2023-01-01T16:00:00.000Z', '2023-01-08T16:00:00.000Z']);
expectDate('2023W01', { timezone: '+08:00' }).toEqual(['2023-01-01T16:00:00.000Z', '2023-01-08T16:00:00.000Z']);
});
it('should parse week', async () => {
expectDate('2023w01').toEqual(['2023-01-01T00:00:00.000Z', '2023-01-08T00:00:00.000Z']);
expectDate('2023w01+08:00').toEqual(['2022-12-31T16:00:00.000Z', '2023-01-07T16:00:00.000Z']);
expectDate('2023w01', { timezone: '+08:00' }).toEqual(['2022-12-31T16:00:00.000Z', '2023-01-07T16:00:00.000Z']);
});
it('should parse month', () => {
expectDate('2023-03').toEqual(['2023-03-01T00:00:00.000Z', '2023-04-01T00:00:00.000Z']);
expectDate('2023-03+08:00').toEqual(['2023-02-28T16:00:00.000Z', '2023-03-31T16:00:00.000Z']);
expectDate('2023-03', { timezone: '+08:00' }).toEqual(['2023-02-28T16:00:00.000Z', '2023-03-31T16:00:00.000Z']);
});
it('should parse day', () => {
expectDate('2023-01-12').toEqual(['2023-01-12T00:00:00.000Z', '2023-01-13T00:00:00.000Z']);
expectDate('2023-01-12+08:00').toEqual(['2023-01-11T16:00:00.000Z', '2023-01-12T16:00:00.000Z']);
expectDate('2023-01-12', { timezone: '+08:00' }).toEqual(['2023-01-11T16:00:00.000Z', '2023-01-12T16:00:00.000Z']);
});
it('should parse hour', () => {
expectDate('2023-01-12T12').toEqual(['2023-01-12T12:00:00.000Z', '2023-01-12T13:00:00.000Z']);
expectDate('2023-01-12T12+08:00').toEqual(['2023-01-12T04:00:00.000Z', '2023-01-12T05:00:00.000Z']);
expectDate('2023-01-12T12', { timezone: '+08:00' }).toEqual([
'2023-01-12T04:00:00.000Z',
'2023-01-12T05:00:00.000Z',
]);
expectDate('2023-01-12 12').toEqual(['2023-01-12T12:00:00.000Z', '2023-01-12T13:00:00.000Z']);
expectDate('2023-01-12 12+08:00').toEqual(['2023-01-12T04:00:00.000Z', '2023-01-12T05:00:00.000Z']);
expectDate('2023-01-12 12', { timezone: '+08:00' }).toEqual([
'2023-01-12T04:00:00.000Z',
'2023-01-12T05:00:00.000Z',
]);
});
it('should parse minute', () => {
expectDate('2023-01-12T12:23').toEqual(['2023-01-12T12:23:00.000Z', '2023-01-12T12:24:00.000Z']);
expectDate('2023-01-12T12:23+08:00').toEqual(['2023-01-12T04:23:00.000Z', '2023-01-12T04:24:00.000Z']);
expectDate('2023-01-12T12:23', { timezone: '+08:00' }).toEqual([
'2023-01-12T04:23:00.000Z',
'2023-01-12T04:24:00.000Z',
]);
expectDate('2023-01-12 12:23').toEqual(['2023-01-12T12:23:00.000Z', '2023-01-12T12:24:00.000Z']);
expectDate('2023-01-12 12:23+08:00').toEqual(['2023-01-12T04:23:00.000Z', '2023-01-12T04:24:00.000Z']);
expectDate('2023-01-12 12:23', { timezone: '+08:00' }).toEqual([
'2023-01-12T04:23:00.000Z',
'2023-01-12T04:24:00.000Z',
]);
});
it('should parse second', () => {
expectDate('2023-01-12T12:23:59').toEqual(['2023-01-12T12:23:59.000Z', '2023-01-12T12:24:00.000Z']);
expectDate('2023-01-12T12:23:59+08:00').toEqual(['2023-01-12T04:23:59.000Z', '2023-01-12T04:24:00.000Z']);
expectDate('2023-01-12T12:23:59', { timezone: '+08:00' }).toEqual([
'2023-01-12T04:23:59.000Z',
'2023-01-12T04:24:00.000Z',
]);
expectDate('2023-01-12 12:23:59').toEqual(['2023-01-12T12:23:59.000Z', '2023-01-12T12:24:00.000Z']);
expectDate('2023-01-12 12:23:59+08:00').toEqual(['2023-01-12T04:23:59.000Z', '2023-01-12T04:24:00.000Z']);
expectDate('2023-01-12 12:23:59', { timezone: '+08:00' }).toEqual([
'2023-01-12T04:23:59.000Z',
'2023-01-12T04:24:00.000Z',
]);
});
it('should parse millisecond', () => {
expectDate('2023-01-12T12:23:59.326').toEqual(['2023-01-12T12:23:59.326Z', '2023-01-12T12:23:59.327Z']);
expectDate('2023-01-12T12:23:59.326+08:00').toEqual(['2023-01-12T04:23:59.326Z', '2023-01-12T04:23:59.327Z']);
expectDate('2023-01-12T12:23:59.326', { timezone: '+08:00' }).toEqual([
'2023-01-12T04:23:59.326Z',
'2023-01-12T04:23:59.327Z',
]);
expectDate('2023-01-12 12:23:59.326').toEqual(['2023-01-12T12:23:59.326Z', '2023-01-12T12:23:59.327Z']);
expectDate('2023-01-12 12:23:59.326+08:00').toEqual(['2023-01-12T04:23:59.326Z', '2023-01-12T04:23:59.327Z']);
expectDate('2023-01-12 12:23:59.326', { timezone: '+08:00' }).toEqual([
'2023-01-12T04:23:59.326Z',
'2023-01-12T04:23:59.327Z',
]);
});
it('should parse utc', () => {
expectDate(new Date('2023-01-12T12:23:59.326Z')).toEqual('2023-01-12T12:23:59.326Z');
expectDate(moment('2023-01-12T12:23:59.326Z')).toEqual('2023-01-12T12:23:59.326Z');
expectDate('2023-01-12T12:23:59.326Z').toEqual('2023-01-12T12:23:59.326Z');
expectDate('2023-01-12T12:23:59.326Z+08:00').toEqual('2023-01-12T12:23:59.326Z');
expectDate('2023-01-12T12:23:59.326Z', { timezone: '+08:00' }).toEqual('2023-01-12T12:23:59.326Z');
});
describe('parse date between', () => {
it('should parse year', async () => {
expectDate('[2023,2024]').toEqual(['2023-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z']);
expectDate('(2023,2024]').toEqual(['2024-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z']);
expectDate('[2023,2024)').toEqual(['2023-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z']);
expectDate('(2023,2026)').toEqual(['2024-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z']);
expectDate(['2023', '2024']).toEqual(['2023-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z']);
expectDate(['2023', '2024', '[]']).toEqual(['2023-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z']);
expectDate(['2023', '2024', '(]']).toEqual(['2024-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z']);
expectDate(['2023', '2024', '[)']).toEqual(['2023-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z']);
expectDate(['2023', '2026', '()']).toEqual(['2024-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z']);
expectDate('[2023,2024]+08:00').toEqual(['2022-12-31T16:00:00.000Z', '2024-12-31T16:00:00.000Z']);
expectDate('(2023,2024]+08:00').toEqual(['2023-12-31T16:00:00.000Z', '2024-12-31T16:00:00.000Z']);
expectDate('[2023,2024)+08:00').toEqual(['2022-12-31T16:00:00.000Z', '2023-12-31T16:00:00.000Z']);
expectDate('(2023,2026)+08:00').toEqual(['2023-12-31T16:00:00.000Z', '2025-12-31T16:00:00.000Z']);
expectDate(['2023', '2024', '[]', '+08:00']).toEqual(['2022-12-31T16:00:00.000Z', '2024-12-31T16:00:00.000Z']);
expectDate(['2023', '2024', '(]', '+08:00']).toEqual(['2023-12-31T16:00:00.000Z', '2024-12-31T16:00:00.000Z']);
expectDate(['2023', '2024', '[)', '+08:00']).toEqual(['2022-12-31T16:00:00.000Z', '2023-12-31T16:00:00.000Z']);
expectDate(['2023', '2026', '()', '+08:00']).toEqual(['2023-12-31T16:00:00.000Z', '2025-12-31T16:00:00.000Z']);
});
it('should parse day', async () => {
expectDate('[2023-01-12,2023-09-12]').toEqual(['2023-01-12T00:00:00.000Z', '2023-09-13T00:00:00.000Z']);
expectDate('[2023-01-12,2023-09-12)').toEqual(['2023-01-12T00:00:00.000Z', '2023-09-12T00:00:00.000Z']);
expectDate('(2023-01-12,2023-09-12]').toEqual(['2023-01-13T00:00:00.000Z', '2023-09-13T00:00:00.000Z']);
expectDate('(2023-01-12,2023-09-12)').toEqual(['2023-01-13T00:00:00.000Z', '2023-09-12T00:00:00.000Z']);
expectDate('[2023-01-12,2023-09-12]+08:00').toEqual(['2023-01-11T16:00:00.000Z', '2023-09-12T16:00:00.000Z']);
expectDate('[2023-01-12,2023-09-12)+08:00').toEqual(['2023-01-11T16:00:00.000Z', '2023-09-11T16:00:00.000Z']);
expectDate('(2023-01-12,2023-09-12]+08:00').toEqual(['2023-01-12T16:00:00.000Z', '2023-09-12T16:00:00.000Z']);
expectDate('(2023-01-12,2023-09-12)+08:00').toEqual(['2023-01-12T16:00:00.000Z', '2023-09-11T16:00:00.000Z']);
});
it('should parse utc', async () => {
expectDate('[2023-01-12T12:23:59.326Z,2023-01-12T12:24:59.326Z]').toEqual([
'2023-01-12T12:23:59.326Z',
'2023-01-12T12:24:59.326Z',
'[]',
]);
expectDate('[2023-01-12T12:23:59.326Z,2023-01-12T12:24:59.326Z)').toEqual([
'2023-01-12T12:23:59.326Z',
'2023-01-12T12:24:59.326Z',
]);
expectDate('(2023-01-12T12:23:59.326Z,2023-01-12T12:24:59.326Z)').toEqual([
'2023-01-12T12:23:59.326Z',
'2023-01-12T12:24:59.326Z',
'()'
]);
expectDate('(2023-01-12T12:23:59.326Z,2023-01-12T12:24:59.326Z]').toEqual([
'2023-01-12T12:23:59.326Z',
'2023-01-12T12:24:59.326Z',
'(]'
]);
expectDate('[2023-01-12T12:23:59.326Z,2023-01-12T12:24:59.326Z]+08:00').toEqual([
'2023-01-12T12:23:59.326Z',
'2023-01-12T12:24:59.326Z',
'[]',
]);
expectDate('[2023-01-12T12:23:59.326Z,2023-01-12T12:24:59.326Z)+08:00').toEqual([
'2023-01-12T12:23:59.326Z',
'2023-01-12T12:24:59.326Z',
]);
expectDate('(2023-01-12T12:23:59.326Z,2023-01-12T12:24:59.326Z)+08:00').toEqual([
'2023-01-12T12:23:59.326Z',
'2023-01-12T12:24:59.326Z',
'()'
]);
expectDate('(2023-01-12T12:23:59.326Z,2023-01-12T12:24:59.326Z]+08:00').toEqual([
'2023-01-12T12:23:59.326Z',
'2023-01-12T12:24:59.326Z',
'(]'
]);
});
});
});

View File

@ -0,0 +1,311 @@
import { getDateVars, getDayRange, parseFilter, utc2unit, Utc2unitOptions } from '../parse-filter';
describe('utc to unit', () => {
const expectUtc2unit = (options: Utc2unitOptions) => {
const r = utc2unit(options);
console.log(options, r);
return expect(r);
};
it('should be year', async () => {
expectUtc2unit({
now: '2023-01-05T16:00:00.000Z',
timezone: '+00:00',
unit: 'year',
}).toBe('2023+00:00');
expectUtc2unit({
now: '2022-12-31T16:00:00.000Z',
timezone: '+08:00',
unit: 'year',
}).toBe('2023+08:00');
expectUtc2unit({
now: '2022-12-31T15:00:00.000Z',
timezone: '+08:00',
unit: 'year',
}).toBe('2022+08:00');
expectUtc2unit({
now: '2023-01-05T16:00:00.000Z',
unit: 'year',
}).toBe('2023+00:00');
});
it('should be month', async () => {
expectUtc2unit({
now: '2023-01-05T16:00:00.000Z',
timezone: '+00:00',
unit: 'month',
}).toBe('2023-01+00:00');
expectUtc2unit({
now: '2022-12-31T16:00:00.000Z',
timezone: '+08:00',
unit: 'month',
}).toBe('2023-01+08:00');
expectUtc2unit({
now: '2023-01-05T16:00:00.000Z',
unit: 'month',
}).toBe('2023-01+00:00');
});
it('should be quarter', async () => {
expectUtc2unit({
now: '2023-01-05T16:00:00.000Z',
timezone: '+00:00',
unit: 'quarter',
}).toBe('2023Q1+00:00');
expectUtc2unit({
now: '2022-12-31T16:00:00.000Z',
timezone: '+08:00',
unit: 'quarter',
}).toBe('2023Q1+08:00');
expectUtc2unit({
now: '2022-12-31T15:00:00.000Z',
timezone: '+08:00',
unit: 'quarter',
}).toBe('2022Q4+08:00');
expectUtc2unit({
now: '2023-01-05T16:00:00.000Z',
unit: 'quarter',
}).toBe('2023Q1+00:00');
});
it('should be week', async () => {
expectUtc2unit({
now: '2023-01-08T00:00:00.000Z',
timezone: '+00:00',
unit: 'week',
}).toBe('2023w02+00:00');
expectUtc2unit({
now: '2022-12-31T16:00:00.000Z',
timezone: '+08:00',
unit: 'week',
}).toBe('2023w01+08:00');
expectUtc2unit({
now: '2023-01-01T00:00:00.000Z',
unit: 'week',
}).toBe('2023w01+00:00');
});
it('should be iso week', async () => {
expectUtc2unit({
now: '2023-01-08T00:00:00.000Z',
timezone: '+00:00',
unit: 'isoWeek',
}).toBe('2023W01+00:00');
expectUtc2unit({
now: '2022-12-31T16:00:00.000Z',
timezone: '+08:00',
unit: 'isoWeek',
}).toBe('2022W52+08:00');
expectUtc2unit({
now: '2023-01-01T00:00:00.000Z',
unit: 'isoWeek',
}).toBe('2022W52+00:00');
});
it('should be day', async () => {
expectUtc2unit({
now: '2023-01-05T16:00:00.000Z',
timezone: '+00:00',
unit: 'day',
}).toBe('2023-01-05+00:00');
expectUtc2unit({
now: '2023-01-05T16:00:00.000Z',
timezone: '+08:00',
unit: 'day',
}).toBe('2023-01-06+08:00');
expectUtc2unit({
now: '2023-01-05T16:00:00.000Z',
unit: 'day',
}).toBe('2023-01-05+00:00');
});
});
describe('getDayRange', () => {
const expectDayRange = (options) => {
const r = getDayRange(options);
console.log(r, options);
return expect(r);
};
test('next7days', () => {
expectDayRange({
now: '2023-03-28T16:00:00.000Z',
offset: 7,
timezone: '+00:00',
}).toEqual(['2023-03-29', '2023-04-05', '[)', '+00:00']);
expectDayRange({
now: '2023-03-28T16:00:00.000Z',
offset: 7,
timezone: '+08:00',
}).toEqual(['2023-03-30', '2023-04-06', '[)', '+08:00']);
});
test('last7days', () => {
expectDayRange({
now: '2023-03-28T16:00:00.000Z',
offset: -7,
timezone: '+00:00',
}).toEqual(['2023-03-22', '2023-03-29', '[)', '+00:00']);
expectDayRange({
now: '2023-03-28T16:00:00.000Z',
offset: -7,
timezone: '+08:00',
}).toEqual(['2023-03-23', '2023-03-30', '[)', '+08:00']);
});
});
describe('parseFilter', () => {
const expectParseFilter = (filter, options) => {
return {
async toEqual(expected) {
const r = await parseFilter(filter, options);
console.log(filter, r);
return expect(r).toEqual(expected);
},
};
};
test('timezone', async () => {
await expectParseFilter(
{
'a.$dateOn': '2023',
'b.$dateOn': '2023+08:00',
},
{
timezone: '+00:00',
},
).toEqual({ a: { $dateOn: '2023+00:00' }, b: { $dateOn: '2023+08:00' } });
});
test('timezone', async () => {
await expectParseFilter(
{
'a.$dateOn': '2023',
'b.$dateOn': '2023+08:00',
'c.$dateOn': '2023+08:00',
},
{
timezone: '+00:00',
getField(path) {
if (path === 'a.$dateOn') {
return {
timezone: '+06:00',
};
}
if (path === 'c.$dateOn') {
return {
timezone: '+06:00',
};
}
},
},
).toEqual({ a: { $dateOn: '2023+06:00' }, b: { $dateOn: '2023+08:00' }, c: { $dateOn: '2023+08:00' } });
});
test('vars', async () => {
await expectParseFilter(
{
'a.$dateOn': '{{$date.today}}',
'b.$eq': '{{$custom.foo}}',
'b.$ne': '{{$foo.bar}}',
},
{
timezone: '+08:00',
vars: {
$custom: {
foo: () => 'bar',
},
$date: {
today: () => '2023-01-01',
},
},
},
).toEqual({ a: { $dateOn: '2023-01-01+08:00' }, b: { $eq: 'bar', $ne: null } });
});
test('$date.today', async () => {
await expectParseFilter(
{
'a.$dateOn': '{{$date.today}}',
},
{
now: '2022-12-31T16:00:00.000Z',
timezone: '+08:00',
vars: {
$date: getDateVars(),
},
},
).toEqual({ a: { $dateOn: '2023-01-01+08:00' } });
});
test('$user', async () => {
await expectParseFilter(
{
'user.id.$eq': '{{$user.id}}',
'team.id.$eq': '{{$user.team.id}}',
'team.name.$eq': '{{$user.team.name}}',
},
{
vars: {
$user: async (fields) => {
return {
id: 1,
team: {
id: 2,
},
};
},
},
},
).toEqual({ user: { id: { $eq: 1 } }, team: { id: { $eq: 2 }, name: { $eq: null } } });
});
test('$user', async () => {
await expectParseFilter(
{
'user.id.$eq': '{{$user.id}}',
'team.id.$eq': '{{$user.team.id}}',
'team.name.$eq': '{{$user.team.name}}',
},
{
vars: {
$user: async (fields) => {
return;
},
},
},
).toEqual({ user: { id: { $eq: null } }, team: { id: { $eq: null }, name: { $eq: null } } });
});
test('$user', async () => {
const date = new Date();
await expectParseFilter(
{
'createdAt.$eq': '{{$user.team.createdAt}}',
},
{
vars: {
$user: async (fields) => {
return {
team: {
createdAt: date,
},
};
},
},
},
).toEqual({ createdAt: { $eq: date } });
});
test('$dateOn', async () => {
const date = new Date();
await expectParseFilter(
{
'createdAt.$dateOn': '{{$user.team.createdAt}}',
},
{
vars: {
$user: async (fields) => {
return {
team: {
createdAt: date,
},
};
},
},
},
).toEqual({ createdAt: { $dateOn: date.toISOString() } });
});
});

View File

@ -1,9 +1,10 @@
export * from './collections-graph';
export * from './common';
export * from './date';
export * from './merge';
export * from './number';
export * from './parse-filter';
export * from './registry';
// export * from './toposort';
export * from './uid';
export * from './common';

View File

@ -1,3 +1,11 @@
export const isString = (value: any): value is string => {
return typeof value === 'string';
};
export const isArray = (value: any): value is Array<any> => {
return Array.isArray(value);
};
export const isEmpty = (value: unknown) => {
if (isPlainObject(value)) {
return Object.keys(value).length === 0;
@ -16,3 +24,20 @@ export const isPlainObject = (value) => {
const prototype = Object.getPrototypeOf(value);
return prototype === null || prototype === Object.prototype;
};
export const hasEmptyValue = (objOrArr: object | any[]) => {
let result = true;
for (const key in objOrArr) {
result = false;
if (isArray(objOrArr[key]) && objOrArr[key].length === 0) {
return true;
}
if (!objOrArr[key]) {
return true;
}
if (isPlainObject(objOrArr[key]) || isArray(objOrArr[key])) {
return hasEmptyValue(objOrArr[key]);
}
}
return result;
};

View File

@ -4,6 +4,7 @@ export interface Str2momentOptions {
gmt?: boolean;
picker?: 'year' | 'month' | 'week' | 'quarter';
utcOffset?: any;
utc?: boolean;
}
export const getDefaultFormat = (props: any) => {
@ -28,27 +29,22 @@ export const getDefaultFormat = (props: any) => {
return props['showTime'] ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD';
};
export const toGmt = (value: moment.Moment | moment.Moment[]) => {
if (!value) {
export const toGmt = (value: moment.Moment) => {
if (!value || !moment.isMoment(value)) {
return value;
}
if (Array.isArray(value)) {
return value.map((val) => `${val.format('YYYY-MM-DD')}T${val.format('HH:mm:ss.SSS')}Z`);
}
if (moment.isMoment(value)) {
return `${value.format('YYYY-MM-DD')}T${value.format('HH:mm:ss.SSS')}Z`;
}
};
export const toLocal = (value: moment.Moment | moment.Moment[]) => {
export const toLocal = (value: moment.Moment) => {
if (!value) {
return value;
}
if (Array.isArray(value)) {
return value.map((val) => val.toISOString());
return value.map((val) => val.startOf('second').toISOString());
}
if (moment.isMoment(value)) {
return value.toISOString();
return value.startOf('second').toISOString();
}
};
@ -57,10 +53,15 @@ const toMoment = (val: any, options?: Str2momentOptions) => {
return;
}
const offset = options.utcOffset || -1 * new Date().getTimezoneOffset();
const { gmt, picker, utc = true } = options;
if (!utc) {
return moment(val);
}
if (moment.isMoment(val)) {
return val.utcOffset(offset);
}
const { gmt, picker } = options;
if (gmt || picker) {
return moment(val).utcOffset(0);
}
@ -111,7 +112,7 @@ export interface Moment2strOptions {
picker?: 'year' | 'month' | 'week' | 'quarter';
}
export const moment2str = (value?: moment.Moment | moment.Moment[], options: Moment2strOptions = {}) => {
export const moment2str = (value?: moment.Moment, options: Moment2strOptions = {}) => {
const { showTime, gmt, picker } = options;
if (!value) {
return value;
@ -121,20 +122,3 @@ export const moment2str = (value?: moment.Moment | moment.Moment[], options: Mom
}
return toGmtByPicker(value, picker);
};
export const mapDateFormat = function () {
return (props: any) => {
const format = getDefaultFormat(props) as any;
const onChange = props.onChange;
return {
...props,
format: format,
value: str2moment(props.value, props),
onChange: (value: moment.Moment | moment.Moment[]) => {
if (onChange) {
onChange(moment2str(value, props));
}
},
};
};
};

View File

@ -0,0 +1,9 @@
export const forEach = (obj: any, callback: (value: any, key: string | number) => void) => {
if (Array.isArray(obj)) {
obj.forEach(callback);
} else {
Object.keys(obj).forEach((key) => {
callback(obj[key], key);
});
}
};

View File

@ -1,12 +1,16 @@
export * from './assign';
export * from './collections-graph';
export * from './common';
export * from './date';
export * from './forEach';
export * from './merge';
export * from './mixin';
export * from './mixin/AsyncEmitter';
export * from './number';
export * from './parse-date';
export * from './parse-filter';
export * from './registry';
export * from './requireModule';
export * from './toposort';
export * from './uid';
export * from './assign';
export * from './collections-graph';
export * from './common';

View File

@ -0,0 +1,213 @@
import moment from 'moment';
function parseUTC(value) {
if (moment.isDate(value) || moment.isMoment(value)) {
return {
unit: 'utc',
start: value.toISOString(),
};
}
if (value.endsWith('Z')) {
return {
unit: 'utc',
start: value,
};
}
}
function parseYear(value) {
if (/^\d\d\d\d$/.test(value)) {
return {
unit: 'year',
start: `${value}-01-01 00:00:00`,
};
}
}
function parseQuarter(value) {
if (/^\d\d\d\d\Q\d$/.test(value)) {
return {
unit: 'quarter',
start: moment(value, 'YYYY[Q]Q').format('YYYY-MM-DD HH:mm:ss'),
};
}
}
function parseWeek(value) {
if (/^\d\d\d\d[W]\d\d$/.test(value)) {
return {
unit: 'isoWeek',
start: moment(value, 'GGGG[W]W').format('YYYY-MM-DD HH:mm:ss'),
};
}
if (/^\d\d\d\d[w]\d\d$/.test(value)) {
return {
unit: 'week',
start: moment(value, 'gggg[w]w').format('YYYY-MM-DD HH:mm:ss'),
};
}
}
function parseMonth(value) {
if (/^\d\d\d\d\-\d\d$/.test(value)) {
return {
unit: 'month',
start: `${value}-01 00:00:00`,
};
}
}
function parseDay(value) {
if (/^\d\d\d\d\-\d\d\-\d\d$/.test(value)) {
return {
unit: 'day',
start: `${value} 00:00:00`,
};
}
}
function parseHour(value) {
if (/^\d\d\d\d\-\d\d\-\d\d(\T|\s)\d\d$/.test(value)) {
return {
unit: 'hour',
start: `${value}:00:00`,
};
}
}
function parseMinute(value) {
if (/^\d\d\d\d\-\d\d\-\d\d(\T|\s)\d\d\:\d\d$/.test(value)) {
return {
unit: 'minute',
start: `${value}:00`,
};
}
}
function parseSecond(value) {
if (/^\d\d\d\d\-\d\d\-\d\d(\T|\s)\d\d\:\d\d\:\d\d$/.test(value)) {
return {
unit: 'second',
start: `${value}`,
};
}
}
function parseMillisecond(value) {
if (/^\d\d\d\d\-\d\d\-\d\d(\T|\s)\d\d\:\d\d\:\d\d\.\d\d\d$/.test(value)) {
return {
unit: 'millisecond',
start: `${value}`,
};
}
}
const parsers = [
parseUTC,
parseYear,
parseQuarter,
parseWeek,
parseMonth,
parseDay,
parseHour,
parseMinute,
parseSecond,
parseMillisecond,
];
type ParseDateResult = {
unit: any;
start: string;
timezone?: string;
};
function toISOString(m: moment.Moment) {
return m.toISOString();
}
function dateRange(r: ParseDateResult) {
if (!r.timezone) {
r.timezone = '+00:00';
}
let m: moment.Moment;
if (r.unit === 'utc') {
return moment(r?.start).toISOString();
} else {
m = moment(`${r?.start}${r?.timezone}`);
}
m = m.utcOffset(r.timezone);
return [m.startOf(r.unit), m.clone().add(1, r.unit).startOf(r.unit)].map(toISOString);
}
export function parseDate(value: any, options = {} as { timezone?: string }) {
if (!value) {
return;
}
if (Array.isArray(value)) {
return parseDateBetween(value, options);
}
let timezone = options.timezone || '+00:00';
const input = value;
if (typeof value === 'string') {
const match = /(.+)((\+|\-)\d\d\:\d\d)$/.exec(value);
if (match) {
value = match[1];
timezone = match[2];
}
if (/^(\(|\[)/.test(value)) {
return parseDateBetween(input, options);
}
}
for (const parse of parsers) {
const r = parse(value);
if (r) {
r['input'] = input;
if (!r['timezone']) {
r['timezone'] = timezone;
}
return dateRange(r);
}
}
}
function parseDateBetween(value: any, options = {} as { timezone?: string }) {
if (Array.isArray(value) && value.length > 1) {
const [startValue, endValue, op = '[]', timezone] = value;
const r0 = parseDate(startValue, { timezone });
const r1 = parseDate(endValue, { timezone });
let start;
let startOp;
let end;
let endOp;
if (typeof r0 === 'string') {
start = r0;
startOp = op[0];
} else {
start = op.startsWith('(') ? r0[1] : r0[0];
startOp = '[';
}
if (typeof r1 === 'string') {
end = r1;
endOp = op[1];
} else {
end = op.endsWith(')') ? r1[0] : r1[1];
endOp = ')';
}
const newOp = startOp + endOp;
return newOp === '[)' ? [start, end] : [start, end, newOp];
}
if (typeof value !== 'string') {
return;
}
const match = /(.+)((\+|\-)\d\d\:\d\d)$/.exec(value);
let timezone = options.timezone || '+00:00';
if (match) {
value = match[1];
timezone = match[2];
}
const m = /^(\(|\[)(.+)\,(.+)(\)|\])$/.exec(value);
if (!m) {
return;
}
return parseDateBetween([m[2], m[3], `${m[1]}${m[4]}`, timezone]);
}

View File

@ -0,0 +1,295 @@
import get from 'lodash/get';
import set from 'lodash/set';
import moment from 'moment';
const re = /^\s*\{\{([\s\S]*)\}\}\s*$/;
function isBuffer(obj) {
return obj && obj.constructor && typeof obj.constructor.isBuffer === 'function' && obj.constructor.isBuffer(obj);
}
function keyIdentity(key) {
return key;
}
function flatten(target, opts?: any) {
opts = opts || {};
const delimiter = opts.delimiter || '.';
const maxDepth = opts.maxDepth;
const transformKey = opts.transformKey || keyIdentity;
const transformValue = opts.transformValue || keyIdentity;
const output = {};
function step(object, prev?: any, currentDepth?: any) {
currentDepth = currentDepth || 1;
Object.keys(object).forEach(function (key) {
const value = object[key];
const isarray = opts.safe && Array.isArray(value);
const type = Object.prototype.toString.call(value);
const isbuffer = isBuffer(value);
const isobject = type === '[object Object]' || type === '[object Array]';
const newKey = prev ? prev + delimiter + transformKey(key) : transformKey(key);
if (opts.breakOn({ key })) {
output[newKey] = transformValue(value, newKey);
return;
}
if (
!isarray &&
!isbuffer &&
isobject &&
Object.keys(value).length &&
(!opts.maxDepth || currentDepth < maxDepth)
) {
return step(value, newKey, currentDepth + 1);
}
output[newKey] = transformValue(value, newKey);
});
}
step(target);
return output;
}
function unflatten(obj, opts: any = {}) {
const parsed = {};
const transformValue = opts.transformValue || keyIdentity;
Object.keys(obj).forEach((key) => {
set(parsed, key, transformValue(obj[key], key));
});
return parsed;
}
const parsePath = (path: string) => {
let operator = path.split('.').pop() || '';
if (!operator.startsWith('$')) {
operator = '';
}
return { operator };
};
const isDateOperator = (op) => {
return [
'$dateOn',
'$dateNotOn',
'$dateBefore',
'$dateAfter',
'$dateNotBefore',
'$dateNotAfter',
'$dateBetween',
].includes(op);
};
function isDate(input) {
return input instanceof Date || Object.prototype.toString.call(input) === '[object Date]';
}
const dateValueWrapper = (value: any, timezone?: string) => {
if (!value) {
return null;
}
if (Array.isArray(value)) {
if (value.length === 2) {
value.push('[]', timezone);
} else if (value.length === 3) {
value.push(timezone);
}
return value;
}
if (typeof value === 'string') {
if (!timezone || /(\+|\-)\d\d\:\d\d$/.test(value)) {
return value;
}
return value + timezone;
}
if (isDate(value)) {
return value.toISOString();
}
};
export type ParseFilterOptions = {
vars?: Record<string, any>;
now?: any;
timezone?: string;
getField?: any;
};
export const parseFilter = async (filter: any, opts: ParseFilterOptions = {}) => {
const userFieldsSet = new Set();
const vars = opts.vars || {};
const timezone = opts.timezone;
const now = opts.now;
const getField = opts.getField;
const flat = flatten(filter, {
breakOn({ key }) {
return key.startsWith('$') && key !== '$and' && key !== '$or';
},
transformValue(value) {
if (typeof value !== 'string') {
return value;
}
// parse user fields parameter
const match = re.exec(value);
if (match) {
const key = match[1].trim();
if (key.startsWith('$user')) {
userFieldsSet.add(key.substring(6));
}
}
return value;
},
});
if (userFieldsSet.size > 0) {
const $user = await vars.$user({ fields: [...userFieldsSet.values()] });
Object.assign(vars, { $user });
}
return unflatten(flat, {
transformValue(value, path) {
const { operator } = parsePath(path);
// parse string variables
if (typeof value === 'string') {
const match = re.exec(value);
if (match) {
const key = match[1].trim();
const val = get(vars, key, null);
const field = getField?.(path);
value = typeof val === 'function' ? val?.({ field, operator, timezone, now }) : val;
}
}
if (isDateOperator(operator)) {
const field = getField?.(path);
return dateValueWrapper(value, field?.timezone || timezone);
}
return value;
},
});
};
export type GetDayRangeOptions = {
now?: any;
timezone?: string;
offset: number;
};
export function getDayRange(options: GetDayRangeOptions) {
const { now, timezone = '+00:00', offset } = options;
let m = toMoment(now).utcOffset(timezone);
if (offset > 0) {
return [
// 第二天开始计算
m.add(1, 'day').startOf('day').format('YYYY-MM-DD'),
// 第九天开始前结束
m.clone().add(offset, 'day').startOf('day').format('YYYY-MM-DD'),
'[)',
timezone,
];
}
return [
// 今天开始前
m
.clone()
.subtract(-1 * offset - 1, 'day')
.startOf('day')
.format('YYYY-MM-DD'),
// 明天开始前
m.clone().add(1, 'day').startOf('day').format('YYYY-MM-DD'),
'[)',
timezone,
];
}
function toMoment(value) {
if (!value) {
return moment();
}
if (moment.isMoment(value)) {
return value;
}
return moment(value);
}
export type Utc2unitOptions = {
now?: any;
unit: any;
timezone?: string;
offset?: number;
};
export function utc2unit(options: Utc2unitOptions) {
const { now, unit, timezone = '+00:00', offset } = options;
let m = toMoment(now);
m.utcOffset(timezone);
m.startOf(unit);
if (offset > 0) {
m.add(offset, unit === 'isoWeek' ? 'week' : unit);
} else if (offset < 0) {
m.subtract(-1 * offset, unit === 'isoWeek' ? 'week' : unit);
}
const fn = {
year: () => m.format('YYYY'),
quarter: () => m.format('YYYY[Q]Q'),
month: () => m.format('YYYY-MM'),
week: () => m.format('gggg[w]ww'),
isoWeek: () => m.format('GGGG[W]WW'),
day: () => m.format('YYYY-MM-DD'),
};
const r = fn[unit]?.();
return timezone ? r + timezone : r;
}
const toUnit = (unit, offset?: number) => {
return ({ now, timezone, field }) => {
if (field?.timezone) {
timezone = field?.timezone;
}
return utc2unit({ now, timezone, unit, offset });
};
};
const toDays = (offset: number) => {
return ({ now, timezone, field }) => {
if (field?.timezone) {
timezone = field?.timezone;
}
return getDayRange({ now, timezone, offset });
};
};
export function getDateVars() {
return {
today: toUnit('day'),
yesterday: toUnit('day', -1),
tomorrow: toUnit('day', 1),
thisWeek: toUnit('week'),
lastWeek: toUnit('week', -1),
nextWeek: toUnit('week', 1),
thisIsoWeek: toUnit('isoWeek'),
lastIsoWeek: toUnit('isoWeek', -1),
nextIsoWeek: toUnit('isoWeek', 1),
thisMonth: toUnit('month'),
lastMonth: toUnit('month', -1),
nextMonth: toUnit('month', 1),
thisQuarter: toUnit('quarter'),
lastQuarter: toUnit('quarter', -1),
nextQuarter: toUnit('quarter', 1),
thisYear: toUnit('year'),
lastYear: toUnit('year', -1),
nextYear: toUnit('year', 1),
last7Days: toDays(-7),
next7Days: toDays(7),
last30Days: toDays(-30),
next30Days: toDays(30),
last90Days: toDays(-90),
next90Days: toDays(90),
};
}

View File

@ -5,23 +5,20 @@ import {
useCollection,
useCollectionFilterOptions,
useDesignable,
useSortFields,
useTableBlockContext,
} from '@nocobase/client';
import React from 'react';
import { useTranslation } from 'react-i18next';
export const AuditLogsDesigner = () => {
const { name, title, sortable } = useCollection();
const { name, title } = useCollection();
const field = useField();
const fieldSchema = useFieldSchema();
const dataSource = useCollectionFilterOptions(name);
const sortFields = useSortFields(name);
const { service } = useTableBlockContext();
const { t } = useTranslation();
const { dn } = useDesignable();
const defaultFilter = fieldSchema?.['x-decorator-props']?.params?.filter || {};
const defaultSort = fieldSchema?.['x-decorator-props']?.params?.sort || [];
return (
<GeneralSchemaDesigner title={title || name}>
<SchemaSettings.ModalItem

View File

@ -12,6 +12,21 @@
"Today": "Today",
"Month": "Month",
"Week": "Week",
"This week": "This week",
"Next week": "Next week",
"This month": "This month",
"Next month": "Next month",
"This year": "This year",
"Next year": "Next year",
"Last week": "Last week",
"Last month": "Last month",
"Last year": "Last year",
"Last 7 days": "last 7 days",
"Last 30 days": "last 30 days",
"Last 90 days": "last 90 days",
"Next 7 days": "next 7 days",
"Next 30 days": "next 30 days",
"Next 90 days": "next 90 days",
"Work week": "Work week",
"Day": "Day",
"Agenda": "Agenda",
@ -233,7 +248,6 @@
"Comparision": "Comparision",
"is": "is",
"is not": "is not",
"is variable": "is variable",
"contains": "contains",
"does not contain": "does not contain",
"starts with": "starts with",
@ -297,6 +311,7 @@
"is after": "is after",
"is on or after": "is on or after",
"is on or before": "is on or before",
"is between": "is between",
"Upload": "Upload",
"Select level": "Select level",
"Province": "Province",
@ -436,8 +451,6 @@
"Add condition group": "Add condition group",
"exists": "exists",
"not exists": "not exists",
"is current logged-in user": "is current logged-in user",
"is not current logged-in user": "is not current logged-in user",
"=": "=",
"≠": "≠",
">": ">",
@ -531,6 +544,8 @@
"Current user": "Current user",
"Current record": "Current record",
"Current time": "Current time",
"System variables": "System variables",
"Date variables": "Date variables",
"Popup close method": "Popup close method",
"Automatic close": "Automatic close",
"Manually close": "Manually close",

View File

@ -12,6 +12,21 @@
"Today": "今天",
"Month": "月",
"Week": "周",
"This week": "本周",
"Next week": "下周",
"This month": "本月",
"Next month": "下月",
"This year": "今年",
"Next year": "明年",
"Last week": "上周",
"Last month": "上月",
"Last year": "去年",
"Last 7 days": "过去 7 天",
"Last 30 days": "过去 30 天",
"Last 90 days": "过去 90 天",
"Next 7 days": "未来 7 天",
"Next 30 days": "未来 30 天",
"Next 90 days": "未来 90 天",
"Work week": "工作日",
"Day": "天",
"Agenda": "列表",
@ -233,7 +248,6 @@
"Comparision": "值比较",
"is": "等于",
"is not": "不等于",
"is variable": "为动态变量",
"contains": "包含",
"does not contain": "不包含",
"starts with": "开头是",
@ -297,6 +311,7 @@
"is after": "晚于",
"is on or after": "不早于",
"is on or before": "不晚于",
"is between": "介于",
"Upload": "上传",
"Select level": "选择层级",
"Province": "省",
@ -436,8 +451,6 @@
"Add condition group": "添加条件分组",
"exists": "存在",
"not exists": "不存在",
"is current logged-in user": "为当前登录用户",
"is not current logged-in user": "不为当前登录用户",
"=": "=",
"≠": "≠",
">": ">",
@ -531,6 +544,8 @@
"Current user": "当前用户",
"Current record": "当前记录",
"Current time": "当前时间",
"System variables": "系统变量",
"Date Variables": "日期变量",
"Popup close method": "弹窗关闭方式",
"Automatic close": "自动关闭",
"Manually close": "手动关闭",

View File

@ -0,0 +1,58 @@
import Migration from '../../migrations/20230330214649-filter-form-block';
import { Database } from '@nocobase/database';
import { mockServer, MockServer } from '@nocobase/test';
import PluginUiSchema, { UiSchemaRepository } from '../..';
describe('migration-20230330214649-filter-form-block', () => {
let app: MockServer;
let db: Database;
let uiSchemaRepository: UiSchemaRepository;
afterEach(async () => {
await app.destroy();
});
beforeEach(async () => {
app = mockServer({
registerActions: true,
});
db = app.db;
await db.clean({ drop: true });
app.plugin(PluginUiSchema, { name: 'ui-schema-storage' });
await app.loadAndInstall();
uiSchemaRepository = db.getCollection('uiSchemas').repository as UiSchemaRepository;
});
test('update x-decorator', async () => {
await uiSchemaRepository.create({
values: {
'x-uid': '78bijc1kw1q',
name: 'xbixv9hl42i',
schema: {
type: 'void',
'x-decorator': 'FormBlockProvider',
'x-decorator-props': { resource: 'tt_org', collection: 'tt_org' },
'x-designer': 'FormV2.FilterDesigner',
'x-component': 'CardItem',
'x-filter-targets': [],
'x-filter-operators': {},
},
},
});
const migration = new Migration({ db: app.db } as any);
migration.context.app = {
version: {
satisfies: async (v) => true,
},
};
await migration.up();
const instance = await uiSchemaRepository.findById('78bijc1kw1q');
expect(instance.schema['x-decorator']).toBe('FilterFormBlockProvider');
});
});

View File

@ -0,0 +1,26 @@
import { Migration } from '@nocobase/server';
export default class extends Migration {
async up() {
const result = await this.app.version.satisfies('<0.9.1-alpha.3');
if (!result) {
return;
}
const r = this.db.getRepository('uiSchemas');
const items = await r.find({
filter: {
'schema.x-designer': 'FormV2.FilterDesigner',
},
});
await this.db.sequelize.transaction(async (transaction) => {
for (const item of items) {
const schema = item.schema;
const decorator = schema['x-decorator'];
schema['x-decorator'] = 'FilterFormBlockProvider';
item.set('schema', schema);
console.log(item['x-uid'], decorator, schema['x-decorator']);
await item.save({ transaction });
}
});
}
}

View File

@ -1,7 +1,7 @@
import { MagicAttributeModel } from '@nocobase/database';
import { Plugin } from '@nocobase/server';
import { uid } from '@nocobase/utils';
import { resolve } from 'path';
import path, { resolve } from 'path';
import { uiSchemaActions } from './actions/ui-schema-action';
import { UiSchemaModel } from './model';
import UiSchemaRepository from './repository';
@ -89,6 +89,14 @@ export class UiSchemaStoragePlugin extends Plugin {
}
async load() {
this.db.addMigrations({
namespace: 'collection-manager',
directory: path.resolve(__dirname, './migrations'),
context: {
plugin: this,
},
});
await this.importCollections(resolve(__dirname, 'collections'));
}
}

View File

@ -1,10 +1,9 @@
import parse from 'json-templates';
import { resolve } from 'path';
import { Collection, Op } from '@nocobase/database';
import { HandlerType } from '@nocobase/resourcer';
import { Plugin } from '@nocobase/server';
import { Registry } from '@nocobase/utils';
import parse from 'json-templates';
import { resolve } from 'path';
import { namespace } from './';
import * as actions from './actions/users';

View File

@ -60,6 +60,7 @@ export default {
'Arithmetic calculation': '算术运算',
'String operation': '字符串',
'System variables': '系统变量',
'Date variables': '日期变量',
'Current time': '当前时间',
'Executed at': '执行于',

View File

@ -1,15 +1,14 @@
import { useCollectionManager, useCompile } from "@nocobase/client";
import { instructions, useAvailableUpstreams, useNodeContext } from "./nodes";
import { useFlowContext } from "./FlowContext";
import { triggers } from "./triggers";
import { NAMESPACE } from "./locale";
import { useCollectionManager, useCompile } from '@nocobase/client';
import { useFlowContext } from './FlowContext';
import { NAMESPACE } from './locale';
import { instructions, useAvailableUpstreams, useNodeContext } from './nodes';
import { triggers } from './triggers';
export type VariableOption = {
key: string;
value: string;
label: string;
children?: VariableOption[]
children?: VariableOption[];
};
const VariableTypes = [
@ -51,18 +50,18 @@ const VariableTypes = [
{
key: 'now',
value: 'now',
label: `{{t("Current time", { ns: "${NAMESPACE}" })}}`,
}
]
}
label: `{{t("Now")}}`,
},
],
},
];
export const TypeSets = {
boolean: new Set(['boolean']),
number: new Set(['integer', 'bigInt', 'float', 'double', 'real', 'decimal']),
string: new Set(['string', 'text', 'password']),
date: new Set(['date', 'time'])
}
date: new Set(['date', 'time']),
};
function matchFieldType(field, type): Boolean {
if (typeof type === 'string') {
@ -70,17 +69,17 @@ function matchFieldType(field, type): Boolean {
}
if (typeof type === 'object' && type.type === 'reference') {
return (field.collectionName === type.options?.collection && field.name === 'id')
|| (field.type === 'belongsTo' && field.target === type.options?.collection);
return (
(field.collectionName === type.options?.collection && field.name === 'id') ||
(field.type === 'belongsTo' && field.target === type.options?.collection)
);
}
return false;
}
export function filterTypedFields(fields, types) {
return types
? fields.filter(field => types.some(type => matchFieldType(field, type)))
: fields;
return types ? fields.filter((field) => types.some((type) => matchFieldType(field, type))) : fields;
}
export function useWorkflowVariableOptions() {
@ -92,7 +91,7 @@ export function useWorkflowVariableOptions() {
value: item.value,
key: item.value,
children: compile(options),
disabled: options && !options.length
disabled: options && !options.length,
};
});
return options;
@ -102,14 +101,15 @@ export function useCollectionFieldOptions(props) {
const { fields, collection, types } = props;
const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
const result = filterTypedFields((fields ?? getCollectionFields(collection)), types)
.map(field => ({
const result = filterTypedFields(fields ?? getCollectionFields(collection), types).map((field) => ({
label: compile(field.uiSchema?.title || field.name),
key: field.name,
value: field.name,
children: ['linkTo', 'belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type)
? getCollectionFields(field.target)?.filter(subField => subField.interface && (!subField.target || subField.type === 'belongsTo'))
.map(subField => subField.type === 'belongsTo'
? getCollectionFields(field.target)
?.filter((subField) => subField.interface && (!subField.target || subField.type === 'belongsTo'))
.map((subField) =>
subField.type === 'belongsTo'
? {
label: `${compile(subField.uiSchema?.title || subField.name)} ID`,
key: subField.foreignKey,
@ -119,8 +119,9 @@ export function useCollectionFieldOptions(props) {
label: compile(subField.uiSchema?.title || subField.name),
key: subField.name,
value: subField.name,
})
: null
},
)
: null,
}));
return result;

View File

@ -59,6 +59,7 @@ export class ShopPlugin extends Plugin {
},
list: {
filter: {
// TODO: 该操作符已废弃,该处代码需要重构
// 由 users 插件扩展的过滤器运算符
$isCurrentUser: true,
status: {