From ef1ded8ff2d1839cc2fd0965fa8e534305f1911d Mon Sep 17 00:00:00 2001 From: ChengLei Shao Date: Thu, 7 Nov 2024 21:05:58 +0800 Subject: [PATCH] fix: import with date field (#5606) * fix: import with dateOnly and datetimeNoTz field * fix: import with date field * fix: export datetime filed * fix: test * fix: test * fix: test * fix: unixtimestamp import * chore: test --- .../antd/date-picker/ReadPretty.tsx | 5 +- .../interfaces/datetime-interface.test.ts | 17 +- .../database/src/interfaces/date-interface.ts | 7 + .../src/interfaces/datetime-interface.ts | 8 +- .../interfaces/datetime-no-tz-interface.ts | 49 ++++++ .../core/database/src/interfaces/index.ts | 2 + .../core/database/src/interfaces/utils.ts | 5 + packages/core/utils/src/date.ts | 2 +- .../server/__tests__/export-to-xlsx.test.ts | 126 ++++++++++++- .../server/__tests__/xlsx-importer.test.ts | 166 +++++++++++++++++- 10 files changed, 348 insertions(+), 39 deletions(-) create mode 100644 packages/core/database/src/interfaces/date-interface.ts create mode 100644 packages/core/database/src/interfaces/datetime-no-tz-interface.ts diff --git a/packages/core/client/src/schema-component/antd/date-picker/ReadPretty.tsx b/packages/core/client/src/schema-component/antd/date-picker/ReadPretty.tsx index 58e1e60e22..bd91927d11 100644 --- a/packages/core/client/src/schema-component/antd/date-picker/ReadPretty.tsx +++ b/packages/core/client/src/schema-component/antd/date-picker/ReadPretty.tsx @@ -10,11 +10,11 @@ import { usePrefixCls } from '@formily/antd-v5/esm/__builtins__'; import { isArr } from '@formily/shared'; import { + getDefaultFormat, GetDefaultFormatProps, + str2moment, Str2momentOptions, Str2momentValue, - getDefaultFormat, - str2moment, } from '@nocobase/utils/client'; import cls from 'classnames'; import dayjs from 'dayjs'; @@ -67,6 +67,7 @@ ReadPretty.DateRangePicker = function DateRangePicker(props: DateRangePickerRead const labels = m.map((m) => m.format(format)); return isArr(labels) ? labels.join('~') : labels; }; + return (
{getLabels()} diff --git a/packages/core/database/src/__tests__/interfaces/datetime-interface.test.ts b/packages/core/database/src/__tests__/interfaces/datetime-interface.test.ts index e5a0deab12..621c090e40 100644 --- a/packages/core/database/src/__tests__/interfaces/datetime-interface.test.ts +++ b/packages/core/database/src/__tests__/interfaces/datetime-interface.test.ts @@ -39,7 +39,7 @@ describe('Date time interface', () => { }, { name: 'dateTime', - type: 'date', + type: 'datetime', uiSchema: { ['x-component-props']: { showTime: true, @@ -74,20 +74,5 @@ describe('Date time interface', () => { expect(await interfaceInstance.toValue(42510)).toBe('2016-05-20T00:00:00.000Z'); expect(await interfaceInstance.toValue('42510')).toBe('2016-05-20T00:00:00.000Z'); expect(await interfaceInstance.toValue('2016-05-20T00:00:00.000Z')).toBe('2016-05-20T00:00:00.000Z'); - expect( - await interfaceInstance.toValue('2016-05-20 04:22:22', { - field: testCollection.getField('dateOnly'), - }), - ).toBe('2016-05-20T00:00:00.000Z'); - expect( - await interfaceInstance.toValue('2016-05-20 01:00:00', { - field: testCollection.getField('dateTime'), - }), - ).toBe(dayjs('2016-05-20 01:00:00').toISOString()); - expect( - await interfaceInstance.toValue('2016-05-20 01:00:00', { - field: testCollection.getField('dateTimeGmt'), - }), - ).toBe('2016-05-20T01:00:00.000Z'); }); }); diff --git a/packages/core/database/src/interfaces/date-interface.ts b/packages/core/database/src/interfaces/date-interface.ts new file mode 100644 index 0000000000..6b8aedcdb6 --- /dev/null +++ b/packages/core/database/src/interfaces/date-interface.ts @@ -0,0 +1,7 @@ +import { DatetimeInterface } from './datetime-interface'; + +export class DateInterface extends DatetimeInterface { + toString(value: any, ctx?: any): any { + return value; + } +} diff --git a/packages/core/database/src/interfaces/datetime-interface.ts b/packages/core/database/src/interfaces/datetime-interface.ts index fed3a38753..85661d5933 100644 --- a/packages/core/database/src/interfaces/datetime-interface.ts +++ b/packages/core/database/src/interfaces/datetime-interface.ts @@ -8,7 +8,7 @@ */ import { BaseInterface } from './base-interface'; -import { getDefaultFormat, moment2str, str2moment } from '@nocobase/utils'; +import { getDefaultFormat, str2moment } from '@nocobase/utils'; import dayjs from 'dayjs'; import { getJsDateFromExcel } from 'excel-date-to-js'; @@ -51,11 +51,7 @@ export class DatetimeInterface extends BaseInterface { } else if (isNumeric(value)) { return getJsDateFromExcel(value).toISOString(); } else if (typeof value === 'string') { - const props = ctx.field?.options?.uiSchema?.['x-component-props'] || {}; - const m = dayjs(value); - if (m.isValid()) { - return moment2str(m, props); - } + return value; } throw new Error(`Invalid date - ${value}`); diff --git a/packages/core/database/src/interfaces/datetime-no-tz-interface.ts b/packages/core/database/src/interfaces/datetime-no-tz-interface.ts new file mode 100644 index 0000000000..d2d4a7d9d0 --- /dev/null +++ b/packages/core/database/src/interfaces/datetime-no-tz-interface.ts @@ -0,0 +1,49 @@ +import { DatetimeInterface } from './datetime-interface'; +import dayjs from 'dayjs'; +import { getJsDateFromExcel } from 'excel-date-to-js'; +import { getDefaultFormat, str2moment } from '@nocobase/utils'; + +function isDate(v) { + return v instanceof Date; +} + +function isNumeric(str: any) { + if (typeof str === 'number') return true; + if (typeof str != 'string') return false; + return !isNaN(str as any) && !isNaN(parseFloat(str)); +} + +export class DatetimeNoTzInterface extends DatetimeInterface { + async toValue(value: any, ctx: any = {}): Promise { + if (!value) { + return null; + } + + if (typeof value === 'string') { + const match = /^(\d{4})[-/]?(\d{2})[-/]?(\d{2})$/.exec(value); + if (match) { + return `${match[1]}-${match[2]}-${match[3]}`; + } + } + + if (dayjs.isDayjs(value)) { + return value; + } else if (isDate(value)) { + return value; + } else if (isNumeric(value)) { + const date = getJsDateFromExcel(value); + return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; + } else if (typeof value === 'string') { + return value; + } + + throw new Error(`Invalid date - ${value}`); + } + + toString(value: any, ctx?: any) { + const props = this.options?.uiSchema?.['x-component-props'] ?? {}; + const format = getDefaultFormat(props); + const m = str2moment(value, { ...props }); + return m ? m.format(format) : ''; + } +} diff --git a/packages/core/database/src/interfaces/index.ts b/packages/core/database/src/interfaces/index.ts index 00eba5d9d8..038ada52cc 100644 --- a/packages/core/database/src/interfaces/index.ts +++ b/packages/core/database/src/interfaces/index.ts @@ -12,4 +12,6 @@ export * from './percent-interface'; export * from './multiple-select-interface'; export * from './select-interface'; export * from './datetime-interface'; +export * from './datetime-no-tz-interface'; export * from './boolean-interface'; +export * from './date-interface'; diff --git a/packages/core/database/src/interfaces/utils.ts b/packages/core/database/src/interfaces/utils.ts index e23ac2e49f..170687f6e5 100644 --- a/packages/core/database/src/interfaces/utils.ts +++ b/packages/core/database/src/interfaces/utils.ts @@ -10,7 +10,9 @@ import Database from '../database'; import { BooleanInterface, + DateInterface, DatetimeInterface, + DatetimeNoTzInterface, MultipleSelectInterface, PercentInterface, SelectInterface, @@ -36,6 +38,9 @@ const interfaces = { radioGroup: SelectInterface, percent: PercentInterface, datetime: DatetimeInterface, + datetimeNoTz: DatetimeNoTzInterface, + unixTimestamp: DatetimeInterface, + date: DateInterface, createdAt: DatetimeInterface, updatedAt: DatetimeInterface, boolean: BooleanInterface, diff --git a/packages/core/utils/src/date.ts b/packages/core/utils/src/date.ts index 731c23a1f9..d35fe611e3 100644 --- a/packages/core/utils/src/date.ts +++ b/packages/core/utils/src/date.ts @@ -78,7 +78,7 @@ const toMoment = (val: any, options?: Str2momentOptions) => { if (!val) { return; } - const offset = options.utcOffset || -1 * new Date().getTimezoneOffset(); + const offset = options.utcOffset !== undefined ? options.utcOffset : -1 * new Date().getTimezoneOffset(); const { gmt, picker, utc = true } = options; if (dayjs(val).isValid()) { if (!utc) { diff --git a/packages/plugins/@nocobase/plugin-action-export/src/server/__tests__/export-to-xlsx.test.ts b/packages/plugins/@nocobase/plugin-action-export/src/server/__tests__/export-to-xlsx.test.ts index 01276726e6..78eb6f3808 100644 --- a/packages/plugins/@nocobase/plugin-action-export/src/server/__tests__/export-to-xlsx.test.ts +++ b/packages/plugins/@nocobase/plugin-action-export/src/server/__tests__/export-to-xlsx.test.ts @@ -31,6 +31,122 @@ describe('export to xlsx with preset', () => { await app.destroy(); }); + describe('export with date field', () => { + let Post; + + beforeEach(async () => { + Post = app.db.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { + name: 'datetime', + type: 'datetime', + interface: 'datetime', + uiSchema: { + 'x-component-props': { picker: 'date', dateFormat: 'YYYY-MM-DD', gmt: false, showTime: false, utc: true }, + type: 'string', + 'x-component': 'DatePicker', + title: 'dateTz', + }, + }, + { + name: 'dateOnly', + type: 'dateOnly', + interface: 'date', + defaultToCurrentTime: false, + onUpdateToCurrentTime: false, + timezone: true, + }, + { + name: 'datetimeNoTz', + type: 'datetimeNoTz', + interface: 'datetimeNoTz', + uiSchema: { + 'x-component-props': { picker: 'date', dateFormat: 'YYYY-MM-DD', gmt: false, showTime: false, utc: true }, + type: 'string', + 'x-component': 'DatePicker', + title: 'dateTz', + }, + }, + { + name: 'unixTimestamp', + type: 'unixTimestamp', + interface: 'unixTimestamp', + uiSchema: { + 'x-component-props': { + picker: 'date', + dateFormat: 'YYYY-MM-DD', + showTime: true, + timeFormat: 'HH:mm:ss', + }, + }, + }, + ], + }); + + await app.db.sync(); + }); + + it('should export with datetime field', async () => { + await Post.repository.create({ + values: { + title: 'p1', + datetime: '2024-05-10T01:42:35.000Z', + dateOnly: '2024-05-10', + datetimeNoTz: '2024-01-01 00:00:00', + unixTimestamp: '2024-05-10T01:42:35.000Z', + }, + }); + + const exporter = new XlsxExporter({ + collectionManager: app.mainDataSource.collectionManager, + collection: Post, + chunkSize: 10, + columns: [ + { dataIndex: ['title'], defaultTitle: 'Title' }, + { + dataIndex: ['datetime'], + defaultTitle: 'datetime', + }, + { + dataIndex: ['dateOnly'], + defaultTitle: 'dateOnly', + }, + { + dataIndex: ['datetimeNoTz'], + defaultTitle: 'datetimeNoTz', + }, + { + dataIndex: ['unixTimestamp'], + defaultTitle: 'unixTimestamp', + }, + ], + }); + + const wb = await exporter.run(); + + const xlsxFilePath = path.resolve(__dirname, `t_${uid()}.xlsx`); + + try { + XLSX.writeFile(wb, xlsxFilePath); + + // read xlsx file + const workbook = XLSX.readFile(xlsxFilePath); + const firstSheet = workbook.Sheets[workbook.SheetNames[0]]; + const sheetData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 }); + + const firstUser = sheetData[1]; + expect(firstUser[1]).toEqual('2024-05-10'); + expect(firstUser[2]).toEqual('2024-05-10'); + expect(firstUser[3]).toEqual('2024-01-01'); + expect(firstUser[4]).toEqual('2024-05-10 01:42:35'); + } finally { + fs.unlinkSync(xlsxFilePath); + } + }); + }); + it('should export with checkbox field', async () => { const Post = app.db.collection({ name: 'posts', @@ -520,7 +636,7 @@ describe('export to xlsx', () => { title: 'test_date', }, name: 'test_date', - type: 'date', + type: 'datetime', interface: 'datetime', }, ], @@ -548,11 +664,7 @@ describe('export to xlsx', () => { ], }); - const wb = await exporter.run({ - get() { - return '+08:00'; - }, - }); + const wb = await exporter.run(); const xlsxFilePath = path.resolve(__dirname, `t_${uid()}.xlsx`); try { @@ -564,7 +676,7 @@ describe('export to xlsx', () => { const sheetData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 }); const firstUser = sheetData[1]; - expect(firstUser).toEqual(['some_title', '2024-05-10 09:42:35']); + expect(firstUser).toEqual(['some_title', '2024-05-10 01:42:35']); } finally { fs.unlinkSync(xlsxFilePath); } diff --git a/packages/plugins/@nocobase/plugin-action-import/src/server/__tests__/xlsx-importer.test.ts b/packages/plugins/@nocobase/plugin-action-import/src/server/__tests__/xlsx-importer.test.ts index f8f0d0beff..031c1f493f 100644 --- a/packages/plugins/@nocobase/plugin-action-import/src/server/__tests__/xlsx-importer.test.ts +++ b/packages/plugins/@nocobase/plugin-action-import/src/server/__tests__/xlsx-importer.test.ts @@ -38,24 +38,143 @@ describe('xlsx importer', () => { name: 'name', }, { - type: 'date', - name: 'date', + type: 'datetime', + name: 'datetime', interface: 'datetime', }, + { + type: 'datetimeNoTz', + name: 'datetimeNoTz', + interface: 'datetimeNoTz', + uiSchema: { + 'x-component-props': { + picker: 'date', + dateFormat: 'YYYY-MM-DD', + showTime: true, + timeFormat: 'HH:mm:ss', + }, + }, + }, + { + type: 'dateOnly', + name: 'dateOnly', + interface: 'date', + }, + { + type: 'unixTimestamp', + name: 'unixTimestamp', + interface: 'unixTimestamp', + uiSchema: { + 'x-component-props': { + picker: 'date', + dateFormat: 'YYYY-MM-DD', + showTime: true, + timeFormat: 'HH:mm:ss', + }, + }, + }, ], }); await app.db.sync(); }); - it('should import with date', async () => { + it('should import with dateOnly', async () => { const columns = [ { dataIndex: ['name'], defaultTitle: '姓名', }, { - dataIndex: ['date'], + dataIndex: ['dateOnly'], + defaultTitle: '日期', + }, + ]; + + const templateCreator = new TemplateCreator({ + collection: User, + columns, + }); + + const template = await templateCreator.run(); + + const worksheet = template.Sheets[template.SheetNames[0]]; + + XLSX.utils.sheet_add_aoa( + worksheet, + [ + ['test', 77383], + ['test2', '2021-10-18'], + ], + { origin: 'A2' }, + ); + + const importer = new XlsxImporter({ + collectionManager: app.mainDataSource.collectionManager, + collection: User, + columns, + workbook: template, + }); + + await importer.run(); + + const users = (await User.repository.find()).map((user) => user.toJSON()); + expect(users[0]['dateOnly']).toBe('2111-11-12'); + expect(users[1]['dateOnly']).toBe('2021-10-18'); + }); + + it.skipIf(process.env['DB_DIALECT'] === 'sqlite')('should import with datetimeNoTz', async () => { + const columns = [ + { + dataIndex: ['name'], + defaultTitle: '姓名', + }, + { + dataIndex: ['datetimeNoTz'], + defaultTitle: '日期', + }, + ]; + + const templateCreator = new TemplateCreator({ + collection: User, + columns, + }); + + const template = await templateCreator.run(); + + const worksheet = template.Sheets[template.SheetNames[0]]; + + XLSX.utils.sheet_add_aoa( + worksheet, + [ + ['test', 77383], + ['test2', '2021-10-18'], + ], + { origin: 'A2' }, + ); + + const importer = new XlsxImporter({ + collectionManager: app.mainDataSource.collectionManager, + collection: User, + columns, + workbook: template, + }); + + await importer.run(); + + const users = (await User.repository.find()).map((user) => user.toJSON()); + expect(users[0]['datetimeNoTz']).toBe('2111-11-12 00:00:00'); + expect(users[1]['datetimeNoTz']).toBe('2021-10-18 00:00:00'); + }); + + it('should import with unixTimestamp', async () => { + const columns = [ + { + dataIndex: ['name'], + defaultTitle: '姓名', + }, + { + dataIndex: ['unixTimestamp'], defaultTitle: '日期', }, ]; @@ -80,11 +199,44 @@ describe('xlsx importer', () => { await importer.run(); - expect(await User.repository.count()).toBe(1); + const users = (await User.repository.find()).map((user) => user.toJSON()); + expect(moment(users[0]['unixTimestamp']).toISOString()).toEqual('2111-11-12T00:00:00.000Z'); + }); - const user = await User.repository.findOne(); + it('should import with datetimeTz', async () => { + const columns = [ + { + dataIndex: ['name'], + defaultTitle: '姓名', + }, + { + dataIndex: ['datetime'], + defaultTitle: '日期', + }, + ]; - expect(moment(user.get('date')).format('YYYY-MM-DD')).toBe('2111-11-12'); + const templateCreator = new TemplateCreator({ + collection: User, + columns, + }); + + const template = await templateCreator.run(); + + const worksheet = template.Sheets[template.SheetNames[0]]; + + XLSX.utils.sheet_add_aoa(worksheet, [['test', 77383]], { origin: 'A2' }); + + const importer = new XlsxImporter({ + collectionManager: app.mainDataSource.collectionManager, + collection: User, + columns, + workbook: template, + }); + + await importer.run(); + + const users = (await User.repository.find()).map((user) => user.toJSON()); + expect(moment(users[0]['datetime']).toISOString()).toEqual('2111-11-12T00:00:00.000Z'); }); });