From 098140d5111de97ff3965347cc682e247a89bee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=AB=E9=9B=A8=E6=B0=B4=E8=BF=87=E6=BB=A4=E7=9A=84?= =?UTF-8?q?=E7=A9=BA=E6=B0=94-Rairn?= <958414905@qq.com> Date: Thu, 30 Mar 2023 23:49:57 +0800 Subject: [PATCH] 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 --- .../BlockSchemaComponentProvider.tsx | 4 +- .../FilterFormBlockProvider.tsx | 11 + .../src/block-provider/FormBlockProvider.tsx | 4 +- .../core/client/src/block-provider/index.tsx | 6 +- .../Configuration/AddFieldAction.tsx | 18 + .../Configuration/EditFieldAction.tsx | 22 +- .../interfaces/properties/operators.ts | 25 +- .../src/filter-provider/FilterProvider.tsx | 5 +- packages/core/client/src/locale/en_US.ts | 26 +- packages/core/client/src/locale/ja_JP.ts | 23 +- packages/core/client/src/locale/ru_RU.ts | 23 +- packages/core/client/src/locale/tr_TR.ts | 23 +- packages/core/client/src/locale/zh_CN.ts | 27 +- .../association-select/AssociationSelect.tsx | 32 +- .../antd/calendar/Calendar.Designer.tsx | 8 +- .../antd/date-picker/DatePicker.tsx | 55 ++- .../__tests__/getDateRanges.test.ts | 120 ++++++ .../__tests__/mapDatePicker.test.ts | 220 +++++++++++ .../__tests__/mapRangePicker.test.ts | 43 +++ .../antd/date-picker/__tests__/util.test.ts | 82 ++++- .../antd/date-picker/demos/demo2.tsx | 42 ++- .../antd/date-picker/demos/demo5.tsx | 58 +++ .../antd/date-picker/demos/demo6.tsx | 62 ++++ .../antd/date-picker/demos/demo7.tsx | 62 ++++ .../antd/date-picker/index.md | 14 +- .../schema-component/antd/date-picker/util.ts | 123 ++++++- .../antd/filter/DynamicComponent.tsx | 56 +-- .../schema-component/antd/filter/Filter.tsx | 21 +- .../schema-component/antd/filter/context.ts | 2 +- .../schema-component/antd/filter/useValues.ts | 2 +- .../antd/form-v2/Form.Designer.tsx | 14 +- .../antd/kanban/Kanban.Designer.tsx | 7 +- .../src/schema-component/antd/radio/Radio.tsx | 2 +- .../antd/table-v2/FilterDynamicComponent.tsx | 197 ++++++++++ .../antd/table-v2/TableBlockDesigner.tsx | 7 +- .../antd/table-v2/TableSelectorDesigner.tsx | 7 +- .../antd/table-v2/hooks/useUserVariable.ts | 55 +++ .../schema-component/antd/variable/Input.tsx | 12 +- .../client/src/schema-initializer/utils.ts | 2 +- .../LinkageRules/DynamicComponent.tsx | 53 +-- .../src/schema-settings/SchemaSettings.tsx | 4 +- .../src/__tests__/fields/date.test.ts | 75 ++++ .../database/src/__tests__/filter.test.ts | 6 +- .../__tests__/operator/date-operator.test.ts | 342 +++++++++++++----- .../core/database/src/fields/date-field.ts | 6 +- packages/core/database/src/operators/date.ts | 132 +++++-- packages/core/server/package.json | 4 +- packages/core/server/src/helper.ts | 3 + packages/core/server/src/middlewares/index.ts | 2 +- .../server/src/middlewares/parse-variables.ts | 55 +++ .../core/utils/src/__tests__/common.test.ts | 73 ++++ .../core/utils/src/__tests__/forEach.test.ts | 21 ++ .../utils/src/__tests__/parse-date.test.ts | 193 ++++++++++ .../utils/src/__tests__/parse-filter.test.ts | 311 ++++++++++++++++ packages/core/utils/src/client.ts | 3 +- packages/core/utils/src/common.ts | 25 ++ packages/core/utils/src/date.ts | 44 +-- packages/core/utils/src/forEach.ts | 9 + packages/core/utils/src/index.ts | 10 +- packages/core/utils/src/parse-date.ts | 213 +++++++++++ packages/core/utils/src/parse-filter.ts | 295 +++++++++++++++ .../client/deplicated/AuditLogsDesigner.tsx | 5 +- packages/plugins/client/src/locale/en-US.json | 21 +- packages/plugins/client/src/locale/zh-CN.json | 21 +- .../20230330214649-filter-form-block.test.ts | 58 +++ .../20230330214649-filter-form-block.ts | 26 ++ .../plugins/ui-schema-storage/src/server.ts | 10 +- packages/plugins/users/src/server.ts | 5 +- .../workflow/src/client/locale/zh-CN.ts | 1 + .../plugins/workflow/src/client/variable.tsx | 75 ++-- .../samples/shop-actions/src/server/index.ts | 1 + 71 files changed, 3178 insertions(+), 446 deletions(-) create mode 100644 packages/core/client/src/block-provider/FilterFormBlockProvider.tsx create mode 100644 packages/core/client/src/schema-component/antd/date-picker/__tests__/getDateRanges.test.ts create mode 100644 packages/core/client/src/schema-component/antd/date-picker/__tests__/mapDatePicker.test.ts create mode 100644 packages/core/client/src/schema-component/antd/date-picker/__tests__/mapRangePicker.test.ts create mode 100644 packages/core/client/src/schema-component/antd/date-picker/demos/demo5.tsx create mode 100644 packages/core/client/src/schema-component/antd/date-picker/demos/demo6.tsx create mode 100644 packages/core/client/src/schema-component/antd/date-picker/demos/demo7.tsx create mode 100644 packages/core/client/src/schema-component/antd/table-v2/FilterDynamicComponent.tsx create mode 100644 packages/core/client/src/schema-component/antd/table-v2/hooks/useUserVariable.ts create mode 100644 packages/core/database/src/__tests__/fields/date.test.ts create mode 100644 packages/core/server/src/middlewares/parse-variables.ts create mode 100644 packages/core/utils/src/__tests__/common.test.ts create mode 100644 packages/core/utils/src/__tests__/forEach.test.ts create mode 100644 packages/core/utils/src/__tests__/parse-date.test.ts create mode 100644 packages/core/utils/src/__tests__/parse-filter.test.ts create mode 100644 packages/core/utils/src/forEach.ts create mode 100644 packages/core/utils/src/parse-date.ts create mode 100644 packages/core/utils/src/parse-filter.ts create mode 100644 packages/plugins/ui-schema-storage/src/__tests__/migrations/20230330214649-filter-form-block.test.ts create mode 100644 packages/plugins/ui-schema-storage/src/migrations/20230330214649-filter-form-block.ts diff --git a/packages/core/client/src/block-provider/BlockSchemaComponentProvider.tsx b/packages/core/client/src/block-provider/BlockSchemaComponentProvider.tsx index fe853d948..4b7b7bc70 100644 --- a/packages/core/client/src/block-provider/BlockSchemaComponentProvider.tsx +++ b/packages/core/client/src/block-provider/BlockSchemaComponentProvider.tsx @@ -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, diff --git a/packages/core/client/src/block-provider/FilterFormBlockProvider.tsx b/packages/core/client/src/block-provider/FilterFormBlockProvider.tsx new file mode 100644 index 000000000..9293d41c9 --- /dev/null +++ b/packages/core/client/src/block-provider/FilterFormBlockProvider.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { DatePickerProvider } from '../schema-component'; +import { FormBlockProvider } from './FormBlockProvider'; + +export const FilterFormBlockProvider = (props) => { + return ( + + + + ); +}; diff --git a/packages/core/client/src/block-provider/FormBlockProvider.tsx b/packages/core/client/src/block-provider/FormBlockProvider.tsx index 0f6e0e7ae..817d700d7 100644 --- a/packages/core/client/src/block-provider/FormBlockProvider.tsx +++ b/packages/core/client/src/block-provider/FormBlockProvider.tsx @@ -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, diff --git a/packages/core/client/src/block-provider/index.tsx b/packages/core/client/src/block-provider/index.tsx index 259064ecd..98216bf30 100644 --- a/packages/core/client/src/block-provider/index.tsx +++ b/packages/core/client/src/block-provider/index.tsx @@ -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'; + diff --git a/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx b/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx index 20c658489..3d6b83efa 100644 --- a/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx +++ b/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx @@ -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()}`, diff --git a/packages/core/client/src/collection-manager/Configuration/EditFieldAction.tsx b/packages/core/client/src/collection-manager/Configuration/EditFieldAction.tsx index 401a09e79..233fa3631 100644 --- a/packages/core/client/src/collection-manager/Configuration/EditFieldAction.tsx +++ b/packages/core/client/src/collection-manager/Configuration/EditFieldAction.tsx @@ -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')} { recordDataBlocks({ diff --git a/packages/core/client/src/locale/en_US.ts b/packages/core/client/src/locale/en_US.ts index a262ec961..0d5310355 100644 --- a/packages/core/client/src/locale/en_US.ts +++ b/packages/core/client/src/locale/en_US.ts @@ -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", diff --git a/packages/core/client/src/locale/ja_JP.ts b/packages/core/client/src/locale/ja_JP.ts index de94da9e9..fd2bd17b2 100644 --- a/packages/core/client/src/locale/ja_JP.ts +++ b/packages/core/client/src/locale/ja_JP.ts @@ -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": "が現在ログインしているユーザー", "=": "=", "≠": "≠", ">": ">", diff --git a/packages/core/client/src/locale/ru_RU.ts b/packages/core/client/src/locale/ru_RU.ts index e3a105844..6534b5501 100644 --- a/packages/core/client/src/locale/ru_RU.ts +++ b/packages/core/client/src/locale/ru_RU.ts @@ -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": "текущий пользователь", "=": "=", "≠": "≠", ">": ">", diff --git a/packages/core/client/src/locale/tr_TR.ts b/packages/core/client/src/locale/tr_TR.ts index dfc0ba857..f937c8008 100644 --- a/packages/core/client/src/locale/tr_TR.ts +++ b/packages/core/client/src/locale/tr_TR.ts @@ -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ı", "=": "=", "≠": "≠", ">": ">", diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index e70e20541..6eddcbbb1 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -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': '假', diff --git a/packages/core/client/src/schema-component/antd/association-select/AssociationSelect.tsx b/packages/core/client/src/schema-component/antd/association-select/AssociationSelect.tsx index 516c253de..b69eeee31 100644 --- a/packages/core/client/src/schema-component/antd/association-select/AssociationSelect.tsx +++ b/packages/core/client/src/schema-component/antd/association-select/AssociationSelect.tsx @@ -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 && ( { // 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(); 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', { diff --git a/packages/core/client/src/schema-component/antd/calendar/Calendar.Designer.tsx b/packages/core/client/src/schema-component/antd/calendar/Calendar.Designer.tsx index 436ba9070..bb8b1d3ee 100644 --- a/packages/core/client/src/schema-component/antd/calendar/Calendar.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/calendar/Calendar.Designer.tsx @@ -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; diff --git a/packages/core/client/src/schema-component/antd/date-picker/DatePicker.tsx b/packages/core/client/src/schema-component/antd/date-picker/DatePicker.tsx index e181aee0c..b3bf0dfbd 100644 --- a/packages/core/client/src/schema-component/antd/date-picker/DatePicker.tsx +++ b/packages/core/client/src/schema-component/antd/date-picker/DatePicker.tsx @@ -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 & { RangePicker?: React.FC; }; -export const DatePicker: ComposedDatePicker = connect( +const DatePickerContext = React.createContext({ 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; diff --git a/packages/core/client/src/schema-component/antd/date-picker/__tests__/getDateRanges.test.ts b/packages/core/client/src/schema-component/antd/date-picker/__tests__/getDateRanges.test.ts new file mode 100644 index 000000000..30455e561 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/date-picker/__tests__/getDateRanges.test.ts @@ -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()); + }); +}); diff --git a/packages/core/client/src/schema-component/antd/date-picker/__tests__/mapDatePicker.test.ts b/packages/core/client/src/schema-component/antd/date-picker/__tests__/mapDatePicker.test.ts new file mode 100644 index 000000000..ffe549cd3 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/date-picker/__tests__/mapDatePicker.test.ts @@ -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'); + }); +}); diff --git a/packages/core/client/src/schema-component/antd/date-picker/__tests__/mapRangePicker.test.ts b/packages/core/client/src/schema-component/antd/date-picker/__tests__/mapRangePicker.test.ts new file mode 100644 index 000000000..3a413f977 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/date-picker/__tests__/mapRangePicker.test.ts @@ -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']); + }); +}); diff --git a/packages/core/client/src/schema-component/antd/date-picker/__tests__/util.test.ts b/packages/core/client/src/schema-component/antd/date-picker/__tests__/util.test.ts index 6ea9f53ff..a395c6153 100644 --- a/packages/core/client/src/schema-component/antd/date-picker/__tests__/util.test.ts +++ b/packages/core/client/src/schema-component/antd/date-picker/__tests__/util.test.ts @@ -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 () => { diff --git a/packages/core/client/src/schema-component/antd/date-picker/demos/demo2.tsx b/packages/core/client/src/schema-component/antd/date-picker/demos/demo2.tsx index 0c4944db2..6c18a4c25 100644 --- a/packages/core/client/src/schema-component/antd/date-picker/demos/demo2.tsx +++ b/packages/core/client/src/schema-component/antd/date-picker/demos/demo2.tsx @@ -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', - fulfill: { - state: { - value: '{{$self.value}}', + '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 ( - + ); diff --git a/packages/core/client/src/schema-component/antd/date-picker/demos/demo5.tsx b/packages/core/client/src/schema-component/antd/date-picker/demos/demo5.tsx new file mode 100644 index 000000000..dd3430a25 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/date-picker/demos/demo5.tsx @@ -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 ( + + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/date-picker/demos/demo6.tsx b/packages/core/client/src/schema-component/antd/date-picker/demos/demo6.tsx new file mode 100644 index 000000000..0ecd1cbec --- /dev/null +++ b/packages/core/client/src/schema-component/antd/date-picker/demos/demo6.tsx @@ -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 ( + + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/date-picker/demos/demo7.tsx b/packages/core/client/src/schema-component/antd/date-picker/demos/demo7.tsx new file mode 100644 index 000000000..7563d2531 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/date-picker/demos/demo7.tsx @@ -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 ( + + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/date-picker/index.md b/packages/core/client/src/schema-component/antd/date-picker/index.md index 6af0a6fff..1cf18958e 100644 --- a/packages/core/client/src/schema-component/antd/date-picker/index.md +++ b/packages/core/client/src/schema-component/antd/date-picker/index.md @@ -17,10 +17,22 @@ group: -### RangePicker +### DatePicker (non-UTC) + + + +### RangePicker (GMT) +### RangePicker (non-GMT) + + + +### RangePicker (non-UTC) + + + ## API 基于 antd 的 [DatePicker](https://ant.design/components/date-picker/#API),新增了以下扩展属性,用于支持 NocoBase 的日期字段设置。 diff --git a/packages/core/client/src/schema-component/antd/date-picker/util.ts b/packages/core/client/src/schema-component/antd/date-picker/util.ts index 76d5650c3..e106ca70c 100644 --- a/packages/core/client/src/schema-component/antd/date-picker/util.ts +++ b/packages/core/client/src/schema-component/antd/date-picker/util.ts @@ -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')], + }; +}; diff --git a/packages/core/client/src/schema-component/antd/filter/DynamicComponent.tsx b/packages/core/client/src/schema-component/antd/filter/DynamicComponent.tsx index 968741756..f8b92bfbd 100644 --- a/packages/core/client/src/schema-component/antd/filter/DynamicComponent.tsx +++ b/packages/core/client/src/schema-component/antd/filter/DynamicComponent.tsx @@ -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 ( - { - 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, - }} /> ); diff --git a/packages/core/client/src/schema-component/antd/filter/Filter.tsx b/packages/core/client/src/schema-component/antd/filter/Filter.tsx index 4d6f2adde..245f283a5 100644 --- a/packages/core/client/src/schema-component/antd/filter/Filter.tsx +++ b/packages/core/client/src/schema-component/antd/filter/Filter.tsx @@ -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 (
- - - {/*
{JSON.stringify(field.value, null, 2)}
*/} -
+ + + + {/*
{JSON.stringify(field.value, null, 2)}
*/} +
+
); }); diff --git a/packages/core/client/src/schema-component/antd/filter/context.ts b/packages/core/client/src/schema-component/antd/filter/context.ts index 00d061f84..cdde8f358 100644 --- a/packages/core/client/src/schema-component/antd/filter/context.ts +++ b/packages/core/client/src/schema-component/antd/filter/context.ts @@ -7,7 +7,7 @@ export interface FilterContextProps { fieldSchema?: Schema; dynamicComponent?: any; options?: any[]; - disabled?: boolean + disabled?: boolean; } export const RemoveConditionContext = createContext(null); diff --git a/packages/core/client/src/schema-component/antd/filter/useValues.ts b/packages/core/client/src/schema-component/antd/filter/useValues.ts index a6212a3bd..44533a06f 100644 --- a/packages/core/client/src/schema-component/antd/filter/useValues.ts +++ b/packages/core/client/src/schema-component/antd/filter/useValues.ts @@ -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) { diff --git a/packages/core/client/src/schema-component/antd/form-v2/Form.Designer.tsx b/packages/core/client/src/schema-component/antd/form-v2/Form.Designer.tsx index f06e53f54..56af18047 100644 --- a/packages/core/client/src/schema-component/antd/form-v2/Form.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/form-v2/Form.Designer.tsx @@ -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; diff --git a/packages/core/client/src/schema-component/antd/kanban/Kanban.Designer.tsx b/packages/core/client/src/schema-component/antd/kanban/Kanban.Designer.tsx index cdc352799..f8b852418 100644 --- a/packages/core/client/src/schema-component/antd/kanban/Kanban.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/kanban/Kanban.Designer.tsx @@ -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; diff --git a/packages/core/client/src/schema-component/antd/radio/Radio.tsx b/packages/core/client/src/schema-component/antd/radio/Radio.tsx index 10a38ef52..bbcad25e3 100644 --- a/packages/core/client/src/schema-component/antd/radio/Radio.tsx +++ b/packages/core/client/src/schema-component/antd/radio/Radio.tsx @@ -31,7 +31,7 @@ Radio.Group = connect( if (!isValid(props.value)) { return
; } - const { options = [], value } = props; + const { value } = props; const field = useField(); const dataSource = field.dataSource || []; return ( diff --git a/packages/core/client/src/schema-component/antd/table-v2/FilterDynamicComponent.tsx b/packages/core/client/src/schema-component/antd/table-v2/FilterDynamicComponent.tsx new file mode 100644 index 000000000..0c884d4de --- /dev/null +++ b/packages/core/client/src/schema-component/antd/table-v2/FilterDynamicComponent.tsx @@ -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 ( + + {renderSchemaComponent()} + + ); +} diff --git a/packages/core/client/src/schema-component/antd/table-v2/TableBlockDesigner.tsx b/packages/core/client/src/schema-component/antd/table-v2/TableBlockDesigner.tsx index bc9907c12..bfa4c24a0 100644 --- a/packages/core/client/src/schema-component/antd/table-v2/TableBlockDesigner.tsx +++ b/packages/core/client/src/schema-component/antd/table-v2/TableBlockDesigner.tsx @@ -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; diff --git a/packages/core/client/src/schema-component/antd/table-v2/TableSelectorDesigner.tsx b/packages/core/client/src/schema-component/antd/table-v2/TableSelectorDesigner.tsx index 78aa15e8e..bfa016363 100644 --- a/packages/core/client/src/schema-component/antd/table-v2/TableSelectorDesigner.tsx +++ b/packages/core/client/src/schema-component/antd/table-v2/TableSelectorDesigner.tsx @@ -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; diff --git a/packages/core/client/src/schema-component/antd/table-v2/hooks/useUserVariable.ts b/packages/core/client/src/schema-component/antd/table-v2/hooks/useUserVariable.ts new file mode 100644 index 000000000..9cee4fb97 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/table-v2/hooks/useUserVariable.ts @@ -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, + }; +}; diff --git a/packages/core/client/src/schema-component/antd/variable/Input.tsx b/packages/core/client/src/schema-component/antd/variable/Input.tsx index e3c57043d..47723aa8d 100644 --- a/packages/core/client/src/schema-component/antd/variable/Input.tsx +++ b/packages/core/client/src/schema-component/antd/variable/Input.tsx @@ -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) diff --git a/packages/core/client/src/schema-initializer/utils.ts b/packages/core/client/src/schema-initializer/utils.ts index 344fbefb0..5f37f5def 100644 --- a/packages/core/client/src/schema-initializer/utils.ts +++ b/packages/core/client/src/schema-initializer/utils.ts @@ -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, diff --git a/packages/core/client/src/schema-settings/LinkageRules/DynamicComponent.tsx b/packages/core/client/src/schema-settings/LinkageRules/DynamicComponent.tsx index a52246201..2b5a5bfa8 100644 --- a/packages/core/client/src/schema-settings/LinkageRules/DynamicComponent.tsx +++ b/packages/core/client/src/schema-settings/LinkageRules/DynamicComponent.tsx @@ -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 ( - { - 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, - }} /> ); diff --git a/packages/core/client/src/schema-settings/SchemaSettings.tsx b/packages/core/client/src/schema-settings/SchemaSettings.tsx index 3b2172fea..c81d95989 100644 --- a/packages/core/client/src/schema-settings/SchemaSettings.tsx +++ b/packages/core/client/src/schema-settings/SchemaSettings.tsx @@ -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 ( - + diff --git a/packages/core/database/src/__tests__/fields/date.test.ts b/packages/core/database/src/__tests__/fields/date.test.ts new file mode 100644 index 000000000..79da8f198 --- /dev/null +++ b/packages/core/database/src/__tests__/fields/date.test.ts @@ -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:00,postgres 和 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', + }, + }, + }); + }); + }); +}); diff --git a/packages/core/database/src/__tests__/filter.test.ts b/packages/core/database/src/__tests__/filter.test.ts index fddf33f37..eede209d7 100644 --- a/packages/core/database/src/__tests__/filter.test.ts +++ b/packages/core/database/src/__tests__/filter.test.ts @@ -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); }); }); diff --git a/packages/core/database/src/__tests__/operator/date-operator.test.ts b/packages/core/database/src/__tests__/operator/date-operator.test.ts index 914da5ec8..537e25978 100644 --- a/packages/core/database/src/__tests__/operator/date-operator.test.ts +++ b/packages/core/database/src/__tests__/operator/date-operator.test.ts @@ -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', - name: 'u0', + 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', + }, + ], + }); + let count: number; + count = await repository.count({ + filter: { + 'date1.$dateOn': '2023', }, }); - - const user1 = await User.repository.create({ - values: { - birthday: '1990-01-01 12:03:02', - name: 'user1', + expect(count).toBe(2); + count = await repository.count({ + filter: { + 'date1.$dateOn': '2023+08:00', }, }); - - const user = await User.repository.findOne({ - filter: { 'birthday.$dateOn': '1990-01-01' }, - }); - - 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', - name: 'u0', + 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', + }, + ], + }); + let count: number; + count = await repository.count({ + filter: { + 'date1.$dateNotOn': '2023', }, }); - - const user1 = await User.repository.create({ - values: { - birthday: '1990-01-01 12:03:02', - name: 'user1', + expect(count).toBe(2); + count = await repository.count({ + filter: { + 'date1.$dateNotOn': '2023+08:00', }, }); - - const user = await User.repository.findOne({ - filter: { 'birthday.$dateNotOn': '1990-01-01' }, - }); - - 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', - name: 'u0', + 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', + }, + ], + }); + let count: number; + count = await repository.count({ + filter: { + 'date1.$dateBefore': '2023', }, }); - - const user1 = await User.repository.create({ - values: { - birthday: '1990-01-01 12:03:02', - name: 'user1', + expect(count).toBe(3); + count = await repository.count({ + filter: { + 'date1.$dateBefore': '2023+08:00', }, }); - - const user = await User.repository.findOne({ - filter: { 'birthday.$dateBefore': '1990-04-01' }, - }); - - 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', - name: 'u0', + 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', + }, + ], + }); + let count: number; + count = await repository.count({ + filter: { + 'date1.$dateNotBefore': '2023', }, }); - - const user1 = await User.repository.create({ - values: { - birthday: '1990-01-01 12:03:02', - name: 'user1', + expect(count).toBe(2); + count = await repository.count({ + filter: { + 'date1.$dateNotBefore': '2023+08:00', }, }); - - const user = await User.repository.findOne({ - filter: { 'birthday.$dateNotBefore': '1990-04-01' }, - }); - - 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', - name: 'u0', + 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', + }, + ], + }); + let count: number; + count = await repository.count({ + filter: { + 'date1.$dateAfter': '2022', }, }); - - const user1 = await User.repository.create({ - values: { - birthday: '1990-01-01 12:03:02', - name: 'user1', + expect(count).toBe(2); + count = await repository.count({ + filter: { + 'date1.$dateAfter': '2022+08:00', }, }); - - const user = await User.repository.findOne({ - filter: { 'birthday.$dateAfter': '1990-04-01' }, - }); - - 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', - name: 'u0', + 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', + }, + ], + }); + let count: number; + count = await repository.count({ + filter: { + 'date1.$dateNotAfter': '2022', }, }); - - const user1 = await User.repository.create({ - values: { - birthday: '1990-01-01 12:03:02', - name: 'user1', + expect(count).toBe(3); + count = await repository.count({ + filter: { + 'date1.$dateNotAfter': '2022+08:00', }, }); + expect(count).toBe(1); + }); - const user = await User.repository.findOne({ - filter: { 'birthday.$dateNotAfter': '1990-04-01' }, + 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', + }, + ], }); - - expect(user.get('id')).toEqual(user1.get('id')); + 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); }); }); diff --git a/packages/core/database/src/fields/date-field.ts b/packages/core/database/src/fields/date-field.ts index 5c4145aca..3a8a989bc 100644 --- a/packages/core/database/src/fields/date-field.ts +++ b/packages/core/database/src/fields/date-field.ts @@ -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; } } diff --git a/packages/core/database/src/operators/date.ts b/packages/core/database/src/operators/date.ts index 69fbe051d..7d0db6219 100644 --- a/packages/core/database/src/operators/date.ts +++ b/packages/core/database/src/operators/date.ts @@ -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()); - return { - [Op.and]: [{ [Op.gte]: stringToDate(value) }, { [Op.lt]: getNextDay(value) }], - }; + const r = parseDate(value, { + timezone: ctx.db.options.timezone, + }); + if (typeof r === 'string') { + return { + [Op.eq]: toDate(r), + }; + } + if (Array.isArray(r)) { + return { + [Op.and]: [{ [Op.gte]: toDate(r[0]) }, { [Op.lt]: toDate(r[1]) }], + }; + } + throw new Error(`Invalid Date ${JSON.stringify(value)}`); }, - $dateNotOn(value) { - return { - [Op.or]: [{ [Op.lt]: stringToDate(value) }, { [Op.gte]: getNextDay(value) }], - }; + $dateNotOn(value, ctx) { + const r = parseDate(value, { + timezone: ctx.db.options.timezone, + }); + if (typeof r === 'string') { + return { + [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)}`); }, - $dateBefore(value) { - return { [Op.lt]: stringToDate(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)}`); }, - $dateNotBefore(value) { - return { - [Op.gte]: stringToDate(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) { - return { [Op.gte]: getNextDay(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) { - return { [Op.lt]: getNextDay(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; diff --git a/packages/core/server/package.json b/packages/core/server/package.json index 2a4344fa5..066cfcf2b 100644 --- a/packages/core/server/package.json +++ b/packages/core/server/package.json @@ -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" diff --git a/packages/core/server/src/helper.ts b/packages/core/server/src/helper.ts index f7f751139..48f0e1f04 100644 --- a/packages/core/server/src/helper.ts +++ b/packages/core/server/src/helper.ts @@ -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' }); } diff --git a/packages/core/server/src/middlewares/index.ts b/packages/core/server/src/middlewares/index.ts index cc04804e8..f76c96b69 100644 --- a/packages/core/server/src/middlewares/index.ts +++ b/packages/core/server/src/middlewares/index.ts @@ -1,3 +1,3 @@ export * from './data-wrapping'; export * from './db2resource'; - +export { parseVariables } from './parse-variables'; diff --git a/packages/core/server/src/middlewares/parse-variables.ts b/packages/core/server/src/middlewares/parse-variables.ts new file mode 100644 index 000000000..6e4cd323b --- /dev/null +++ b/packages/core/server/src/middlewares/parse-variables.ts @@ -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(); +}; diff --git a/packages/core/utils/src/__tests__/common.test.ts b/packages/core/utils/src/__tests__/common.test.ts new file mode 100644 index 000000000..e91e3c62f --- /dev/null +++ b/packages/core/utils/src/__tests__/common.test.ts @@ -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); + }); +}); diff --git a/packages/core/utils/src/__tests__/forEach.test.ts b/packages/core/utils/src/__tests__/forEach.test.ts new file mode 100644 index 000000000..3fab7a592 --- /dev/null +++ b/packages/core/utils/src/__tests__/forEach.test.ts @@ -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]); + }); +}); diff --git a/packages/core/utils/src/__tests__/parse-date.test.ts b/packages/core/utils/src/__tests__/parse-date.test.ts new file mode 100644 index 000000000..f2d935ec0 --- /dev/null +++ b/packages/core/utils/src/__tests__/parse-date.test.ts @@ -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', + '(]' + ]); + }); + }); +}); diff --git a/packages/core/utils/src/__tests__/parse-filter.test.ts b/packages/core/utils/src/__tests__/parse-filter.test.ts new file mode 100644 index 000000000..50e899663 --- /dev/null +++ b/packages/core/utils/src/__tests__/parse-filter.test.ts @@ -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() } }); + }); +}); diff --git a/packages/core/utils/src/client.ts b/packages/core/utils/src/client.ts index 79e68c6e9..2760cf81d 100644 --- a/packages/core/utils/src/client.ts +++ b/packages/core/utils/src/client.ts @@ -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'; diff --git a/packages/core/utils/src/common.ts b/packages/core/utils/src/common.ts index 1d14e8d23..efa216a81 100644 --- a/packages/core/utils/src/common.ts +++ b/packages/core/utils/src/common.ts @@ -1,3 +1,11 @@ +export const isString = (value: any): value is string => { + return typeof value === 'string'; +}; + +export const isArray = (value: any): value is Array => { + 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; +}; diff --git a/packages/core/utils/src/date.ts b/packages/core/utils/src/date.ts index a6747cf3c..d142a66c8 100644 --- a/packages/core/utils/src/date.ts +++ b/packages/core/utils/src/date.ts @@ -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`; - } + 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)); - } - }, - }; - }; -}; diff --git a/packages/core/utils/src/forEach.ts b/packages/core/utils/src/forEach.ts new file mode 100644 index 000000000..13feac09c --- /dev/null +++ b/packages/core/utils/src/forEach.ts @@ -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); + }); + } +}; diff --git a/packages/core/utils/src/index.ts b/packages/core/utils/src/index.ts index c5c3969bc..8e6f76968 100644 --- a/packages/core/utils/src/index.ts +++ b/packages/core/utils/src/index.ts @@ -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'; + diff --git a/packages/core/utils/src/parse-date.ts b/packages/core/utils/src/parse-date.ts new file mode 100644 index 000000000..1200f9f44 --- /dev/null +++ b/packages/core/utils/src/parse-date.ts @@ -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]); +} diff --git a/packages/core/utils/src/parse-filter.ts b/packages/core/utils/src/parse-filter.ts new file mode 100644 index 000000000..e9bea1ef4 --- /dev/null +++ b/packages/core/utils/src/parse-filter.ts @@ -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; + 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), + }; +} diff --git a/packages/plugins/audit-logs/src/client/deplicated/AuditLogsDesigner.tsx b/packages/plugins/audit-logs/src/client/deplicated/AuditLogsDesigner.tsx index 9a7daa8f9..abaef30b4 100644 --- a/packages/plugins/audit-logs/src/client/deplicated/AuditLogsDesigner.tsx +++ b/packages/plugins/audit-logs/src/client/deplicated/AuditLogsDesigner.tsx @@ -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 ( ": ">", @@ -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", diff --git a/packages/plugins/client/src/locale/zh-CN.json b/packages/plugins/client/src/locale/zh-CN.json index 3b96e1c96..a6a641c74 100644 --- a/packages/plugins/client/src/locale/zh-CN.json +++ b/packages/plugins/client/src/locale/zh-CN.json @@ -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": "手动关闭", diff --git a/packages/plugins/ui-schema-storage/src/__tests__/migrations/20230330214649-filter-form-block.test.ts b/packages/plugins/ui-schema-storage/src/__tests__/migrations/20230330214649-filter-form-block.test.ts new file mode 100644 index 000000000..9977d88b9 --- /dev/null +++ b/packages/plugins/ui-schema-storage/src/__tests__/migrations/20230330214649-filter-form-block.test.ts @@ -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'); + }); +}); diff --git a/packages/plugins/ui-schema-storage/src/migrations/20230330214649-filter-form-block.ts b/packages/plugins/ui-schema-storage/src/migrations/20230330214649-filter-form-block.ts new file mode 100644 index 000000000..1f22588d2 --- /dev/null +++ b/packages/plugins/ui-schema-storage/src/migrations/20230330214649-filter-form-block.ts @@ -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 }); + } + }); + } +} diff --git a/packages/plugins/ui-schema-storage/src/server.ts b/packages/plugins/ui-schema-storage/src/server.ts index 617bb8a08..8045cbfb5 100644 --- a/packages/plugins/ui-schema-storage/src/server.ts +++ b/packages/plugins/ui-schema-storage/src/server.ts @@ -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')); } } diff --git a/packages/plugins/users/src/server.ts b/packages/plugins/users/src/server.ts index 9335345e3..a1ccdbfb6 100644 --- a/packages/plugins/users/src/server.ts +++ b/packages/plugins/users/src/server.ts @@ -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'; diff --git a/packages/plugins/workflow/src/client/locale/zh-CN.ts b/packages/plugins/workflow/src/client/locale/zh-CN.ts index a45789f89..d0034b897 100644 --- a/packages/plugins/workflow/src/client/locale/zh-CN.ts +++ b/packages/plugins/workflow/src/client/locale/zh-CN.ts @@ -60,6 +60,7 @@ export default { 'Arithmetic calculation': '算术运算', 'String operation': '字符串', 'System variables': '系统变量', + 'Date variables': '日期变量', 'Current time': '当前时间', 'Executed at': '执行于', diff --git a/packages/plugins/workflow/src/client/variable.tsx b/packages/plugins/workflow/src/client/variable.tsx index 0dc9a71a2..9abc5b09d 100644 --- a/packages/plugins/workflow/src/client/variable.tsx +++ b/packages/plugins/workflow/src/client/variable.tsx @@ -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,26 +101,28 @@ export function useCollectionFieldOptions(props) { const { fields, collection, types } = props; const compile = useCompile(); const { getCollectionFields } = useCollectionManager(); - 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' + 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' ? { - label: `${compile(subField.uiSchema?.title || subField.name)} ID`, - key: subField.foreignKey, - value: subField.foreignKey, - } + label: `${compile(subField.uiSchema?.title || subField.name)} ID`, + key: subField.foreignKey, + value: subField.foreignKey, + } : { - label: compile(subField.uiSchema?.title || subField.name), - key: subField.name, - value: subField.name, - }) - : null - })); + label: compile(subField.uiSchema?.title || subField.name), + key: subField.name, + value: subField.name, + }, + ) + : null, + })); return result; } diff --git a/packages/samples/shop-actions/src/server/index.ts b/packages/samples/shop-actions/src/server/index.ts index 99d012192..930db3527 100644 --- a/packages/samples/shop-actions/src/server/index.ts +++ b/packages/samples/shop-actions/src/server/index.ts @@ -59,6 +59,7 @@ export class ShopPlugin extends Plugin { }, list: { filter: { + // TODO: 该操作符已废弃,该处代码需要重构 // 由 users 插件扩展的过滤器运算符 $isCurrentUser: true, status: {