From e232ed75827cd76778ce796f04e60f58ada581a9 Mon Sep 17 00:00:00 2001 From: Katherine Date: Fri, 16 Aug 2024 16:50:51 +0800 Subject: [PATCH] refactor: datetime field support timezone, defaultToCurrentTime, and onUpdateToCurrentTime (#5012) * refactor: date field support timezone, defaultToCurrentTime, and onUpdateToCurrentTime * refactor: availableTypes unixTimestamp * chore: defaultToCurrentTime * chore: unix timestamp field * fix: bug * chore: field type map * refactor: local improve * fix: bug * fix: bug * chore: timezone test * chore: test * fix: test * fix: test * chore: field setter * chore: test * chore: date only field * chore: test * chore: test * fix: bug * fix: unixTimestamp * fix: unixTimestamp * chore: accuracy * fix: bug * fix: bug * fix: unixTimestamp * fix: unixTimestamp * fix: date & datetime * refactor: add DateFieldInterface * fix: bug * chore: test * chore: test * chore: test * refactor: locale improve * refactor: local improve * fix: bug * refactor: unixTimestamp not support default value --------- Co-authored-by: Chareice --- .../collection-manager/collectionPlugin.ts | 2 + .../interfaces/components/index.tsx | 38 ++++- .../src/collection-manager/interfaces/date.ts | 60 ++++++++ .../collection-manager/interfaces/datetime.ts | 3 + .../collection-manager/interfaces/index.ts | 1 + .../interfaces/properties/index.ts | 44 +++++- .../interfaces/unixTimestamp.tsx | 54 ++++++- packages/core/client/src/locale/zh-CN.json | 7 +- .../schema-component/antd/date-picker/util.ts | 9 +- .../antd/unix-timestamp/UnixTimestamp.tsx | 41 +----- .../__tests__/UnixTimestamp.test.tsx | 78 +--------- .../src/__tests__/fields/date-only.test.ts | 42 ++++++ .../src/__tests__/fields/date.test.ts | 137 +++++++++++++++++- .../fields/unix-timestamp-field.tests.ts | 86 +++++++++++ packages/core/database/src/database.ts | 8 +- .../core/database/src/fields/date-field.ts | 68 ++++++++- .../database/src/fields/date-only-field.ts | 21 +++ packages/core/database/src/fields/field.ts | 8 +- packages/core/database/src/fields/index.ts | 6 + .../src/fields/unix-timestamp-field.ts | 60 ++++++++ packages/core/database/src/model.ts | 20 ++- packages/core/database/src/repository.ts | 4 +- .../core/database/src/view/field-type-map.ts | 14 +- packages/core/server/src/application.ts | 21 +-- 24 files changed, 674 insertions(+), 158 deletions(-) create mode 100644 packages/core/client/src/collection-manager/interfaces/date.ts create mode 100644 packages/core/database/src/__tests__/fields/date-only.test.ts create mode 100644 packages/core/database/src/__tests__/fields/unix-timestamp-field.tests.ts create mode 100644 packages/core/database/src/fields/date-only-field.ts create mode 100644 packages/core/database/src/fields/unix-timestamp-field.ts diff --git a/packages/core/client/src/collection-manager/collectionPlugin.ts b/packages/core/client/src/collection-manager/collectionPlugin.ts index 8d71266aaa..270393cf9d 100644 --- a/packages/core/client/src/collection-manager/collectionPlugin.ts +++ b/packages/core/client/src/collection-manager/collectionPlugin.ts @@ -52,6 +52,7 @@ import { UUIDFieldInterface, NanoidFieldInterface, UnixTimestampFieldInterface, + DateFieldInterface, } from './interfaces'; import { GeneralCollectionTemplate, @@ -173,6 +174,7 @@ export class CollectionPlugin extends Plugin { UUIDFieldInterface, NanoidFieldInterface, UnixTimestampFieldInterface, + DateFieldInterface, ]); } diff --git a/packages/core/client/src/collection-manager/interfaces/components/index.tsx b/packages/core/client/src/collection-manager/interfaces/components/index.tsx index ccb5915a60..2fbd571127 100644 --- a/packages/core/client/src/collection-manager/interfaces/components/index.tsx +++ b/packages/core/client/src/collection-manager/interfaces/components/index.tsx @@ -7,8 +7,8 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { Switch } from 'antd'; -import React from 'react'; +import { Switch, Radio, Input } from 'antd'; +import React, { useEffect, useState } from 'react'; export const TargetKey = () => { return
Target key
; @@ -50,3 +50,37 @@ export const ForeignKey2 = () => { ); }; + +// 自定义 Radio 组件 +export const CustomRadio = (props) => { + const { options, onChange } = props; + const [value, setValue] = useState(props.value); + useEffect(() => { + setValue(['server', 'client'].includes(props.value) ? props.value : 'custom'); + }, [props.value]); + const handleRadioChange = (e) => { + setValue(e.target.value); + if (e.target.value !== 'custom') { + onChange?.(e.target.value); + } + }; + + return ( + + {options.map((option) => ( + + {option.label} + {option.value === 'custom' && value === 'custom' ? ( + { + onChange?.(e.target.value); + }} + value={['server', 'client', 'custom'].includes(props.value) ? null : props.value} + /> + ) : null} + + ))} + + ); +}; diff --git a/packages/core/client/src/collection-manager/interfaces/date.ts b/packages/core/client/src/collection-manager/interfaces/date.ts new file mode 100644 index 0000000000..59d23be7fa --- /dev/null +++ b/packages/core/client/src/collection-manager/interfaces/date.ts @@ -0,0 +1,60 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface'; +import { dateTimeProps, defaultProps, operators } from './properties'; + +export class DateFieldInterface extends CollectionFieldInterface { + name = 'date'; + type = 'object'; + group = 'datetime'; + order = 1; + title = '{{t("Date")}}'; + sortable = true; + default = { + type: 'dateOnly', + uiSchema: { + type: 'string', + 'x-component': 'DatePicker', + 'x-component-props': { + dateOnly: true, + }, + }, + }; + availableTypes = ['date', 'dateOnly', 'string']; + hasDefaultValue = true; + properties = { + ...defaultProps, + 'uiSchema.x-component-props.dateFormat': { + type: 'string', + title: '{{t("Date format")}}', + 'x-component': 'Radio.Group', + 'x-decorator': 'FormItem', + default: 'YYYY-MM-DD', + enum: [ + { + label: '{{t("Year/Month/Day")}}', + value: 'YYYY/MM/DD', + }, + { + label: '{{t("Year-Month-Day")}}', + value: 'YYYY-MM-DD', + }, + { + label: '{{t("Day/Month/Year")}}', + value: 'DD/MM/YYYY', + }, + ], + }, + }; + filterable = { + operators: operators.datetime, + }; + titleUsable = true; +} diff --git a/packages/core/client/src/collection-manager/interfaces/datetime.ts b/packages/core/client/src/collection-manager/interfaces/datetime.ts index 615d11e9ea..3492a87b6a 100644 --- a/packages/core/client/src/collection-manager/interfaces/datetime.ts +++ b/packages/core/client/src/collection-manager/interfaces/datetime.ts @@ -19,6 +19,9 @@ export class DatetimeFieldInterface extends CollectionFieldInterface { sortable = true; default = { type: 'date', + defaultToCurrentTime: false, + onUpdateToCurrentTime: false, + timezone: 'server', uiSchema: { type: 'string', 'x-component': 'DatePicker', diff --git a/packages/core/client/src/collection-manager/interfaces/index.ts b/packages/core/client/src/collection-manager/interfaces/index.ts index 6778d83413..663f55ac2d 100644 --- a/packages/core/client/src/collection-manager/interfaces/index.ts +++ b/packages/core/client/src/collection-manager/interfaces/index.ts @@ -46,3 +46,4 @@ export * from './sort'; export * from './uuid'; export * from './nanoid'; export * from './unixTimestamp'; +export * from './date'; diff --git a/packages/core/client/src/collection-manager/interfaces/properties/index.ts b/packages/core/client/src/collection-manager/interfaces/properties/index.ts index 1ef36e4b61..df63fd215b 100644 --- a/packages/core/client/src/collection-manager/interfaces/properties/index.ts +++ b/packages/core/client/src/collection-manager/interfaces/properties/index.ts @@ -10,6 +10,7 @@ import { Field } from '@formily/core'; import { ISchema } from '@formily/react'; import { uid } from '@formily/shared'; +import { CustomRadio } from '../components'; export * as operators from './operators'; export const type: ISchema = { @@ -225,6 +226,29 @@ export const reverseFieldProperties: Record = { }; export const dateTimeProps: { [key: string]: ISchema } = { + timezone: { + type: 'string', + title: '{{t("Timezone")}}', + 'x-component': CustomRadio, + 'x-decorator': 'FormItem', + default: 'server', + 'x-component-props': { + options: [ + { + label: '{{t("None")}}', + value: 'server', + }, + { + label: '{{t("Client\'s time zone")}}', + value: 'client', + }, + { + label: '{{t("Custom")}}', + value: 'custom', + }, + ], + }, + }, 'uiSchema.x-component-props.dateFormat': { type: 'string', title: '{{t("Date format")}}', @@ -253,10 +277,10 @@ export const dateTimeProps: { [key: string]: ISchema } = { 'x-content': '{{t("Show time")}}', 'x-reactions': [ `{{(field) => { - field.query('..[].timeFormat').take(f => { - f.display = field.value ? 'visible' : 'none'; - }); - }}}`, + field.query('..[].timeFormat').take(f => { + f.display = field.value ? 'visible' : 'none'; + }); + }}}`, ], }, 'uiSchema.x-component-props.timeFormat': { @@ -276,6 +300,18 @@ export const dateTimeProps: { [key: string]: ISchema } = { }, ], }, + defaultToCurrentTime: { + type: 'boolean', + 'x-decorator': 'FormItem', + 'x-component': 'Checkbox', + 'x-content': '{{t("Default value to current time")}}', + }, + onUpdateToCurrentTime: { + type: 'boolean', + 'x-decorator': 'FormItem', + 'x-component': 'Checkbox', + 'x-content': '{{t("Automatically update timestamp on update")}}', + }, }; export const dataSource: ISchema = { diff --git a/packages/core/client/src/collection-manager/interfaces/unixTimestamp.tsx b/packages/core/client/src/collection-manager/interfaces/unixTimestamp.tsx index 47b3ebc2cf..8e9db2b6d1 100644 --- a/packages/core/client/src/collection-manager/interfaces/unixTimestamp.tsx +++ b/packages/core/client/src/collection-manager/interfaces/unixTimestamp.tsx @@ -8,8 +8,8 @@ */ import { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface'; -import { dateTimeProps, defaultProps, operators } from './properties'; - +import { defaultProps, operators } from './properties'; +import { CustomRadio } from './components'; export class UnixTimestampFieldInterface extends CollectionFieldInterface { name = 'unixTimestamp'; type = 'object'; @@ -18,21 +18,47 @@ export class UnixTimestampFieldInterface extends CollectionFieldInterface { title = '{{t("Unix Timestamp")}}'; sortable = true; default = { - type: 'bigInt', + type: 'unixTimestamp', + accuracy: 'second', + timezone: 'server', + defaultToCurrentTime: false, + onUpdateToCurrentTime: false, uiSchema: { type: 'number', 'x-component': 'UnixTimestamp', 'x-component-props': { - accuracy: 'second', showTime: true, }, }, }; - availableTypes = ['integer', 'bigInt']; - hasDefaultValue = true; + availableTypes = ['integer', 'bigInt', 'unixTimestamp']; + hasDefaultValue = false; properties = { ...defaultProps, - 'uiSchema.x-component-props.accuracy': { + timezone: { + type: 'string', + title: '{{t("Timezone")}}', + 'x-component': CustomRadio, + 'x-decorator': 'FormItem', + default: 'server', + 'x-component-props': { + options: [ + { + label: '{{t("None")}}', + value: 'server', + }, + { + label: '{{t("Client\'s time zone")}}', + value: 'client', + }, + { + label: 'custom', + value: 'custom', + }, + ], + }, + }, + accuracy: { type: 'string', title: '{{t("Accuracy")}}', 'x-component': 'Radio.Group', @@ -43,6 +69,20 @@ export class UnixTimestampFieldInterface extends CollectionFieldInterface { { value: 'second', label: '{{t("Second")}}' }, ], }, + defaultToCurrentTime: { + type: 'boolean', + 'x-decorator': 'FormItem', + 'x-component': 'Checkbox', + 'x-content': '{{t("Default value to current time")}}', + default: true, + }, + onUpdateToCurrentTime: { + type: 'boolean', + 'x-decorator': 'FormItem', + 'x-component': 'Checkbox', + 'x-content': '{{t("Automatically update timestamp on update")}}', + default: true, + }, }; filterable = { operators: operators.number, diff --git a/packages/core/client/src/locale/zh-CN.json b/packages/core/client/src/locale/zh-CN.json index 9535a2dbf2..84c45b1c84 100644 --- a/packages/core/client/src/locale/zh-CN.json +++ b/packages/core/client/src/locale/zh-CN.json @@ -283,7 +283,7 @@ "Checkbox group": "复选框", "China region": "中国行政区", "Date & Time": "日期 & 时间", - "Datetime": "日期", + "Datetime": "日期时间", "Relation": "关系类型", "Link to": "关联", "Link to description": "用于快速创建表关系,可兼容大多数普通场景。适合非开发人员使用。作为字段存在时,它是一个下拉选择用于选择目标数据表的数据。创建后,将同时在目标数据表中生成当前数据表的关联字段。", @@ -967,5 +967,8 @@ "Clear default value": "清除默认值", "Open in new window": "新窗口打开", "Sorry, the page you visited does not exist.": "抱歉,你访问的页面不存在。", - "Set Template Engine": "设置模板引擎" + "Set Template Engine": "设置模板引擎", + "Default value to current time":"设置字段默认值为当前时间", + "Automatically update timestamp on update":"当记录更新时自动设置字段值为当前时间", + "Client's time zone":"客户端时区" } 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 59026b3efb..9c80b91a28 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 @@ -78,17 +78,20 @@ 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: Dayjs | null) => { + onChange: (value: Dayjs | null, dateString) => { if (onChange) { if (!props.showTime && value) { value = value.startOf('day'); } - onChange(moment2str(value, props)); + if (props.dateOnly) { + onChange(dateString); + } else { + onChange(moment2str(value, props)); + } } }, }; diff --git a/packages/core/client/src/schema-component/antd/unix-timestamp/UnixTimestamp.tsx b/packages/core/client/src/schema-component/antd/unix-timestamp/UnixTimestamp.tsx index 5d19a1e118..e83f9d00bc 100644 --- a/packages/core/client/src/schema-component/antd/unix-timestamp/UnixTimestamp.tsx +++ b/packages/core/client/src/schema-component/antd/unix-timestamp/UnixTimestamp.tsx @@ -8,57 +8,32 @@ */ import { connect, mapReadPretty } from '@formily/react'; -import React, { useMemo } from 'react'; +import React from 'react'; import { DatePicker } from '../date-picker'; -import dayjs from 'dayjs'; - -const toValue = (value: any, accuracy) => { - if (value) { - return timestampToDate(value, accuracy); - } - return null; -}; - -function timestampToDate(timestamp, accuracy = 'millisecond') { - if (accuracy === 'second') { - timestamp *= 1000; // 如果精确度是秒级,则将时间戳乘以1000转换为毫秒级 - } - return dayjs(timestamp); -} - -function getTimestamp(date, accuracy = 'millisecond') { - if (accuracy === 'second') { - return dayjs(date).unix(); - } else { - return dayjs(date).valueOf(); // 默认返回毫秒级时间戳 - } -} interface UnixTimestampProps { - value?: number; - accuracy?: 'millisecond' | 'second'; + value?: any; onChange?: (value: number) => void; } export const UnixTimestamp = connect( (props: UnixTimestampProps) => { - const { value, onChange, accuracy = 'second' } = props; - const v = useMemo(() => toValue(value, accuracy), [value, accuracy]); + const { value, onChange } = props; + return ( { if (onChange) { - onChange(getTimestamp(v, accuracy)); + onChange(v); } }} /> ); }, mapReadPretty((props) => { - const { value, accuracy = 'second' } = props; - const v = useMemo(() => toValue(value, accuracy), [value, accuracy]); - return ; + const { value } = props; + return ; }), ); diff --git a/packages/core/client/src/schema-component/antd/unix-timestamp/__tests__/UnixTimestamp.test.tsx b/packages/core/client/src/schema-component/antd/unix-timestamp/__tests__/UnixTimestamp.test.tsx index ba82c73147..c9c107f45c 100644 --- a/packages/core/client/src/schema-component/antd/unix-timestamp/__tests__/UnixTimestamp.test.tsx +++ b/packages/core/client/src/schema-component/antd/unix-timestamp/__tests__/UnixTimestamp.test.tsx @@ -13,11 +13,9 @@ import { UnixTimestamp } from '@nocobase/client'; describe('UnixTimestamp', () => { it('renders without errors', async () => { const { container } = await renderAppOptions({ - Component: UnixTimestamp, - props: { - accuracy: 'millisecond', - }, - value: 0, + Component: UnixTimestamp as any, + props: {}, + value: null, }); expect(container).toMatchInlineSnapshot(`
@@ -69,78 +67,10 @@ describe('UnixTimestamp', () => { `); }); - it('millisecond', async () => { - await renderAppOptions({ - Component: UnixTimestamp, - value: 1712819630000, - props: { - accuracy: 'millisecond', - }, - }); - await waitFor(() => { - expect(screen.getByRole('textbox')).toHaveValue('2024-04-11'); - }); - }); - - it('second', async () => { - await renderAppOptions({ - Component: UnixTimestamp, - value: 1712819630, - props: { - accuracy: 'second', - }, - }); - - await waitFor(() => { - expect(screen.getByRole('textbox')).toHaveValue('2024-04-11'); - }); - }); - - it('string', async () => { - await renderAppOptions({ - Component: UnixTimestamp, - value: '2024-04-11', - props: { - accuracy: 'millisecond', - }, - }); - - await waitFor(() => { - expect(screen.getByRole('textbox')).toHaveValue('2024-04-11'); - }); - }); - - it('change', async () => { - const onChange = vitest.fn(); - await renderAppOptions({ - Component: UnixTimestamp, - value: '2024-04-11', - onChange, - props: { - accuracy: 'millisecond', - }, - }); - await userEvent.click(screen.getByRole('textbox')); - - await waitFor(() => { - expect(screen.queryByRole('table')).toBeInTheDocument(); - }); - - await userEvent.click(document.querySelector('td[title="2024-04-12"]')); - - await waitFor(() => { - expect(screen.getByRole('textbox')).toHaveValue('2024-04-12'); - }); - expect(onChange).toBeCalledWith(1712880000000); - }); - it('read pretty', async () => { const { container } = await renderReadPrettyApp({ - Component: UnixTimestamp, + Component: UnixTimestamp as any, value: '2024-04-11', - props: { - accuracy: 'millisecond', - }, }); expect(screen.getByText('2024-04-11')).toBeInTheDocument(); diff --git a/packages/core/database/src/__tests__/fields/date-only.test.ts b/packages/core/database/src/__tests__/fields/date-only.test.ts new file mode 100644 index 0000000000..1b6d9a60f7 --- /dev/null +++ b/packages/core/database/src/__tests__/fields/date-only.test.ts @@ -0,0 +1,42 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Database, mockDatabase } from '@nocobase/database'; + +describe('date only', () => { + let db: Database; + + beforeEach(async () => { + db = mockDatabase({ + timezone: '+08:00', + }); + await db.clean({ drop: true }); + }); + + afterEach(async () => { + await db.close(); + }); + + it('should set date field with dateOnly', async () => { + db.collection({ + name: 'tests', + fields: [{ name: 'date1', type: 'dateOnly' }], + }); + + await db.sync(); + + const item = await db.getRepository('tests').create({ + values: { + date1: '2023-03-24', + }, + }); + + expect(item.get('date1')).toBe('2023-03-24'); + }); +}); diff --git a/packages/core/database/src/__tests__/fields/date.test.ts b/packages/core/database/src/__tests__/fields/date.test.ts index e1a2ff189e..4c6b373721 100644 --- a/packages/core/database/src/__tests__/fields/date.test.ts +++ b/packages/core/database/src/__tests__/fields/date.test.ts @@ -11,6 +11,69 @@ import { mockDatabase } from '../'; import { Database } from '../../database'; import { Repository } from '../../repository'; +describe('timezone', () => { + let db: Database; + + beforeEach(async () => { + db = mockDatabase({ + timezone: '+08:00', + }); + await db.clean({ drop: true }); + }); + + afterEach(async () => { + await db.close(); + }); + + describe('timezone', () => { + test('custom', async () => { + db.collection({ + name: 'tests', + timestamps: false, + fields: [{ name: 'date1', type: 'date', timezone: '+06:00' }], + }); + + await db.sync(); + const repository = db.getRepository('tests'); + const instance = await repository.create({ values: { date1: '2023-03-24 00:00:00' } }); + const date1 = instance.get('date1'); + expect(date1.toISOString()).toEqual('2023-03-23T18:00:00.000Z'); + }); + + test('client', async () => { + db.collection({ + name: 'tests', + timestamps: false, + fields: [{ name: 'date1', type: 'date', timezone: 'client' }], + }); + + await db.sync(); + const repository = db.getRepository('tests'); + const instance = await repository.create({ + values: { date1: '2023-03-24 01:00:00' }, + context: { + timezone: '+01:00', + }, + }); + const date1 = instance.get('date1'); + expect(date1.toISOString()).toEqual('2023-03-24T00:00:00.000Z'); + }); + + test('server', async () => { + db.collection({ + name: 'tests', + fields: [{ name: 'date1', type: 'date', timezone: 'server' }], + }); + + await db.sync(); + const repository = db.getRepository('tests'); + const instance = await repository.create({ values: { date1: '2023-03-24 08:00:00' } }); + const date1 = instance.get('date1'); + expect(date1.toISOString()).toEqual('2023-03-24T00:00:00.000Z'); + }); + }); +}); + describe('date-field', () => { let db: Database; let repository: Repository; @@ -30,16 +93,80 @@ describe('date-field', () => { await db.close(); }); - const createExpectToBe = async (key, actual, expected) => { - const instance = await repository.create({ + it('should set default to current time', async () => { + const c1 = db.collection({ + name: 'test11', + fields: [ + { + name: 'date1', + type: 'date', + defaultToCurrentTime: true, + }, + ], + }); + + await db.sync(); + + const instance = await c1.repository.create({}); + const date1 = instance.get('date1'); + expect(date1).toBeDefined(); + }); + + it('should set to current time when update', async () => { + const c1 = db.collection({ + name: 'test11', + fields: [ + { + name: 'date1', + type: 'date', + onUpdateToCurrentTime: true, + }, + { + name: 'title', + type: 'string', + }, + ], + }); + + await db.sync(); + + const instance = await c1.repository.create({ values: { - [key]: actual, + title: 'test', }, }); - return expect(instance.get(key).toISOString()).toEqual(expected); - }; + + const date1Val = instance.get('date1'); + expect(date1Val).toBeDefined(); + + console.log('update'); + await c1.repository.update({ + values: { + title: 'test2', + }, + filter: { + id: instance.get('id'), + }, + }); + + await instance.reload(); + + const date1Val2 = instance.get('date1'); + expect(date1Val2).toBeDefined(); + + expect(date1Val2.getTime()).toBeGreaterThan(date1Val.getTime()); + }); test('create', async () => { + const createExpectToBe = async (key, actual, expected) => { + const instance = await repository.create({ + values: { + [key]: actual, + }, + }); + return expect(instance.get(key).toISOString()).toEqual(expected); + }; + // 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'); diff --git a/packages/core/database/src/__tests__/fields/unix-timestamp-field.tests.ts b/packages/core/database/src/__tests__/fields/unix-timestamp-field.tests.ts new file mode 100644 index 0000000000..d590653811 --- /dev/null +++ b/packages/core/database/src/__tests__/fields/unix-timestamp-field.tests.ts @@ -0,0 +1,86 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Database, mockDatabase } from '@nocobase/database'; +import moment from 'moment'; + +describe('unix timestamp field', () => { + let db: Database; + + beforeEach(async () => { + db = mockDatabase(); + await db.clean({ drop: true }); + }); + + afterEach(async () => { + await db.close(); + }); + + it('should set default to current time', async () => { + const c1 = db.collection({ + name: 'test11', + fields: [ + { + name: 'date1', + type: 'unixTimestamp', + defaultToCurrentTime: true, + }, + ], + }); + + await db.sync(); + + const instance = await c1.repository.create({}); + const date1 = instance.get('date1'); + expect(date1).toBeDefined(); + + console.log(instance.toJSON()); + }); + + it('should set date value', async () => { + const c1 = db.collection({ + name: 'test12', + fields: [ + { + name: 'date1', + type: 'unixTimestamp', + }, + ], + }); + + await db.sync(); + + await c1.repository.create({ + values: { + date1: '2021-01-01T00:00:00Z', + }, + }); + + const item = await c1.repository.findOne(); + const val = item.get('date1'); + const date = moment(val).utc().format('YYYY-MM-DD HH:mm:ss'); + expect(date).toBe('2021-01-01 00:00:00'); + }); + + describe('timezone', () => { + test('custom', async () => { + db.collection({ + name: 'tests', + timestamps: false, + fields: [{ name: 'date1', type: 'unixTimestamp', timezone: '+06:00' }], + }); + + await db.sync(); + const repository = db.getRepository('tests'); + const instance = await repository.create({ values: { date1: '2023-03-24 00:00:00' } }); + const date1 = instance.get('date1'); + expect(date1.toISOString()).toEqual('2023-03-23T18:00:00.000Z'); + }); + }); +}); diff --git a/packages/core/database/src/database.ts b/packages/core/database/src/database.ts index 6300eb55bf..bf10c3bf17 100644 --- a/packages/core/database/src/database.ts +++ b/packages/core/database/src/database.ts @@ -34,7 +34,6 @@ import { import { SequelizeStorage, Umzug } from 'umzug'; import { Collection, CollectionOptions, RepositoryType } from './collection'; import { CollectionFactory } from './collection-factory'; -import { CollectionGroupManager } from './collection-group-manager'; import { ImporterReader, ImportFileExtension } from './collection-importer'; import DatabaseUtils from './database-utils'; import ReferencesMap from './features/references-map'; @@ -42,7 +41,6 @@ import { referentialIntegrityCheck } from './features/referential-integrity-chec import { ArrayFieldRepository } from './field-repository/array-field-repository'; import * as FieldTypes from './fields'; import { Field, FieldContext, RelationField } from './fields'; -import { checkDatabaseVersion } from './helpers'; import { InheritedCollection } from './inherited-collection'; import InheritanceMap from './inherited-map'; import { InterfaceManager } from './interface-manager'; @@ -221,6 +219,9 @@ export class Database extends EventEmitter implements AsyncEmitter { } } + // @ts-ignore + opts.rawTimezone = opts.timezone; + if (options.dialect === 'sqlite') { delete opts.timezone; } else if (!opts.timezone) { @@ -848,7 +849,8 @@ export class Database extends EventEmitter implements AsyncEmitter { * @internal */ async checkVersion() { - return await checkDatabaseVersion(this); + return true; + // return await checkDatabaseVersion(this); } /** diff --git a/packages/core/database/src/fields/date-field.ts b/packages/core/database/src/fields/date-field.ts index f40b27de3e..8cae2221d5 100644 --- a/packages/core/database/src/fields/date-field.ts +++ b/packages/core/database/src/fields/date-field.ts @@ -10,8 +10,14 @@ import { DataTypes } from 'sequelize'; import { BaseColumnFieldOptions, Field } from './field'; +const datetimeRegex = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; + +function isValidDatetime(str) { + return datetimeRegex.test(str); +} + export class DateField extends Field { - get dataType() { + get dataType(): any { return DataTypes.DATE(3); } @@ -33,6 +39,59 @@ export class DateField extends Field { return props.gmt; } + init() { + const { name, defaultToCurrentTime, onUpdateToCurrentTime, timezone } = this.options; + + this.resolveTimeZone = (context) => { + // @ts-ignore + const serverTimeZone = this.database.options.rawTimezone; + if (timezone === 'server') { + return serverTimeZone; + } + + if (timezone === 'client') { + return context?.timezone || serverTimeZone; + } + + if (timezone) { + return timezone; + } + + return serverTimeZone; + }; + + this.beforeSave = async (instance, options) => { + const value = instance.get(name); + + if (!value && instance.isNewRecord && defaultToCurrentTime) { + instance.set(name, new Date()); + return; + } + + if (onUpdateToCurrentTime) { + instance.set(name, new Date()); + return; + } + }; + } + + setter(value, options) { + if (value === null) { + return value; + } + if (value instanceof Date) { + return value; + } + + if (typeof value === 'string' && isValidDatetime(value)) { + const dateTimezone = this.resolveTimeZone(options?.context); + const dateString = `${value} ${dateTimezone}`; + return new Date(dateString); + } + + return value; + } + bind() { super.bind(); @@ -51,6 +110,13 @@ export class DateField extends Field { // @ts-ignore model.refreshAttributes(); } + + this.on('beforeSave', this.beforeSave); + } + + unbind() { + super.unbind(); + this.off('beforeSave', this.beforeSave); } } diff --git a/packages/core/database/src/fields/date-only-field.ts b/packages/core/database/src/fields/date-only-field.ts new file mode 100644 index 0000000000..5fce9d1b74 --- /dev/null +++ b/packages/core/database/src/fields/date-only-field.ts @@ -0,0 +1,21 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { BaseColumnFieldOptions, Field } from './field'; +import { DataTypes } from 'sequelize'; + +export class DateOnlyField extends Field { + get dataType(): any { + return DataTypes.DATEONLY; + } +} + +export interface DateOnlyFieldOptions extends BaseColumnFieldOptions { + type: 'dateOnly'; +} diff --git a/packages/core/database/src/fields/field.ts b/packages/core/database/src/fields/field.ts index b9f230dd4f..e4b0b70024 100644 --- a/packages/core/database/src/fields/field.ts +++ b/packages/core/database/src/fields/field.ts @@ -56,7 +56,7 @@ export abstract class Field { return this.options.type; } - abstract get dataType(); + abstract get dataType(): any; isRelationField() { return false; @@ -171,11 +171,13 @@ export abstract class Field { Object.assign(opts, { type: this.database.sequelize.normalizeDataType(this.dataType) }); } + Object.assign(opts, this.additionalSequelizeOptions()); + return opts; } - isSqlite() { - return this.database.sequelize.getDialect() === 'sqlite'; + additionalSequelizeOptions() { + return {}; } typeToString() { diff --git a/packages/core/database/src/fields/index.ts b/packages/core/database/src/fields/index.ts index 610b6f1ad1..d698e44cd6 100644 --- a/packages/core/database/src/fields/index.ts +++ b/packages/core/database/src/fields/index.ts @@ -36,6 +36,8 @@ import { UUIDFieldOptions } from './uuid-field'; import { VirtualFieldOptions } from './virtual-field'; import { NanoidFieldOptions } from './nanoid-field'; import { EncryptionField } from './encryption-field'; +import { UnixTimestampFieldOptions } from './unix-timestamp-field'; +import { DateOnlyFieldOptions } from './date-only-field'; export * from './array-field'; export * from './belongs-to-field'; @@ -43,6 +45,7 @@ export * from './belongs-to-many-field'; export * from './boolean-field'; export * from './context-field'; export * from './date-field'; +export * from './date-only-field'; export * from './field'; export * from './has-many-field'; export * from './has-one-field'; @@ -61,6 +64,7 @@ export * from './uuid-field'; export * from './virtual-field'; export * from './nanoid-field'; export * from './encryption-field'; +export * from './unix-timestamp-field'; export type FieldOptions = | BaseFieldOptions @@ -81,6 +85,8 @@ export type FieldOptions = | SetFieldOptions | TimeFieldOptions | DateFieldOptions + | DateOnlyFieldOptions + | UnixTimestampFieldOptions | UidFieldOptions | UUIDFieldOptions | NanoidFieldOptions diff --git a/packages/core/database/src/fields/unix-timestamp-field.ts b/packages/core/database/src/fields/unix-timestamp-field.ts new file mode 100644 index 0000000000..fc634ecde5 --- /dev/null +++ b/packages/core/database/src/fields/unix-timestamp-field.ts @@ -0,0 +1,60 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { DataTypes } from 'sequelize'; +import { DateField } from './date-field'; +import { BaseColumnFieldOptions } from './field'; + +export class UnixTimestampField extends DateField { + get dataType() { + return DataTypes.BIGINT; + } + + additionalSequelizeOptions(): {} { + const { name } = this.options; + let { accuracy } = this.options; + + if (this.options?.uiSchema['x-component-props']?.accuracy) { + accuracy = this.options?.uiSchema['x-component-props']?.accuracy; + } + + if (!accuracy) { + accuracy = 'second'; + } + + let rationalNumber = 1000; + + if (accuracy === 'millisecond') { + rationalNumber = 1; + } + + return { + get() { + const value = this.getDataValue(name); + if (value === null || value === undefined) { + return value; + } + + return new Date(value * rationalNumber); + }, + set(value) { + if (value === null || value === undefined) { + this.setDataValue(name, value); + } else { + // date to unix timestamp + this.setDataValue(name, Math.floor(new Date(value).getTime() / rationalNumber)); + } + }, + }; + } +} + +export interface UnixTimestampFieldOptions extends BaseColumnFieldOptions { + type: 'unix-timestamp'; +} diff --git a/packages/core/database/src/model.ts b/packages/core/database/src/model.ts index a7581dece3..9ec745fcca 100644 --- a/packages/core/database/src/model.ts +++ b/packages/core/database/src/model.ts @@ -7,7 +7,7 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import lodash, { isPlainObject } from 'lodash'; +import lodash from 'lodash'; import { Model as SequelizeModel, ModelStatic } from 'sequelize'; import { Collection } from './collection'; import { Database } from './database'; @@ -30,7 +30,8 @@ interface JSONTransformerOptions { export class Model extends SequelizeModel - implements IModel { + implements IModel +{ public static database: Database; public static collection: Collection; @@ -49,6 +50,21 @@ export class Model(values, { ...options, @@ -645,7 +645,7 @@ export class Repository exten * @internal */ public perfHistograms = new Map(); - protected plugins = new Map(); - protected _appSupervisor: AppSupervisor = AppSupervisor.getInstance(); - protected _started: Date | null = null; - private _authenticated = false; - private _maintaining = false; - private _maintainingCommandStatus: MaintainingCommandStatus; - private _maintainingStatusBeforeCommand: MaintainingCommandStatus | null; - private _actionCommand: Command; - /** * @internal */ public syncManager: SyncManager; public requestLogger: Logger; + protected plugins = new Map(); + protected _appSupervisor: AppSupervisor = AppSupervisor.getInstance(); + private _authenticated = false; + private _maintaining = false; + private _maintainingCommandStatus: MaintainingCommandStatus; + private _maintainingStatusBeforeCommand: MaintainingCommandStatus | null; + private _actionCommand: Command; private sqlLogger: Logger; - protected _logger: SystemLogger; constructor(public options: ApplicationOptions) { super(); @@ -241,6 +238,8 @@ export class Application exten } } + protected _started: Date | null = null; + /** * @experimental */ @@ -248,6 +247,8 @@ export class Application exten return this._started; } + protected _logger: SystemLogger; + get logger() { return this._logger; }