refactor: datetime field (#5084)

* 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

* refactor: timezone

* refactor: datetimeNoTzFieldInterface

* refactor: locale improve

* refactor: locale improve

* fix: test

* fix: bug

* chore: datetime no tz field

* refactor: datetimeNoTz

* refactor: datetime

* fix: bug

* refactor: timeFormat

* style: collection fields style improve

* refactor: defaultToCurrentTime

* fix: datetime no tz

* chore: field type map

* fix: bug

* fix: bug

* refactor: createAt & updateAt

* fix: bug

* fix: no tz field with timezone

* refactor: dateonly

* fix: test

* chore: data type map

* fix: dateonly

* fix: dateonly

* fix: datetime

* refactor: locale improve

* refactor: unixtimestamp

* fix: merge bug

* fix: bug

* fix: datetime

* fix: datetime no tz

* fix: datetime no tz

* chore: mysql datetime map

* chore: test

* chore: test

* chore: test

* chore: datetimeTz field

* fix: no interface option

* refactor: update type

* refactor: update type

* fix: pg no tz field

* chore: save iso8601 format to no tz field

* fix: test

* fix: test

* refactor: gannt & calender startTime & endTime

* refactor: unixTimestamp

* chore: filter of datetime field

* chore: test

* chore: test

* fix: test

* fix: datetime no tz filter

* chore: test

* chore: test

* fix: datetime default value in mysql

* fix: sqlite test

* chore: test

* fix: test

* fix: test

* fix: $dateOn

* fix: bug

* fix: bug

* refactor: datepicker

* fix: test

* refactor: datePicker

* refactor: gantt setting

---------

Co-authored-by: katherinehhh <katherine_15995@163.com>
This commit is contained in:
ChengLei Shao 2024-09-10 15:25:20 +08:00 committed by GitHub
parent 68b3fa78c4
commit a7df0e3fd3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 1773 additions and 299 deletions

View File

@ -52,6 +52,8 @@ import {
UUIDFieldInterface, UUIDFieldInterface,
NanoidFieldInterface, NanoidFieldInterface,
UnixTimestampFieldInterface, UnixTimestampFieldInterface,
DateFieldInterface,
DatetimeNoTzFieldInterface,
} from './interfaces'; } from './interfaces';
import { import {
GeneralCollectionTemplate, GeneralCollectionTemplate,
@ -173,6 +175,8 @@ export class CollectionPlugin extends Plugin {
UUIDFieldInterface, UUIDFieldInterface,
NanoidFieldInterface, NanoidFieldInterface,
UnixTimestampFieldInterface, UnixTimestampFieldInterface,
DateFieldInterface,
DatetimeNoTzFieldInterface,
]); ]);
} }

View File

@ -28,7 +28,7 @@ export class CreatedAtFieldInterface extends CollectionFieldInterface {
'x-read-pretty': true, 'x-read-pretty': true,
}, },
}; };
availableTypes = ['date']; availableTypes = [];
properties = { properties = {
...defaultProps, ...defaultProps,
...dateTimeProps, ...dateTimeProps,

View File

@ -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 = 3;
title = '{{t("DateOnly")}}';
sortable = true;
default = {
type: 'dateOnly',
uiSchema: {
type: 'string',
'x-component': 'DatePicker',
'x-component-props': {
dateOnly: true,
},
},
};
availableTypes = ['dateOnly'];
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;
}

View File

@ -15,23 +15,39 @@ export class DatetimeFieldInterface extends CollectionFieldInterface {
type = 'object'; type = 'object';
group = 'datetime'; group = 'datetime';
order = 1; order = 1;
title = '{{t("Datetime")}}'; title = '{{t("Datetime(with time zone)")}}';
sortable = true; sortable = true;
default = { default = {
type: 'date', type: 'date',
defaultToCurrentTime: false,
onUpdateToCurrentTime: false,
timezone: true,
uiSchema: { uiSchema: {
type: 'string', type: 'string',
'x-component': 'DatePicker', 'x-component': 'DatePicker',
'x-component-props': { 'x-component-props': {
showTime: false, showTime: false,
utc: true,
}, },
}, },
}; };
availableTypes = ['date', 'dateOnly', 'string']; availableTypes = ['date', 'string', 'datetime', 'datetimeTz'];
hasDefaultValue = true; hasDefaultValue = true;
properties = { properties = {
...defaultProps, ...defaultProps,
...dateTimeProps, ...dateTimeProps,
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")}}',
},
'uiSchema.x-component-props.gmt': { 'uiSchema.x-component-props.gmt': {
type: 'boolean', type: 'boolean',
title: '{{t("GMT")}}', title: '{{t("GMT")}}',

View File

@ -0,0 +1,65 @@
/**
* 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 DatetimeNoTzFieldInterface extends CollectionFieldInterface {
name = 'datetimeNoTz';
type = 'object';
group = 'datetime';
order = 2;
title = '{{t("Datetime(without time zone)")}}';
sortable = true;
default = {
type: 'datetimeNoTz',
defaultToCurrentTime: false,
onUpdateToCurrentTime: false,
timezone: false,
uiSchema: {
type: 'string',
'x-component': 'DatePicker',
'x-component-props': {
showTime: false,
utc: false,
},
},
};
availableTypes = ['string', 'datetimeNoTz'];
hasDefaultValue = true;
properties = {
...defaultProps,
...dateTimeProps,
defaultToCurrentTime: {
type: 'boolean',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
'x-content': '{{t("Default value to current server time")}}',
},
onUpdateToCurrentTime: {
type: 'boolean',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
'x-content': '{{t("Automatically update timestamp to the current server time on update")}}',
},
'uiSchema.x-component-props.gmt': {
type: 'boolean',
title: '{{t("GMT")}}',
'x-hidden': true,
'x-component': 'Checkbox',
'x-content': '{{t("Use the same time zone (GMT) for all users")}}',
'x-decorator': 'FormItem',
default: false,
},
};
filterable = {
operators: operators.datetime,
};
titleUsable = true;
}

View File

@ -46,3 +46,5 @@ export * from './sort';
export * from './uuid'; export * from './uuid';
export * from './nanoid'; export * from './nanoid';
export * from './unixTimestamp'; export * from './unixTimestamp';
export * from './dateOnly';
export * from './datetimeNoTz';

View File

@ -255,6 +255,7 @@ export const dateTimeProps: { [key: string]: ISchema } = {
`{{(field) => { `{{(field) => {
field.query('..[].timeFormat').take(f => { field.query('..[].timeFormat').take(f => {
f.display = field.value ? 'visible' : 'none'; f.display = field.value ? 'visible' : 'none';
f.value='HH:mm:ss'
}); });
}}}`, }}}`,
], ],

View File

@ -14,7 +14,7 @@ export class TimeFieldInterface extends CollectionFieldInterface {
name = 'time'; name = 'time';
type = 'object'; type = 'object';
group = 'datetime'; group = 'datetime';
order = 2; order = 4;
title = '{{t("Time")}}'; title = '{{t("Time")}}';
sortable = true; sortable = true;
default = { default = {

View File

@ -8,31 +8,33 @@
*/ */
import { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface'; import { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface';
import { dateTimeProps, defaultProps, operators } from './properties'; import { defaultProps, operators } from './properties';
export class UnixTimestampFieldInterface extends CollectionFieldInterface { export class UnixTimestampFieldInterface extends CollectionFieldInterface {
name = 'unixTimestamp'; name = 'unixTimestamp';
type = 'object'; type = 'object';
group = 'datetime'; group = 'datetime';
order = 1; order = 4;
title = '{{t("Unix Timestamp")}}'; title = '{{t("Unix Timestamp")}}';
sortable = true; sortable = true;
default = { default = {
type: 'bigInt', type: 'unixTimestamp',
accuracy: 'second',
timezone: true,
defaultToCurrentTime: false,
onUpdateToCurrentTime: false,
uiSchema: { uiSchema: {
type: 'number', type: 'number',
'x-component': 'UnixTimestamp', 'x-component': 'UnixTimestamp',
'x-component-props': { 'x-component-props': {
accuracy: 'second',
showTime: true, showTime: true,
}, },
}, },
}; };
availableTypes = ['integer', 'bigInt']; availableTypes = ['unixTimestamp'];
hasDefaultValue = true; hasDefaultValue = false;
properties = { properties = {
...defaultProps, ...defaultProps,
'uiSchema.x-component-props.accuracy': { accuracy: {
type: 'string', type: 'string',
title: '{{t("Accuracy")}}', title: '{{t("Accuracy")}}',
'x-component': 'Radio.Group', 'x-component': 'Radio.Group',
@ -43,9 +45,21 @@ export class UnixTimestampFieldInterface extends CollectionFieldInterface {
{ value: 'second', label: '{{t("Second")}}' }, { value: 'second', label: '{{t("Second")}}' },
], ],
}, },
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")}}',
},
}; };
filterable = { filterable = {
operators: operators.number, operators: operators.datetime,
}; };
titleUsable = true; titleUsable = true;
} }

View File

@ -28,7 +28,7 @@ export class UpdatedAtFieldInterface extends CollectionFieldInterface {
'x-read-pretty': true, 'x-read-pretty': true,
}, },
}; };
availableTypes = ['date']; availableTypes = [];
properties = { properties = {
...defaultProps, ...defaultProps,
...dateTimeProps, ...dateTimeProps,

View File

@ -119,8 +119,8 @@ export abstract class CollectionFieldInterface {
}, },
}, },
{ {
dependencies: ['primaryKey', 'unique', 'autoIncrement'], dependencies: ['primaryKey', 'unique', 'autoIncrement', 'defaultToCurrentTime'],
when: '{{$deps[0]||$deps[1]||$deps[2]}}', when: '{{$deps[0]||$deps[1]||$deps[2]||$deps[3]}}',
fulfill: { fulfill: {
state: { state: {
hidden: true, hidden: true,

View File

@ -283,7 +283,7 @@
"Checkbox group": "复选框", "Checkbox group": "复选框",
"China region": "中国行政区", "China region": "中国行政区",
"Date & Time": "日期 & 时间", "Date & Time": "日期 & 时间",
"Datetime": "日期", "Datetime": "日期时间",
"Relation": "关系类型", "Relation": "关系类型",
"Link to": "关联", "Link to": "关联",
"Link to description": "用于快速创建表关系,可兼容大多数普通场景。适合非开发人员使用。作为字段存在时,它是一个下拉选择用于选择目标数据表的数据。创建后,将同时在目标数据表中生成当前数据表的关联字段。", "Link to description": "用于快速创建表关系,可兼容大多数普通场景。适合非开发人员使用。作为字段存在时,它是一个下拉选择用于选择目标数据表的数据。创建后,将同时在目标数据表中生成当前数据表的关联字段。",
@ -974,5 +974,12 @@
"Skip getting the total number of table records during paging to speed up loading. It is recommended to enable this option for data tables with a large amount of data": "在分页时跳过获取表记录总数,以加快加载速度,建议对有大量数据的数据表开启此选项", "Skip getting the total number of table records during paging to speed up loading. It is recommended to enable this option for data tables with a large amount of data": "在分页时跳过获取表记录总数,以加快加载速度,建议对有大量数据的数据表开启此选项",
"The current user only has the UI configuration permission, but don't have view permission for collection \"{{name}}\"": "当前用户只有 UI 配置权限,但没有数据表 \"{{name}}\" 查看权限。", "The current user only has the UI configuration permission, but don't have view permission for collection \"{{name}}\"": "当前用户只有 UI 配置权限,但没有数据表 \"{{name}}\" 查看权限。",
"Plugin dependency version mismatch": "插件依赖版本不一致", "Plugin dependency version mismatch": "插件依赖版本不一致",
"The current dependency version of the plugin does not match the version of the application and may not work properly. Are you sure you want to continue enabling the plugin?": "当前插件的依赖版本与应用的版本不一致,可能无法正常工作。您确定要继续激活插件吗?" "The current dependency version of the plugin does not match the version of the application and may not work properly. Are you sure you want to continue enabling the plugin?": "当前插件的依赖版本与应用的版本不一致,可能无法正常工作。您确定要继续激活插件吗?",
"Default value to current time": "设置字段默认值为当前时间",
"Automatically update timestamp on update": "当记录更新时自动设置字段值为当前时间",
"Default value to current server time": "设置字段默认值为当前服务端时间",
"Automatically update timestamp to the current server time on update": "当记录更新时自动设置字段值为当前服务端时间",
"Datetime(with time zone)": "日期时间(含时区)",
"Datetime(without time zone)": "日期时间(不含时区)",
"DateOnly":"仅日期"
} }

View File

@ -102,7 +102,7 @@ describe('moment2str', () => {
test('picker is year', () => { test('picker is year', () => {
const m = dayjs('2023-06-21 10:10:00'); const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'year' }); const str = moment2str(m, { picker: 'year', gmt: true });
expect(str).toBe('2023-01-01T00:00:00.000Z'); expect(str).toBe('2023-01-01T00:00:00.000Z');
}); });
@ -132,7 +132,7 @@ describe('moment2str', () => {
test('picker is month', () => { test('picker is month', () => {
const m = dayjs('2023-06-21 10:10:00'); const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'month' }); const str = moment2str(m, { picker: 'month', gmt: true });
expect(str).toBe('2023-06-01T00:00:00.000Z'); expect(str).toBe('2023-06-01T00:00:00.000Z');
}); });

View File

@ -78,18 +78,21 @@ export const mapDatePicker = function () {
return (props: any) => { return (props: any) => {
const format = getDefaultFormat(props) as any; const format = getDefaultFormat(props) as any;
const onChange = props.onChange; const onChange = props.onChange;
return { return {
...props, ...props,
format: format, format: format,
value: str2moment(props.value, props), value: str2moment(props.value, props),
onChange: (value: Dayjs | null) => { onChange: (value: Dayjs | null, dateString) => {
if (onChange) { if (onChange) {
if (!props.showTime && value) { if (!props.showTime && value) {
value = value.startOf('day'); value = value.startOf('day');
} }
if (props.dateOnly) {
onChange(dateString !== '' ? dateString : undefined);
} else {
onChange(moment2str(value, props)); onChange(moment2str(value, props));
} }
}
}, },
}; };
}; };

View File

@ -64,6 +64,7 @@ export const DynamicComponent = (props: Props) => {
minWidth: 150, minWidth: 150,
...props.style, ...props.style,
}, },
utc: false,
}), }),
name: 'value', name: 'value',
'x-read-pretty': false, 'x-read-pretty': false,

View File

@ -8,57 +8,32 @@
*/ */
import { connect, mapReadPretty } from '@formily/react'; import { connect, mapReadPretty } from '@formily/react';
import React, { useMemo } from 'react'; import React from 'react';
import { DatePicker } from '../date-picker'; 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 { interface UnixTimestampProps {
value?: number; value?: any;
accuracy?: 'millisecond' | 'second';
onChange?: (value: number) => void; onChange?: (value: number) => void;
} }
export const UnixTimestamp = connect( export const UnixTimestamp = connect(
(props: UnixTimestampProps) => { (props: UnixTimestampProps) => {
const { value, onChange, accuracy = 'second' } = props; const { value, onChange } = props;
const v = useMemo(() => toValue(value, accuracy), [value, accuracy]);
return ( return (
<DatePicker <DatePicker
{...props} {...props}
value={v} value={value}
onChange={(v: any) => { onChange={(v: any) => {
if (onChange) { if (onChange) {
onChange(getTimestamp(v, accuracy)); onChange(v);
} }
}} }}
/> />
); );
}, },
mapReadPretty((props) => { mapReadPretty((props) => {
const { value, accuracy = 'second' } = props; const { value } = props;
const v = useMemo(() => toValue(value, accuracy), [value, accuracy]); return <DatePicker.ReadPretty {...props} value={value} />;
return <DatePicker.ReadPretty {...props} value={v} />;
}), }),
); );

View File

@ -13,11 +13,9 @@ import { UnixTimestamp } from '@nocobase/client';
describe('UnixTimestamp', () => { describe('UnixTimestamp', () => {
it('renders without errors', async () => { it('renders without errors', async () => {
const { container } = await renderAppOptions({ const { container } = await renderAppOptions({
Component: UnixTimestamp, Component: UnixTimestamp as any,
props: { props: {},
accuracy: 'millisecond', value: null,
},
value: 0,
}); });
expect(container).toMatchInlineSnapshot(` expect(container).toMatchInlineSnapshot(`
<div> <div>
@ -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 () => { it('read pretty', async () => {
const { container } = await renderReadPrettyApp({ const { container } = await renderReadPrettyApp({
Component: UnixTimestamp, Component: UnixTimestamp as any,
value: '2024-04-11', value: '2024-04-11',
props: {
accuracy: 'millisecond',
},
}); });
expect(screen.getByText('2024-04-11')).toBeInTheDocument(); expect(screen.getByText('2024-04-11')).toBeInTheDocument();

View File

@ -463,6 +463,7 @@ export const useFilterFormItemInitializerFields = (options?: any) => {
'x-collection-field': `${name}.${field.name}`, 'x-collection-field': `${name}.${field.name}`,
'x-component-props': { 'x-component-props': {
component: interfaceConfig?.filterable?.operators?.[0]?.schema?.['x-component'], component: interfaceConfig?.filterable?.operators?.[0]?.schema?.['x-component'],
utc: false,
}, },
}; };
if (isAssocField(field)) { if (isAssocField(field)) {
@ -571,6 +572,7 @@ const associationFieldToMenu = (
interface: field.interface, interface: field.interface,
}, },
'x-component': 'CollectionField', 'x-component': 'CollectionField',
'x-component-props': { utc: false },
'x-read-pretty': false, 'x-read-pretty': false,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-collection-field': `${collectionName}.${schemaName}`, 'x-collection-field': `${collectionName}.${schemaName}`,
@ -686,7 +688,7 @@ export const useFilterInheritsFormItemInitializerFields = (options?) => {
'x-component': 'CollectionField', 'x-component': 'CollectionField',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-collection-field': `${name}.${field.name}`, 'x-collection-field': `${name}.${field.name}`,
'x-component-props': {}, 'x-component-props': { utc: false },
'x-read-pretty': field?.uiSchema?.['x-read-pretty'], 'x-read-pretty': field?.uiSchema?.['x-read-pretty'],
}; };
return { return {

View File

@ -89,6 +89,7 @@ export const SchemaSettingsDateFormat = function DateFormatConfig(props: { field
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Checkbox', 'x-component': 'Checkbox',
'x-content': '{{t("Show time")}}', 'x-content': '{{t("Show time")}}',
'x-hidden': collectionField?.type === 'dateOnly',
'x-reactions': [ 'x-reactions': [
`{{(field) => { `{{(field) => {
field.query('.timeFormat').take(f => { field.query('.timeFormat').take(f => {
@ -142,9 +143,10 @@ export const SchemaSettingsDateFormat = function DateFormatConfig(props: { field
const schema = { const schema = {
['x-uid']: fieldSchema['x-uid'], ['x-uid']: fieldSchema['x-uid'],
}; };
schema['x-component-props'] = fieldSchema['x-component-props'] || {}; console.log(field.componentProps);
schema['x-component-props'] = field.componentProps || {};
fieldSchema['x-component-props'] = { fieldSchema['x-component-props'] = {
...(fieldSchema['x-component-props'] || {}), ...(field.componentProps || {}),
...data, ...data,
}; };
schema['x-component-props'] = fieldSchema['x-component-props']; schema['x-component-props'] = fieldSchema['x-component-props'];

View File

@ -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');
});
});

View File

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

View File

@ -0,0 +1,145 @@
/**
* 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 { sleep } from '@nocobase/test';
describe('datetime no tz field', () => {
let db: Database;
beforeEach(async () => {
db = mockDatabase({
timezone: '+01:00',
});
await db.clean({ drop: true });
});
afterEach(async () => {
await db.close();
});
it('should not get timezone part', async () => {
db.collection({
name: 'tests',
timestamps: false,
fields: [{ name: 'date1', type: 'datetimeNoTz' }],
});
await db.sync();
await db.getRepository('tests').create({
values: {
date1: '2023-03-24 12:00:00',
},
});
const item = await db.getRepository('tests').findOne();
expect(item.toJSON()['date1']).toBe('2023-03-24 12:00:00');
});
it('should save datetime with timezone to no tz field', async () => {
db.collection({
name: 'tests',
timestamps: false,
fields: [{ name: 'date1', type: 'datetimeNoTz' }],
});
await db.sync();
await db.getRepository('tests').create({
values: {
date1: '2023-03-24T12:00:00.892Z',
},
});
const item = await db.getRepository('tests').findOne();
expect(item.get('date1')).toBe('2023-03-24 13:00:00');
});
it('should set datetime no tz field', async () => {
db.collection({
name: 'tests',
timestamps: false,
fields: [{ name: 'date1', type: 'datetimeNoTz' }],
});
await db.sync();
const item = await db.getRepository('tests').create({
values: {
date1: '2023-03-24 12:00:00',
},
});
expect(item.get('date1')).toBe('2023-03-24 12:00:00');
});
it('should set default to current time', async () => {
const c1 = db.collection({
name: 'test11',
fields: [
{
name: 'date1',
type: 'datetimeNoTz',
defaultToCurrentTime: true,
},
],
});
await db.sync();
const instance = await c1.repository.create({});
const date1 = instance.get('date1');
expect(date1).toBeTruthy();
});
it('should set to current time when update', async () => {
const c1 = db.collection({
name: 'test11',
fields: [
{
name: 'date1',
type: 'datetimeNoTz',
onUpdateToCurrentTime: true,
},
{
name: 'title',
type: 'string',
},
],
});
await db.sync();
const instance = await c1.repository.create({
values: {
title: 'test',
},
});
const date1Val = instance.get('date1');
expect(date1Val).toBeTruthy();
await sleep(1000);
await c1.repository.update({
values: {
title: 'test2',
},
filter: {
id: instance.get('id'),
},
});
await instance.reload();
const date1Val2 = instance.get('date1');
expect(date1Val2).toBeTruthy();
});
});

View File

@ -0,0 +1,250 @@
/**
* 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 { 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();
});
it('should save with timezone value', async () => {
db.collection({
name: 'tests',
timestamps: false,
fields: [{ name: 'date1', type: 'datetimeTz' }],
});
await db.sync();
const repository = db.getRepository('tests');
const instance = await repository.create({ values: { date1: '2023-03-23T12:00:00.000Z' } });
const date1 = instance.get('date1');
expect(date1.toISOString()).toEqual('2023-03-23T12:00:00.000Z');
});
it('should create field with default value', async () => {
db.collection({
name: 'tests',
timestamps: false,
fields: [{ name: 'date1', type: 'datetimeTz', defaultValue: '2023-03-23T18:00:00.000Z' }],
});
let err;
try {
await db.sync();
} catch (e) {
err = e;
}
expect(err).toBeUndefined();
const repository = db.getRepository('tests');
const instance = await repository.create({});
const date1 = instance.get('date1');
expect(date1.toISOString()).toEqual('2023-03-23T18:00:00.000Z');
});
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;
beforeEach(async () => {
db = mockDatabase();
await db.clean({ drop: true });
db.collection({
name: 'tests',
fields: [{ name: 'date1', type: 'date' }],
});
await db.sync();
repository = db.getRepository('tests');
});
afterEach(async () => {
await db.close();
});
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).toBeTruthy();
});
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: {
title: 'test',
},
});
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:00postgres 和 mysql 可以自定义 DB_TIMEZONE
await createExpectToBe('date1', '2023-03-24', '2023-03-24T00:00:00.000Z');
await createExpectToBe('date1', '2023-03-24T16:00:00.000Z', '2023-03-24T16:00:00.000Z');
});
// dateXX 相关 Operator 都是去 time 比较的
describe('dateOn', () => {
test('dateOn operator', async () => {
console.log('timezone', db.options.timezone);
// 默认的情况,时区为 db.options.timezone
await repository.find({
filter: {
date1: {
// 由 db.options.timezone 来处理日期转换,假设是 +08:00 的时区
// 2023-03-24表示的范围2023-03-23T16:00:00 ~ 2023-03-24T16:00:00
$dateOn: '2023-03-24',
},
},
});
await repository.find({
filter: {
date1: {
// +06:00 时区 2023-03-24 的范围2023-03-23T18:00:00 ~ 2023-03-24T18:00:00
$dateOn: '2023-03-24+06:00',
},
},
});
await repository.find({
filter: {
date1: {
// 2023-03-23T20:00:00+08:00 在 +08:00 时区的时间是2023-03-24 04:00:00
// 也就是 +08:00 时区 2023-03-24 这一天的范围2023-03-23T16:00:00 ~ 2023-03-24T16:00:00
$dateOn: '2023-03-23T20:00:00+08:00',
},
},
});
});
});
});

View File

@ -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');
});
});
});

View File

@ -0,0 +1,164 @@
import Database, { mockDatabase, Repository } from '../../../index';
describe('dateOnly operator', () => {
let db: Database;
let repository: Repository;
afterEach(async () => {
await db.close();
});
beforeEach(async () => {
db = mockDatabase({
timezone: '+08:00',
});
await db.clean({ drop: true });
const Test = db.collection({
name: 'tests',
fields: [
{
name: 'date1',
type: 'dateOnly',
},
{
type: 'string',
name: 'name',
},
],
});
repository = Test.repository;
await db.sync();
});
test('$dateOn', async () => {
await repository.create({
values: [
{
date1: '2023-01-01',
name: 'u0',
},
{
date1: '2023-01-01',
name: 'u1',
},
{
date1: '2022-12-31',
name: 'u2',
},
{
date1: '2022-12-31',
name: 'u3',
},
],
});
let count: number;
count = await repository.count({
filter: {
'date1.$dateOn': '2022-12-31',
},
});
expect(count).toBe(2);
count = await repository.count({
filter: {
'date1.$dateOn': '2023-01-01',
},
});
expect(count).toBe(2);
});
test('$dateBefore', async () => {
await repository.create({
values: [
{
date1: '2023-01-01',
name: 'u0',
},
{
date1: '2023-01-01',
name: 'u1',
},
{
date1: '2022-12-31',
name: 'u2',
},
{
date1: '2022-12-31',
name: 'u3',
},
],
});
let count: number;
count = await repository.count({
filter: {
'date1.$dateBefore': '2023-01-01',
},
});
expect(count).toBe(2);
count = await repository.count({
filter: {
'date1.$dateBefore': '2022-12-31',
},
});
expect(count).toBe(0);
});
test('dateBetween', async () => {
await repository.create({
values: [
{
date1: '2023-01-01',
name: 'u0',
},
{
date1: '2023-01-01',
name: 'u1',
},
{
date1: '2022-12-31',
name: 'u2',
},
{
date1: '2022-12-31',
name: 'u3',
},
],
});
let count: number;
count = await repository.count({
filter: {
'date1.$dateBetween': ['2022-12-31', '2023-01-01'],
},
});
expect(count).toBe(4);
count = await repository.count({
filter: {
'date1.$dateBetween': ['2022-12-31', '2023-01-01'],
},
});
expect(count).toBe(4);
count = await repository.count({
filter: {
'date1.$dateBetween': ['2022-12-31', '2022-12-31'],
},
});
expect(count).toBe(2);
});
});

View File

@ -0,0 +1,201 @@
import Database, { mockDatabase, Repository } from '../../../index';
describe('datetimeNoTz date operator test', () => {
let db: Database;
let repository: Repository;
afterEach(async () => {
await db.close();
});
beforeEach(async () => {
db = mockDatabase({
timezone: '+00:00',
});
await db.clean({ drop: true });
const Test = db.collection({
name: 'tests',
fields: [
{
name: 'date1',
type: 'datetimeNoTz',
},
{
type: 'string',
name: 'name',
},
],
});
repository = Test.repository;
await db.sync();
});
test('$dateOn', async () => {
await repository.create({
values: [
{
date1: '2023-01-01 00:00:00',
name: 'u0',
},
{
date1: '2023-01-01 00:00:00',
name: 'u1',
},
{
date1: '2022-12-31 16:00:00',
name: 'u2',
},
{
date1: '2022-12-31 16:00:00',
name: 'u3',
},
],
});
let count: number;
count = await repository.count({
filter: {
'date1.$dateOn': '2023-01-01',
},
});
expect(count).toBe(2);
count = await repository.count({
filter: {
'date1.$dateOn': '2022-12-31',
},
});
expect(count).toBe(2);
count = await repository.count({
filter: {
'date1.$dateOn': '2022-12-31 16:00:00',
},
});
expect(count).toBe(2);
count = await repository.count({
filter: {
'date1.$dateOn': '2022-12-31 16:00:01',
},
});
expect(count).toBe(0);
});
test('$dateBefore', async () => {
await repository.create({
values: [
{
date1: '2023-01-01 00:00:00',
name: 'u0',
},
{
date1: '2023-01-01 00:00:00',
name: 'u1',
},
{
date1: '2022-12-31 16:00:00',
name: 'u2',
},
{
date1: '2022-12-31 16:00:00',
name: 'u3',
},
],
});
let count: number;
count = await repository.count({
filter: {
'date1.$dateBefore': '2023-01-01',
},
});
expect(count).toBe(2);
count = await repository.count({
filter: {
'date1.$dateBefore': '2022-12-31',
},
});
expect(count).toBe(0);
});
test('$dateBefore2', async () => {
await repository.create({
values: [
{
date1: '2024-09-08 15:33:54',
name: 'u0',
},
],
});
let count: number;
count = await repository.count({
filter: {
'date1.$dateBefore': '2024-09-08 15:33:55',
},
});
expect(count).toBe(1);
});
test('dateBetween', async () => {
await repository.create({
values: [
{
date1: '2023-01-01 00:00:00',
name: 'u0',
},
{
date1: '2023-01-01 00:00:00',
name: 'u1',
},
{
date1: '2022-12-31 16:00:00',
name: 'u2',
},
{
date1: '2022-12-31 16:00:00',
name: 'u3',
},
],
});
let count: number;
count = await repository.count({
filter: {
'date1.$dateBetween': ['2022-12-31', '2023-01-01'],
},
});
expect(count).toBe(4);
count = await repository.count({
filter: {
'date1.$dateBetween': ['2022-12-31 16:00:00', '2023-01-01 00:00:00'],
},
});
expect(count).toBe(4);
count = await repository.count({
filter: {
'date1.$dateBetween': ['2022-12-31 11:00:00', '2022-12-31 17:00:00'],
},
});
expect(count).toBe(2);
});
});

View File

@ -7,9 +7,9 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import Database from '../../database'; import Database from '../../../database';
import { Repository } from '../../repository'; import { Repository } from '../../../repository';
import { mockDatabase } from '../index'; import { mockDatabase } from '../../index';
describe('date operator test', () => { describe('date operator test', () => {
let db: Database; let db: Database;

View File

@ -0,0 +1,75 @@
import Database, { mockDatabase, Repository } from '../../../index';
describe('unix timestamp date operator test', () => {
let db: Database;
let repository: Repository;
afterEach(async () => {
await db.close();
});
beforeEach(async () => {
db = mockDatabase({
timezone: '+00:00',
});
await db.clean({ drop: true });
const Test = db.collection({
name: 'tests',
fields: [
{
name: 'date1',
type: 'unixTimestamp',
},
{
type: 'string',
name: 'name',
},
],
});
repository = Test.repository;
await db.sync();
});
test('$dateOn', async () => {
await repository.create({
values: [
{
date1: '2023-01-01 00:00:00',
name: 'u0',
},
{
date1: '2023-01-01 00:00:00',
name: 'u1',
},
{
date1: '2022-12-31 16:00:00',
name: 'u2',
},
{
date1: '2022-12-31 16:00:00',
name: 'u3',
},
],
});
let count: number;
count = await repository.count({
filter: {
'date1.$dateOn': '2023-01-01',
},
});
expect(count).toBe(2);
count = await repository.count({
filter: {
'date1.$dateOn': '2022-12-31',
},
});
expect(count).toBe(2);
});
});

View File

@ -127,7 +127,11 @@ describe('view inference', function () {
}); });
const createdAt = UserCollection.model.rawAttributes['createdAt'].field; const createdAt = UserCollection.model.rawAttributes['createdAt'].field;
expect(inferredFields[createdAt]['type']).toBe('date'); if (db.isMySQLCompatibleDialect()) {
expect(inferredFields[createdAt]['type']).toBe('datetimeNoTz');
} else {
expect(inferredFields[createdAt]['type']).toBe('datetimeTz');
}
if (db.options.dialect == 'sqlite') { if (db.options.dialect == 'sqlite') {
expect(inferredFields['name']).toMatchObject({ expect(inferredFields['name']).toMatchObject({

View File

@ -34,7 +34,6 @@ import {
import { SequelizeStorage, Umzug } from 'umzug'; import { SequelizeStorage, Umzug } from 'umzug';
import { Collection, CollectionOptions, RepositoryType } from './collection'; import { Collection, CollectionOptions, RepositoryType } from './collection';
import { CollectionFactory } from './collection-factory'; import { CollectionFactory } from './collection-factory';
import { CollectionGroupManager } from './collection-group-manager';
import { ImporterReader, ImportFileExtension } from './collection-importer'; import { ImporterReader, ImportFileExtension } from './collection-importer';
import DatabaseUtils from './database-utils'; import DatabaseUtils from './database-utils';
import ReferencesMap from './features/references-map'; 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 { ArrayFieldRepository } from './field-repository/array-field-repository';
import * as FieldTypes from './fields'; import * as FieldTypes from './fields';
import { Field, FieldContext, RelationField } from './fields'; import { Field, FieldContext, RelationField } from './fields';
import { checkDatabaseVersion } from './helpers';
import { InheritedCollection } from './inherited-collection'; import { InheritedCollection } from './inherited-collection';
import InheritanceMap from './inherited-map'; import InheritanceMap from './inherited-map';
import { InterfaceManager } from './interface-manager'; 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') { if (options.dialect === 'sqlite') {
delete opts.timezone; delete opts.timezone;
} else if (!opts.timezone) { } else if (!opts.timezone) {
@ -851,7 +852,8 @@ export class Database extends EventEmitter implements AsyncEmitter {
* @internal * @internal
*/ */
async checkVersion() { async checkVersion() {
return await checkDatabaseVersion(this); return true;
// return await checkDatabaseVersion(this);
} }
/** /**

View File

@ -9,9 +9,16 @@
import { DataTypes } from 'sequelize'; import { DataTypes } from 'sequelize';
import { BaseColumnFieldOptions, Field } from './field'; import { BaseColumnFieldOptions, Field } from './field';
import moment from 'moment';
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 { export class DateField extends Field {
get dataType() { get dataType(): any {
return DataTypes.DATE(3); return DataTypes.DATE(3);
} }
@ -33,6 +40,90 @@ export class DateField extends Field {
return props.gmt; 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;
}
};
if (this.options.defaultValue && this.database.isMySQLCompatibleDialect()) {
if (typeof this.options.defaultValue === 'string' && isIso8601(this.options.defaultValue)) {
this.options.defaultValue = moment(this.options.defaultValue)
.utcOffset(this.resolveTimeZone())
.format('YYYY-MM-DD HH:mm:ss');
}
}
}
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;
}
additionalSequelizeOptions() {
const { name } = this.options;
// @ts-ignore
const serverTimeZone = this.database.options.rawTimezone;
return {
get() {
const value = this.getDataValue(name);
if (value === null || value === undefined) {
return value;
}
if (typeof value === 'string' && isValidDatetime(value)) {
const dateString = `${value} ${serverTimeZone}`;
return new Date(dateString);
}
return new Date(value);
},
};
}
bind() { bind() {
super.bind(); super.bind();
@ -51,9 +142,21 @@ export class DateField extends Field {
// @ts-ignore // @ts-ignore
model.refreshAttributes(); model.refreshAttributes();
} }
this.on('beforeSave', this.beforeSave);
}
unbind() {
super.unbind();
this.off('beforeSave', this.beforeSave);
} }
} }
export interface DateFieldOptions extends BaseColumnFieldOptions { export interface DateFieldOptions extends BaseColumnFieldOptions {
type: 'date'; type: 'date';
} }
function isIso8601(str) {
const iso8601StrictRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
return iso8601StrictRegex.test(str);
}

View File

@ -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';
}

View File

@ -0,0 +1,17 @@
/**
* 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 { DateField } from './date-field';
import { BaseColumnFieldOptions } from './field';
export class DatetimeField extends DateField {}
export interface DatetimeFieldOptions extends BaseColumnFieldOptions {
type: 'datetime';
}

View File

@ -0,0 +1,112 @@
/**
* 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';
import moment from 'moment';
class DatetimeNoTzTypeMySQL extends DataTypes.ABSTRACT {
key = 'DATETIME';
}
class DatetimeNoTzTypePostgres extends DataTypes.ABSTRACT {
key = 'TIMESTAMP';
}
export class DatetimeNoTzField extends Field {
get dataType() {
if (this.database.inDialect('postgres')) {
return DatetimeNoTzTypePostgres;
}
if (this.database.isMySQLCompatibleDialect()) {
return DatetimeNoTzTypeMySQL;
}
return DataTypes.STRING;
}
init() {
const { name, defaultToCurrentTime, onUpdateToCurrentTime } = this.options;
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;
}
};
}
additionalSequelizeOptions(): {} {
const { name } = this.options;
// @ts-ignore
const timezone = this.database.options.rawTimezone || '+00:00';
const isPg = this.database.inDialect('postgres');
return {
get() {
const val = this.getDataValue(name);
if (val instanceof Date) {
if (isPg) {
return moment(val).format('YYYY-MM-DD HH:mm:ss');
}
// format to YYYY-MM-DD HH:mm:ss
const momentVal = moment(val).utcOffset(timezone);
return momentVal.format('YYYY-MM-DD HH:mm:ss');
}
return val;
},
set(val) {
if (typeof val === 'string' && isIso8601(val)) {
const momentVal = moment(val).utcOffset(timezone);
val = momentVal.format('YYYY-MM-DD HH:mm:ss');
}
if (val && val instanceof Date) {
// format to YYYY-MM-DD HH:mm:ss
const momentVal = moment(val).utcOffset(timezone);
val = momentVal.format('YYYY-MM-DD HH:mm:ss');
}
return this.setDataValue(name, val);
},
};
}
bind() {
super.bind();
this.on('beforeSave', this.beforeSave);
}
unbind() {
super.unbind();
this.off('beforeSave', this.beforeSave);
}
}
export interface DatetimeNoTzFieldOptions extends BaseColumnFieldOptions {
type: 'datetimeNoTz';
}
function isIso8601(str) {
const iso8601StrictRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
return iso8601StrictRegex.test(str);
}

View File

@ -0,0 +1,17 @@
/**
* 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 { DateField } from './date-field';
import { BaseColumnFieldOptions } from './field';
export class DatetimeTzField extends DateField {}
export interface DatetimeTzFieldOptions extends BaseColumnFieldOptions {
type: 'datetimeTz';
}

View File

@ -56,7 +56,7 @@ export abstract class Field {
return this.options.type; return this.options.type;
} }
abstract get dataType(); abstract get dataType(): any;
isRelationField() { isRelationField() {
return false; return false;
@ -171,11 +171,13 @@ export abstract class Field {
Object.assign(opts, { type: this.database.sequelize.normalizeDataType(this.dataType) }); Object.assign(opts, { type: this.database.sequelize.normalizeDataType(this.dataType) });
} }
Object.assign(opts, this.additionalSequelizeOptions());
return opts; return opts;
} }
isSqlite() { additionalSequelizeOptions() {
return this.database.sequelize.getDialect() === 'sqlite'; return {};
} }
typeToString() { typeToString() {

View File

@ -36,6 +36,10 @@ import { UUIDFieldOptions } from './uuid-field';
import { VirtualFieldOptions } from './virtual-field'; import { VirtualFieldOptions } from './virtual-field';
import { NanoidFieldOptions } from './nanoid-field'; import { NanoidFieldOptions } from './nanoid-field';
import { EncryptionField } from './encryption-field'; import { EncryptionField } from './encryption-field';
import { UnixTimestampFieldOptions } from './unix-timestamp-field';
import { DateOnlyFieldOptions } from './date-only-field';
import { DatetimeNoTzField, DatetimeNoTzFieldOptions } from './datetime-no-tz-field';
import { DatetimeTzFieldOptions } from './datetime-tz-field';
export * from './array-field'; export * from './array-field';
export * from './belongs-to-field'; export * from './belongs-to-field';
@ -43,6 +47,10 @@ export * from './belongs-to-many-field';
export * from './boolean-field'; export * from './boolean-field';
export * from './context-field'; export * from './context-field';
export * from './date-field'; export * from './date-field';
export * from './datetime-field';
export * from './datetime-tz-field';
export * from './datetime-no-tz-field';
export * from './date-only-field';
export * from './field'; export * from './field';
export * from './has-many-field'; export * from './has-many-field';
export * from './has-one-field'; export * from './has-one-field';
@ -61,6 +69,7 @@ export * from './uuid-field';
export * from './virtual-field'; export * from './virtual-field';
export * from './nanoid-field'; export * from './nanoid-field';
export * from './encryption-field'; export * from './encryption-field';
export * from './unix-timestamp-field';
export type FieldOptions = export type FieldOptions =
| BaseFieldOptions | BaseFieldOptions
@ -81,6 +90,10 @@ export type FieldOptions =
| SetFieldOptions | SetFieldOptions
| TimeFieldOptions | TimeFieldOptions
| DateFieldOptions | DateFieldOptions
| DatetimeTzFieldOptions
| DatetimeNoTzFieldOptions
| DateOnlyFieldOptions
| UnixTimestampFieldOptions
| UidFieldOptions | UidFieldOptions
| UUIDFieldOptions | UUIDFieldOptions
| NanoidFieldOptions | NanoidFieldOptions

View File

@ -0,0 +1,84 @@
/**
* 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;
}
dateToValue(val) {
if (val === null || val === undefined) {
return val;
}
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 Math.floor(new Date(val).getTime() / rationalNumber);
}
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: 'unixTimestamp';
}

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * 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 { Model as SequelizeModel, ModelStatic } from 'sequelize';
import { Collection } from './collection'; import { Collection } from './collection';
import { Database } from './database'; import { Database } from './database';
@ -50,6 +50,21 @@ export class Model<TModelAttributes extends {} = any, TCreationAttributes extend
return await runner.runSync(options); return await runner.runSync(options);
} }
static callSetters(values, options) {
// map values
const result = {};
for (const key of Object.keys(values)) {
const field = this.collection.getField(key);
if (field && field.setter) {
result[key] = field.setter.call(field, values[key], options, values, key);
} else {
result[key] = values[key];
}
}
return result;
}
// TODO // TODO
public toChangedWithAssociations() { public toChangedWithAssociations() {
// @ts-ignore // @ts-ignore

View File

@ -9,80 +9,126 @@
import { parseDate } from '@nocobase/utils'; import { parseDate } from '@nocobase/utils';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import moment from 'moment';
function isDate(input) { function isDate(input) {
return input instanceof Date || Object.prototype.toString.call(input) === '[object Date]'; return input instanceof Date || Object.prototype.toString.call(input) === '[object Date]';
} }
const toDate = (date) => { const toDate = (date, options: any = {}) => {
if (isDate(date)) { const { ctx } = options;
return date; const val = isDate(date) ? date : new Date(date);
const field = ctx.db.getFieldByPath(ctx.fieldPath);
if (!field) {
return val;
} }
return new Date(date);
if (field.constructor.name === 'UnixTimestampField') {
return field.dateToValue(val);
}
if (field.constructor.name === 'DatetimeNoTzField') {
return moment(val).utcOffset('+00:00').format('YYYY-MM-DD HH:mm:ss');
}
if (field.constructor.name === 'DateOnlyField') {
return moment(val).format('YYYY-MM-DD HH:mm:ss');
}
return val;
}; };
function parseDateTimezone(ctx) {
const field = ctx.db.getFieldByPath(ctx.fieldPath);
if (!field) {
return ctx.db.options.timezone;
}
if (field.constructor.name === 'DatetimeNoTzField') {
return '+00:00';
}
if (field.constructor.name === 'DateOnlyField') {
return '+00:00';
}
return ctx.db.options.timezone;
}
function isDatetimeString(str) {
return /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(str);
}
export default { export default {
$dateOn(value, ctx) { $dateOn(value, ctx) {
const r = parseDate(value, { const r = parseDate(value, {
timezone: ctx.db.options.timezone, timezone: parseDateTimezone(ctx),
}); });
if (typeof r === 'string') { if (typeof r === 'string') {
return { return {
[Op.eq]: toDate(r), [Op.eq]: toDate(r, { ctx }),
}; };
} }
if (Array.isArray(r)) { if (Array.isArray(r)) {
return { return {
[Op.and]: [{ [Op.gte]: toDate(r[0]) }, { [Op.lt]: toDate(r[1]) }], [Op.and]: [{ [Op.gte]: toDate(r[0], { ctx }) }, { [Op.lt]: toDate(r[1], { ctx }) }],
}; };
} }
throw new Error(`Invalid Date ${JSON.stringify(value)}`); throw new Error(`Invalid Date ${JSON.stringify(value)}`);
}, },
$dateNotOn(value, ctx) { $dateNotOn(value, ctx) {
const r = parseDate(value, { const r = parseDate(value, {
timezone: ctx.db.options.timezone, timezone: parseDateTimezone(ctx),
}); });
if (typeof r === 'string') { if (typeof r === 'string') {
return { return {
[Op.ne]: toDate(r), [Op.ne]: toDate(r, { ctx }),
}; };
} }
if (Array.isArray(r)) { if (Array.isArray(r)) {
return { return {
[Op.or]: [{ [Op.lt]: toDate(r[0]) }, { [Op.gte]: toDate(r[1]) }], [Op.or]: [{ [Op.lt]: toDate(r[0], { ctx }) }, { [Op.gte]: toDate(r[1], { ctx }) }],
}; };
} }
throw new Error(`Invalid Date ${JSON.stringify(value)}`); throw new Error(`Invalid Date ${JSON.stringify(value)}`);
}, },
$dateBefore(value, ctx) { $dateBefore(value, ctx) {
const r = parseDate(value, { const r = parseDate(value, {
timezone: ctx.db.options.timezone, timezone: parseDateTimezone(ctx),
}); });
if (typeof r === 'string') { if (typeof r === 'string') {
return { return {
[Op.lt]: toDate(r), [Op.lt]: toDate(r, { ctx }),
}; };
} else if (Array.isArray(r)) { } else if (Array.isArray(r)) {
return { return {
[Op.lt]: toDate(r[0]), [Op.lt]: toDate(r[0], { ctx }),
}; };
} }
throw new Error(`Invalid Date ${JSON.stringify(value)}`); throw new Error(`Invalid Date ${JSON.stringify(value)}`);
}, },
$dateNotBefore(value, ctx) { $dateNotBefore(value, ctx) {
const r = parseDate(value, { const r = parseDate(value, {
timezone: ctx.db.options.timezone, timezone: parseDateTimezone(ctx),
}); });
if (typeof r === 'string') { if (typeof r === 'string') {
return { return {
[Op.gte]: toDate(r), [Op.gte]: toDate(r, { ctx }),
}; };
} else if (Array.isArray(r)) { } else if (Array.isArray(r)) {
return { return {
[Op.gte]: toDate(r[0]), [Op.gte]: toDate(r[0], { ctx }),
}; };
} }
throw new Error(`Invalid Date ${JSON.stringify(value)}`); throw new Error(`Invalid Date ${JSON.stringify(value)}`);
@ -90,15 +136,15 @@ export default {
$dateAfter(value, ctx) { $dateAfter(value, ctx) {
const r = parseDate(value, { const r = parseDate(value, {
timezone: ctx.db.options.timezone, timezone: parseDateTimezone(ctx),
}); });
if (typeof r === 'string') { if (typeof r === 'string') {
return { return {
[Op.gt]: toDate(r), [Op.gt]: toDate(r, { ctx }),
}; };
} else if (Array.isArray(r)) { } else if (Array.isArray(r)) {
return { return {
[Op.gte]: toDate(r[1]), [Op.gte]: toDate(r[1], { ctx }),
}; };
} }
throw new Error(`Invalid Date ${JSON.stringify(value)}`); throw new Error(`Invalid Date ${JSON.stringify(value)}`);
@ -106,15 +152,15 @@ export default {
$dateNotAfter(value, ctx) { $dateNotAfter(value, ctx) {
const r = parseDate(value, { const r = parseDate(value, {
timezone: ctx.db.options.timezone, timezone: parseDateTimezone(ctx),
}); });
if (typeof r === 'string') { if (typeof r === 'string') {
return { return {
[Op.lte]: toDate(r), [Op.lte]: toDate(r, { ctx }),
}; };
} else if (Array.isArray(r)) { } else if (Array.isArray(r)) {
return { return {
[Op.lt]: toDate(r[1]), [Op.lt]: toDate(r[1], { ctx }),
}; };
} }
throw new Error(`Invalid Date ${JSON.stringify(value)}`); throw new Error(`Invalid Date ${JSON.stringify(value)}`);
@ -122,11 +168,11 @@ export default {
$dateBetween(value, ctx) { $dateBetween(value, ctx) {
const r = parseDate(value, { const r = parseDate(value, {
timezone: ctx.db.options.timezone, timezone: parseDateTimezone(ctx),
}); });
if (r) { if (r) {
return { return {
[Op.and]: [{ [Op.gte]: toDate(r[0]) }, { [Op.lt]: toDate(r[1]) }], [Op.and]: [{ [Op.gte]: toDate(r[0], { ctx }) }, { [Op.lt]: toDate(r[1], { ctx }) }],
}; };
} }
throw new Error(`Invalid Date ${JSON.stringify(value)}`); throw new Error(`Invalid Date ${JSON.stringify(value)}`);

View File

@ -573,7 +573,7 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
underscored: this.collection.options.underscored, underscored: this.collection.options.underscored,
}); });
const values = guard.sanitize(options.values || {}); const values = (this.model as typeof Model).callSetters(guard.sanitize(options.values || {}), options);
const instance = await this.model.create<any>(values, { const instance = await this.model.create<any>(values, {
...options, ...options,
@ -645,7 +645,7 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
const guard = UpdateGuard.fromOptions(this.model, { ...options, underscored: this.collection.options.underscored }); const guard = UpdateGuard.fromOptions(this.model, { ...options, underscored: this.collection.options.underscored });
const values = guard.sanitize(options.values); const values = (this.model as typeof Model).callSetters(guard.sanitize(options.values || {}), options);
// NOTE: // NOTE:
// 1. better to be moved to separated API like bulkUpdate/updateMany // 1. better to be moved to separated API like bulkUpdate/updateMany

View File

@ -8,9 +8,9 @@
*/ */
const postgres = { const postgres = {
'character varying': ['string', 'uuid', 'nanoid', 'encryption'], 'character varying': ['string', 'uuid', 'nanoid', 'encryption', 'datetimeNoTz'],
varchar: ['string', 'uuid', 'nanoid', 'encryption'], varchar: ['string', 'uuid', 'nanoid', 'encryption', 'datetimeNoTz'],
char: ['string', 'uuid', 'nanoid', 'encryption'], char: ['string', 'uuid', 'nanoid', 'encryption', 'datetimeNoTz'],
character: 'string', character: 'string',
text: 'text', text: 'text',
@ -18,18 +18,18 @@ const postgres = {
name: 'string', name: 'string',
smallint: ['integer', 'sort'], smallint: ['integer', 'sort'],
integer: ['integer', 'sort'], integer: ['integer', 'unixTimestamp', 'sort'],
bigint: ['bigInt', 'sort'], bigint: ['bigInt', 'unixTimestamp', 'sort'],
decimal: 'decimal', decimal: 'decimal',
numeric: 'float', numeric: 'float',
real: 'float', real: 'float',
'double precision': 'float', 'double precision': 'float',
'timestamp without time zone': 'date', 'timestamp without time zone': 'datetimeNoTz',
'timestamp with time zone': 'date', 'timestamp with time zone': 'datetimeTz',
'time without time zone': 'time', 'time without time zone': 'time',
date: 'date', date: 'dateOnly',
boolean: 'boolean', boolean: 'boolean',
json: ['json', 'array'], json: ['json', 'array'],
@ -55,24 +55,24 @@ const mysql = {
char: ['string', 'uuid', 'nanoid', 'encryption'], char: ['string', 'uuid', 'nanoid', 'encryption'],
varchar: ['string', 'uuid', 'nanoid', 'encryption'], varchar: ['string', 'uuid', 'nanoid', 'encryption'],
date: 'date', date: 'dateOnly',
time: 'time', time: 'time',
tinytext: 'text', tinytext: 'text',
text: 'text', text: 'text',
mediumtext: 'text', mediumtext: 'text',
longtext: 'text', longtext: 'text',
int: ['integer', 'sort'], int: ['integer', 'unixTimestamp', 'sort'],
'int unsigned': ['integer', 'sort'], 'int unsigned': ['integer', 'unixTimestamp', 'sort'],
integer: ['integer', 'sort'], integer: ['integer', 'unixTimestamp', 'sort'],
bigint: ['bigInt', 'sort'], bigint: ['bigInt', 'unixTimestamp', 'sort'],
'bigint unsigned': ['bigInt', 'sort'], 'bigint unsigned': ['bigInt', 'unixTimestamp', 'sort'],
float: 'float', float: 'float',
double: 'float', double: 'float',
boolean: 'boolean', boolean: 'boolean',
decimal: 'decimal', decimal: 'decimal',
year: ['string', 'integer'], year: ['string', 'integer'],
datetime: 'date', datetime: ['datetimeNoTz', 'datetimeTz'],
timestamp: 'date', timestamp: 'datetimeTz',
json: ['json', 'array'], json: ['json', 'array'],
enum: 'string', enum: 'string',
}; };
@ -84,7 +84,7 @@ const sqlite = {
integer: 'integer', integer: 'integer',
real: 'real', real: 'real',
datetime: 'date', datetime: 'datetimeTz',
date: 'date', date: 'date',
time: 'time', time: 'time',

View File

@ -213,22 +213,19 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
* @internal * @internal
*/ */
public perfHistograms = new Map<string, RecordableHistogram>(); public perfHistograms = new Map<string, RecordableHistogram>();
protected plugins = new Map<string, Plugin>();
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 * @internal
*/ */
public syncManager: SyncManager; public syncManager: SyncManager;
public requestLogger: Logger; public requestLogger: Logger;
protected plugins = new Map<string, Plugin>();
protected _appSupervisor: AppSupervisor = AppSupervisor.getInstance();
private _authenticated = false;
private _maintaining = false;
private _maintainingCommandStatus: MaintainingCommandStatus;
private _maintainingStatusBeforeCommand: MaintainingCommandStatus | null;
private _actionCommand: Command;
private sqlLogger: Logger; private sqlLogger: Logger;
protected _logger: SystemLogger;
constructor(public options: ApplicationOptions) { constructor(public options: ApplicationOptions) {
super(); super();
@ -241,6 +238,8 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
} }
} }
protected _started: Date | null = null;
/** /**
* @experimental * @experimental
*/ */
@ -248,6 +247,8 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
return this._started; return this._started;
} }
protected _logger: SystemLogger;
get logger() { get logger() {
return this._logger; return this._logger;
} }

View File

@ -174,10 +174,13 @@ export function parseDate(value: any, options = {} as { timezone?: string }) {
if (!value) { if (!value) {
return; return;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
return parseDateBetween(value, options); return parseDateBetween(value, options);
} }
let timezone = options.timezone || '+00:00'; let timezone = options.timezone || '+00:00';
const input = value; const input = value;
if (typeof value === 'string') { if (typeof value === 'string') {
const match = /(.+)((\+|\-)\d\d\:\d\d)$/.exec(value); const match = /(.+)((\+|\-)\d\d\:\d\d)$/.exec(value);
@ -232,10 +235,12 @@ function parseDateBetween(value: any, options = {} as { timezone?: string }) {
} }
const match = /(.+)((\+|\-)\d\d\:\d\d)$/.exec(value); const match = /(.+)((\+|\-)\d\d\:\d\d)$/.exec(value);
let timezone = options.timezone || '+00:00'; let timezone = options.timezone || '+00:00';
if (match) { if (match) {
value = match[1]; value = match[1];
timezone = match[2]; timezone = match[2];
} }
const m = /^(\(|\[)(.+)\,(.+)(\)|\])$/.exec(value); const m = /^(\(|\[)(.+)\,(.+)(\)|\])$/.exec(value);
if (!m) { if (!m) {
return; return;

View File

@ -107,6 +107,7 @@ const dateValueWrapper = (value: any, timezone?: string) => {
if (!value) { if (!value) {
return null; return null;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
if (value.length === 2) { if (value.length === 2) {
value.push('[]', timezone); value.push('[]', timezone);
@ -182,6 +183,11 @@ export const parseFilter = async (filter: any, opts: ParseFilterOptions = {}) =>
} }
if (isDateOperator(operator)) { if (isDateOperator(operator)) {
const field = getField?.(path); const field = getField?.(path);
if (field?.constructor.name === 'DateOnlyField' || field?.constructor.name === 'DatetimeNoTzField') {
return value;
}
return dateValueWrapper(value, field?.timezone || timezone); return dateValueWrapper(value, field?.timezone || timezone);
} }
return value; return value;

View File

@ -153,7 +153,7 @@ export const calendarBlockSettings = new SchemaSettings({
return { return {
title: t('End date field'), title: t('End date field'),
value: fieldNames.end, value: fieldNames.end,
options: getCollectionFieldsOptions(name, 'date', { options: getCollectionFieldsOptions(name, ['date', 'datetime', 'dateOnly', 'datetimeNoTz'], {
association: ['o2o', 'obo', 'oho', 'm2o'], association: ['o2o', 'obo', 'oho', 'm2o'],
}), }),
onChange: (end) => { onChange: (end) => {

View File

@ -70,7 +70,7 @@ export const useCreateCalendarBlock = () => {
const createCalendarBlock = async ({ item }) => { const createCalendarBlock = async ({ item }) => {
const stringFieldsOptions = getCollectionFieldsOptions(item.name, 'string', { dataSource: item.dataSource }); const stringFieldsOptions = getCollectionFieldsOptions(item.name, 'string', { dataSource: item.dataSource });
const dateFieldsOptions = getCollectionFieldsOptions(item.name, 'date', { const dateFieldsOptions = getCollectionFieldsOptions(item.name, ['date', 'datetime', 'dateOnly', 'datetimeNoTz'], {
association: ['o2o', 'obo', 'oho', 'm2o'], association: ['o2o', 'obo', 'oho', 'm2o'],
dataSource: item.dataSource, dataSource: item.dataSource,
}); });

View File

@ -75,7 +75,10 @@ test.describe('configure fields', () => {
await addField('Attachment'); await addField('Attachment');
// 添加 date & time 字段 // 添加 date & time 字段
await addField('Datetime'); await addField('Datetime(with time zone)');
await addField('Datetime(without time zone)');
await addField('DateOnly');
await addField('Unix Timestamp');
await addField('Time'); await addField('Time');
// 添加 relation 字段 // 添加 relation 字段

View File

@ -430,8 +430,11 @@ export type FieldInterface =
| 'Markdown' | 'Markdown'
| 'Rich Text' | 'Rich Text'
| 'Attachment' | 'Attachment'
| 'Datetime' | 'Datetime(with time zone)'
| 'Datetime(without time zone)'
| 'Date'
| 'Time' | 'Time'
| 'Unix Timestamp'
| 'One to one (belongs to)' | 'One to one (belongs to)'
| 'One to one (has one)' | 'One to one (has one)'
| 'One to many' | 'One to many'

View File

@ -0,0 +1,51 @@
import Database, { Collection as DBCollection } from '@nocobase/database';
import Application from '@nocobase/server';
import { createApp } from '../index';
describe('datetime', () => {
let db: Database;
let app: Application;
let Collection: DBCollection;
let Field: DBCollection;
beforeEach(async () => {
app = await createApp();
db = app.db;
Collection = db.getCollection('collections');
Field = db.getCollection('fields');
});
afterEach(async () => {
await app.destroy();
});
it('should create datetimeNoTz field', async () => {
await Collection.repository.create({
values: {
name: 'tests',
fields: [
{
type: 'datetimeNoTz',
name: 'date1',
},
],
},
context: {},
});
// @ts-ignore
const agent = app.agent();
const createRes = await agent.resource('tests').create({
values: {
date1: '2023-03-24 12:00:00',
},
});
expect(createRes.status).toBe(200);
// get item
const res = await agent.resource('tests').list();
expect(res.body.data[0].date1).toBe('2023-03-24 12:00:00');
});
});

View File

@ -38,7 +38,7 @@ export const SetFilterTargetKey = (props) => {
const interfaceOptions = app.dataSourceManager.collectionFieldInterfaceManager.getFieldInterface( const interfaceOptions = app.dataSourceManager.collectionFieldInterfaceManager.getFieldInterface(
field.interface, field.interface,
); );
if (interfaceOptions.titleUsable) { if (interfaceOptions?.titleUsable) {
return true; return true;
} }
return false; return false;

View File

@ -11,12 +11,13 @@ import { observer } from '@formily/react';
import { useRecord } from '@nocobase/client'; import { useRecord } from '@nocobase/client';
import { Select, Tag } from 'antd'; import { Select, Tag } from 'antd';
import React from 'react'; import React from 'react';
import { omit } from 'lodash';
export const FieldType = observer( export const FieldType = observer(
(props: any) => { (props: any) => {
const { value, handleFieldChange, onChange } = props; const { value, handleFieldChange, onChange } = props;
const record = useRecord(); const record = useRecord();
const item = record; const item = omit(record, ['__parent', '__collectionName']);
return !item?.possibleTypes ? ( return !item?.possibleTypes ? (
<Tag>{value}</Tag> <Tag>{value}</Tag>
) : ( ) : (

View File

@ -81,13 +81,13 @@ const tableContainer = css`
} }
td, td,
th { th {
flex: 2; flex: 2.3;
width: 0; width: 0;
&:nth-child(5) { &:nth-child(5) {
flex: 1.2; flex: 1.2;
} }
&:last-child { &:last-child {
flex: 1.8; flex: 1.5;
} }
} }
.ant-table-selection-column, .ant-table-selection-column,

View File

@ -366,7 +366,7 @@ export const ganttSettings = new SchemaSettings({
return { return {
title: t('Start date field'), title: t('Start date field'),
value: fieldNames.start, value: fieldNames.start,
options: useOptions('date'), options: useOptions(['date', 'datetime', 'dateOnly', 'datetimeNoTz']),
onChange: (start) => { onChange: (start) => {
const fieldNames = field.decoratorProps.fieldNames || {}; const fieldNames = field.decoratorProps.fieldNames || {};
fieldNames['start'] = start; fieldNames['start'] = start;
@ -398,7 +398,7 @@ export const ganttSettings = new SchemaSettings({
return { return {
title: t('End date field'), title: t('End date field'),
value: fieldNames.end, value: fieldNames.end,
options: useOptions('date'), options: useOptions(['date', 'datetime', 'dateOnly', 'datetimeNoTz']),
onChange: (end) => { onChange: (end) => {
const fieldNames = field.decoratorProps.fieldNames || {}; const fieldNames = field.decoratorProps.fieldNames || {};
fieldNames['end'] = end; fieldNames['end'] = end;

View File

@ -80,7 +80,7 @@ export const useCreateGanttBlock = () => {
}; };
}); });
const dateFields = collectionFields const dateFields = collectionFields
?.filter((field) => field.type === 'date') ?.filter((field) => ['date', 'datetime', 'dateOnly', 'datetimeNoTz'].includes(field.type))
?.map((field) => { ?.map((field) => {
return { return {
label: field?.uiSchema?.title, label: field?.uiSchema?.title,

View File

@ -13,11 +13,17 @@ import { useTranslation } from 'react-i18next';
export const useGanttTranslation = () => { export const useGanttTranslation = () => {
return useTranslation('gantt'); return useTranslation('gantt');
}; };
export const useOptions = (type = 'string') => { export const useOptions = (type: string | string[] = 'string') => {
const compile = useCompile(); const compile = useCompile();
const { fields } = useCollection_deprecated(); const { fields } = useCollection_deprecated();
const options = fields const options = fields
?.filter((field) => field.type === type) ?.filter((field) => {
if (typeof type === 'string') {
return field.type === type;
} else {
return type.includes(field.type);
}
})
?.map((field) => { ?.map((field) => {
return { return {
value: field.name, value: field.name,

View File

@ -565,7 +565,7 @@ test.describe('field data entry', () => {
await page await page
.locator(`button[aria-label^="schema-initializer-Grid-workflowManual:customForm:configureFields-${randomValue}"]`) .locator(`button[aria-label^="schema-initializer-Grid-workflowManual:customForm:configureFields-${randomValue}"]`)
.hover(); .hover();
await page.getByRole('menuitem', { name: 'Datetime', exact: true }).click(); await page.getByRole('menuitem', { name: 'Datetime(with time zone)' }).click();
await page await page
.getByLabel(`block-item-Input-${randomValue}-Field display name`) .getByLabel(`block-item-Input-${randomValue}-Field display name`)
.getByRole('textbox') .getByRole('textbox')