diff --git a/packages/nc-gui/components.d.ts b/packages/nc-gui/components.d.ts index d48defb626..bb95e0722e 100644 --- a/packages/nc-gui/components.d.ts +++ b/packages/nc-gui/components.d.ts @@ -170,8 +170,6 @@ declare module '@vue/runtime-core' { MdiExport: typeof import('~icons/mdi/export')['default'] MdiEyeCircleOutline: typeof import('~icons/mdi/eye-circle-outline')['default'] MdiEyeOffOutline: typeof import('~icons/mdi/eye-off-outline')['default'] - MdiEyeSettings: typeof import('~icons/mdi/eye-settings')['default'] - MdiEyeSettingsOutline: typeof import('~icons/mdi/eye-settings-outline')['default'] MdiFileDocumentOutline: typeof import('~icons/mdi/file-document-outline')['default'] MdiFileExcel: typeof import('~icons/mdi/file-excel')['default'] MdiFileEyeOutline: typeof import('~icons/mdi/file-eye-outline')['default'] @@ -203,7 +201,6 @@ declare module '@vue/runtime-core' { MdiMagnify: typeof import('~icons/mdi/magnify')['default'] MdiMenu: typeof import('~icons/mdi/menu')['default'] MdiMenuDown: typeof import('~icons/mdi/menu-down')['default'] - MdiMenuIcon: typeof import('~icons/mdi/menu-icon')['default'] MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['default'] MdiMinusCircleOutline: typeof import('~icons/mdi/minus-circle-outline')['default'] MdiMoonFull: typeof import('~icons/mdi/moon-full')['default'] diff --git a/packages/nc-gui/components/cell/DateTimePicker.vue b/packages/nc-gui/components/cell/DateTimePicker.vue index 8ad758b8b8..af47b62e02 100644 --- a/packages/nc-gui/components/cell/DateTimePicker.vue +++ b/packages/nc-gui/components/cell/DateTimePicker.vue @@ -2,10 +2,13 @@ import dayjs from 'dayjs' import { ActiveCellInj, + ColumnInj, ReadonlyInj, + dateFormats, inject, isDrawerOrModalExist, ref, + timeFormats, useProject, useSelectedCellKeyupListener, watch, @@ -32,7 +35,11 @@ const column = inject(ColumnInj)! let isDateInvalid = $ref(false) -const dateFormat = isMysql(column.value.base_id) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ' +const dateTimeFormat = $computed(() => { + const dateFormat = column?.value?.meta?.date_format ?? dateFormats[0] + const timeFormat = column?.value?.meta?.time_format ?? timeFormats[0] + return `${dateFormat} ${timeFormat}` +}) let localState = $computed({ get() { @@ -54,7 +61,7 @@ let localState = $computed({ } if (val.isValid()) { - emit('update:modelValue', val?.format(dateFormat)) + emit('update:modelValue', val?.format(isMysql(column.value.base_id) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ')) } }, }) @@ -165,7 +172,7 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => { :show-time="true" :bordered="false" class="!w-full !px-0 !border-none" - format="YYYY-MM-DD HH:mm" + :format="dateTimeFormat" :placeholder="isDateInvalid ? 'Invalid date' : ''" :allow-clear="!readOnly && !localState && !isPk" :input-read-only="true" diff --git a/packages/nc-gui/components/smartsheet/column/DateTimeOptions.vue b/packages/nc-gui/components/smartsheet/column/DateTimeOptions.vue new file mode 100644 index 0000000000..152b504fd4 --- /dev/null +++ b/packages/nc-gui/components/smartsheet/column/DateTimeOptions.vue @@ -0,0 +1,38 @@ + + + diff --git a/packages/nc-gui/components/smartsheet/column/EditOrAdd.vue b/packages/nc-gui/components/smartsheet/column/EditOrAdd.vue index f7a63fe1de..cfe70bae07 100644 --- a/packages/nc-gui/components/smartsheet/column/EditOrAdd.vue +++ b/packages/nc-gui/components/smartsheet/column/EditOrAdd.vue @@ -177,6 +177,7 @@ useEventListener('keydown', (e: KeyboardEvent) => { + { + if (!validateDateWithUnknownFormat(v)) { + typeErrors.add('The first parameter of DATETIME_DIFF() should have date value') + } + }, + typeErrors, + ) + // parsedTree.arguments[1] = date + validateAgainstType( + parsedTree.arguments[1], + formulaTypes.DATE, + (v: any) => { + if (!validateDateWithUnknownFormat(v)) { + typeErrors.add('The second parameter of DATETIME_DIFF() should have date value') + } + }, + typeErrors, + ) + // parsedTree.arguments[2] = ["milliseconds" | "ms" | "seconds" | "s" | "minutes" | "m" | "hours" | "h" | "days" | "d" | "weeks" | "w" | "months" | "M" | "quarters" | "Q" | "years" | "y"] + validateAgainstType( + parsedTree.arguments[2], + formulaTypes.STRING, + (v: any) => { + if ( + ![ + 'milliseconds', + 'ms', + 'seconds', + 's', + 'minutes', + 'm', + 'hours', + 'h', + 'days', + 'd', + 'weeks', + 'w', + 'months', + 'M', + 'quarters', + 'Q', + 'years', + 'y', + ].includes(v) + ) { + typeErrors.add( + 'The third parameter of DATETIME_DIFF() should have value either "milliseconds", "ms", "seconds", "s", "minutes", "m", "hours", "h", "days", "d", "weeks", "w", "months", "M", "quarters", "Q", "years", or "y"', + ) + } + }, + typeErrors, + ) } } } diff --git a/packages/nc-gui/utils/dateTimeUtils.ts b/packages/nc-gui/utils/dateTimeUtils.ts index 674b063854..803936ebdb 100644 --- a/packages/nc-gui/utils/dateTimeUtils.ts +++ b/packages/nc-gui/utils/dateTimeUtils.ts @@ -5,17 +5,19 @@ export const timeAgo = (date: any) => { } export const dateFormats = [ + 'YYYY-MM-DD', + 'YYYY/MM/DD', 'DD-MM-YYYY', 'MM-DD-YYYY', - 'YYYY-MM-DD', 'DD/MM/YYYY', 'MM/DD/YYYY', - 'YYYY/MM/DD', 'DD MM YYYY', 'MM DD YYYY', 'YYYY MM DD', ] +export const timeFormats = ['HH:mm', 'HH:mm:ss'] + export const handleTZ = (val: any) => { if (val === undefined || val === null) { return @@ -60,7 +62,7 @@ export function getDateFormat(v: string) { export function getDateTimeFormat(v: string) { for (const format of dateFormats) { - for (const timeFormat of ['HH:mm', 'HH:mm:ss', 'HH:mm:ss.SSS']) { + for (const timeFormat of timeFormats) { const dateTimeFormat = `${format} ${timeFormat}` if (dayjs(v, dateTimeFormat, true).isValid() as any) { return dateTimeFormat diff --git a/packages/nc-gui/utils/formulaUtils.ts b/packages/nc-gui/utils/formulaUtils.ts index e49655338f..5c1cdf99be 100644 --- a/packages/nc-gui/utils/formulaUtils.ts +++ b/packages/nc-gui/utils/formulaUtils.ts @@ -51,6 +51,29 @@ const formulas: Record = { 'DATEADD({column1}, -2, "year")', ], }, + DATETIME_DIFF: { + type: formulaTypes.DATE, + validation: { + args: { + min: 2, + max: 3, + }, + }, + description: 'Calculate the difference of two given date / datetime in specified units.', + syntax: + 'DATETIME_DIFF(date | datetime, date | datetime, ["milliseconds" | "ms" | "seconds" | "s" | "minutes" | "m" | "hours" | "h" | "days" | "d" | "weeks" | "w" | "months" | "M" | "quarters" | "Q" | "years" | "y"])', + examples: [ + 'DATEDIFF({column1}, {column2})', + 'DATEDIFF({column1}, {column2}, "seconds")', + 'DATEDIFF({column1}, {column2}, "s")', + 'DATEDIFF({column1}, {column2}, "years")', + 'DATEDIFF({column1}, {column2}, "y")', + 'DATEDIFF({column1}, {column2}, "minutes")', + 'DATEDIFF({column1}, {column2}, "m")', + 'DATEDIFF({column1}, {column2}, "days")', + 'DATEDIFF({column1}, {column2}, "d")', + ], + }, AND: { type: formulaTypes.COND_EXP, validation: { diff --git a/packages/noco-docs/content/en/setup-and-usages/formulas.md b/packages/noco-docs/content/en/setup-and-usages/formulas.md index 6d39cb250d..5a1d7c3a1c 100644 --- a/packages/noco-docs/content/en/setup-and-usages/formulas.md +++ b/packages/noco-docs/content/en/setup-and-usages/formulas.md @@ -97,6 +97,8 @@ Example: ({Column1} + ({Column2} * {Column3}) / (3 - $Column4$ )) | | | `DATEADD(date, 1, 'year')` | Supposing {DATE_COL} is 2022-03-14 03:14. The result is 2023-03-14 03:14. | DateTime columns and negative values are supported. Example: `DATEADD(DATE_TIME_COL, -1, 'year')` | | | | `IF(NOW() < DATEADD(date,10,'day'), "true", "false")` | If the current date is less than {DATE_COL} plus 10 days, it returns true. Otherwise, it returns false. | DateTime columns and negative values are supported. | | | | `IF(NOW() < DATEADD(date,10,'day'), "true", "false")` | If the current date is less than {DATE_COL} plus 10 days, it returns true. Otherwise, it returns false. | DateTime columns and negative values are supported. | +| **DATETIME_DIFF** | `DATETIME_DIFF(date, date, ["milliseconds" \| "ms" \| "seconds" \| "s" \| "minutes" \| "m" \| "hours" \| "h" \| "days" \| "d" \| "weeks" \| "w" \| "months" \| "M" \| "quarters" \| "Q" \| "years" \| "y"])` | `DATETIME_DIFF("2022/10/14", "2022/10/15", "second")` | Supposing {DATE_COL_1} is 2017-08-25 and {DATE_COL_2} is 2011-08-25. The result is 86400. | Compares two dates and returns the difference in the unit specified. Positive integers indicate the second date being in the past compared to the first and vice versa for negative ones. | +| | | `WEEKDAY(NOW(), "sunday")` | If today is Monday, it returns 1 | Get the week day of NOW() with the first day set as sunday | | **WEEKDAY** | `WEEKDAY(date, [startDayOfWeek])` | `WEEKDAY(NOW())` | If today is Monday, it returns 0 | Returns the day of the week as an integer between 0 and 6 inclusive starting from Monday by default. You can optionally change the start day of the week by specifying in the second argument | | | | `WEEKDAY(NOW(), "sunday")` | If today is Monday, it returns 1 | Get the week day of NOW() with the first day set as sunday | diff --git a/packages/nocodb-sdk/src/lib/formulaHelpers.ts b/packages/nocodb-sdk/src/lib/formulaHelpers.ts index 94f22b79b5..ffa7964488 100644 --- a/packages/nocodb-sdk/src/lib/formulaHelpers.ts +++ b/packages/nocodb-sdk/src/lib/formulaHelpers.ts @@ -132,6 +132,7 @@ export function jsepTreeToFormula(node) { 'AVG', 'ADD', 'DATEADD', + 'DATETIME_DIFF', 'WEEKDAY', 'AND', 'OR', diff --git a/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mssql.ts b/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mssql.ts index 903478af5f..58068e6c79 100644 --- a/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mssql.ts +++ b/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mssql.ts @@ -1,6 +1,7 @@ import dayjs from 'dayjs'; import { MapFnArgs } from '../mapFunctionName'; import commonFns from './commonFns'; +import { convertUnits } from '../helpers/convertUnits'; import { getWeekdayByText } from '../helpers/formulaFnHelper'; const mssql = { @@ -110,6 +111,17 @@ const mssql = { END${colAlias}` ); }, + DATETIME_DIFF: ({ fn, knex, pt, colAlias }: MapFnArgs) => { + const datetime_expr1 = fn(pt.arguments[0]); + const datetime_expr2 = fn(pt.arguments[1]); + const rawUnit = pt.arguments[2] + ? fn(pt.arguments[2]).bindings[0] + : 'seconds'; + const unit = convertUnits(rawUnit, 'mssql'); + return knex.raw( + `DATEDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) ${colAlias}` + ); + }, WEEKDAY: ({ fn, knex, pt, colAlias }: MapFnArgs) => { // DATEPART(WEEKDAY, DATE): sunday = 1, monday = 2, ..., saturday = 7 // WEEKDAY() returns an index from 0 to 6 for Monday to Sunday diff --git a/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mysql.ts b/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mysql.ts index ab1acb763b..2512437c17 100644 --- a/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mysql.ts +++ b/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mysql.ts @@ -1,6 +1,7 @@ import dayjs from 'dayjs'; import { MapFnArgs } from '../mapFunctionName'; import commonFns from './commonFns'; +import { convertUnits } from '../helpers/convertUnits'; import { getWeekdayByText } from '../helpers/formulaFnHelper'; const mysql2 = { @@ -61,6 +62,26 @@ const mysql2 = { END${colAlias}` ); }, + DATETIME_DIFF: ({ fn, knex, pt, colAlias }: MapFnArgs) => { + const datetime_expr1 = fn(pt.arguments[0]); + const datetime_expr2 = fn(pt.arguments[1]); + + const unit = convertUnits( + pt.arguments[2] ? fn(pt.arguments[2]).bindings[0] : 'seconds', + 'mysql' + ); + + if (unit === 'MICROSECOND') { + // MySQL doesn't support millisecond + // hence change from MICROSECOND to millisecond manually + return knex.raw( + `TIMESTAMPDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) div 1000 ${colAlias}` + ); + } + return knex.raw( + `TIMESTAMPDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) ${colAlias}` + ); + }, WEEKDAY: ({ fn, knex, pt, colAlias }: MapFnArgs) => { // WEEKDAY() returns an index from 0 to 6 for Monday to Sunday return knex.raw( diff --git a/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/pg.ts b/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/pg.ts index f24ec33598..72ee49bcfa 100644 --- a/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/pg.ts +++ b/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/pg.ts @@ -1,6 +1,7 @@ import dayjs from 'dayjs'; import { MapFnArgs } from '../mapFunctionName'; import commonFns from './commonFns'; +import { convertUnits } from '../helpers/convertUnits'; import { getWeekdayByText } from '../helpers/formulaFnHelper'; const pg = { @@ -50,6 +51,56 @@ const pg = { )}')::interval${colAlias}` ); }, + DATETIME_DIFF: ({ fn, knex, pt, colAlias }: MapFnArgs) => { + const datetime_expr1 = fn(pt.arguments[0]); + const datetime_expr2 = fn(pt.arguments[1]); + const rawUnit = pt.arguments[2] + ? fn(pt.arguments[2]).bindings[0] + : 'seconds'; + let sql; + const unit = convertUnits(rawUnit, 'pg'); + switch (unit) { + case 'second': + sql = `EXTRACT(EPOCH from (${datetime_expr1}::TIMESTAMP - ${datetime_expr2}::TIMESTAMP))::INTEGER`; + break; + case 'minute': + sql = `EXTRACT(EPOCH from (${datetime_expr1}::TIMESTAMP - ${datetime_expr2}::TIMESTAMP))::INTEGER / 60`; + break; + case 'milliseconds': + sql = `EXTRACT(EPOCH from (${datetime_expr1}::TIMESTAMP - ${datetime_expr2}::TIMESTAMP))::INTEGER * 1000`; + break; + case 'hour': + sql = `EXTRACT(EPOCH from (${datetime_expr1}::TIMESTAMP - ${datetime_expr2}::TIMESTAMP))::INTEGER / 3600`; + break; + case 'week': + sql = `TRUNC(DATE_PART('day', ${datetime_expr1}::TIMESTAMP - ${datetime_expr2}::TIMESTAMP) / 7)`; + break; + case 'month': + sql = `( + DATE_PART('year', ${datetime_expr1}::TIMESTAMP) - + DATE_PART('year', ${datetime_expr2}::TIMESTAMP) + ) * 12 + ( + DATE_PART('month', ${datetime_expr1}::TIMESTAMP) - + DATE_PART('month', ${datetime_expr2}::TIMESTAMP) + )`; + break; + case 'quarter': + sql = `((EXTRACT(QUARTER FROM ${datetime_expr1}::TIMESTAMP) + + DATE_PART('year', AGE(${datetime_expr1}, '1900/01/01')) * 4) - 1) - + ((EXTRACT(QUARTER FROM ${datetime_expr2}::TIMESTAMP) + + DATE_PART('year', AGE(${datetime_expr2}, '1900/01/01')) * 4) - 1)`; + break; + case 'year': + sql = `DATE_PART('year', ${datetime_expr1}::TIMESTAMP) - DATE_PART('year', ${datetime_expr2}::TIMESTAMP)`; + break; + case 'day': + sql = `DATE_PART('day', ${datetime_expr1}::TIMESTAMP - ${datetime_expr2}::TIMESTAMP)`; + break; + default: + sql = ''; + } + return knex.raw(`${sql} ${colAlias}`); + }, WEEKDAY: ({ fn, knex, pt, colAlias }: MapFnArgs) => { // isodow: the day of the week as Monday (1) to Sunday (7) // WEEKDAY() returns an index from 0 to 6 for Monday to Sunday diff --git a/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/sqlite.ts b/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/sqlite.ts index be33bb7606..ad384aeedf 100644 --- a/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/sqlite.ts +++ b/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/sqlite.ts @@ -1,7 +1,12 @@ import dayjs from 'dayjs'; import { MapFnArgs } from '../mapFunctionName'; import commonFns from './commonFns'; +import { convertUnits } from '../helpers/convertUnits'; import { getWeekdayByText } from '../helpers/formulaFnHelper'; +import { + convertToTargetFormat, + getDateFormat, +} from '../../../../../utils/dateTimeUtils'; const sqlite3 = { ...commonFns, @@ -77,7 +82,63 @@ const sqlite3 = { END${colAlias}` ); }, - + DATETIME_DIFF: ({ fn, knex, pt, colAlias }: MapFnArgs) => { + let datetime_expr1 = fn(pt.arguments[0]).bindings[0]; + let datetime_expr2 = fn(pt.arguments[1]).bindings[0]; + // JULIANDAY takes YYYY-MM-DD + datetime_expr1 = convertToTargetFormat( + datetime_expr1, + getDateFormat(datetime_expr1), + 'YYYY-MM-DD' + ); + datetime_expr2 = convertToTargetFormat( + datetime_expr2, + getDateFormat(datetime_expr2), + 'YYYY-MM-DD' + ); + const rawUnit = pt.arguments[2] + ? fn(pt.arguments[2]).bindings[0] + : 'seconds'; + let sql; + const unit = convertUnits(rawUnit, 'sqlite'); + switch (unit) { + case 'seconds': + sql = `ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) * 86400)`; + break; + case 'minutes': + sql = `ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) * 1440)`; + break; + case 'hours': + sql = `ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) * 24)`; + break; + case 'milliseconds': + sql = `ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) * 86400000)`; + break; + case 'weeks': + sql = `ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) / 7)`; + break; + case 'months': + sql = `(ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) / 365)) + * 12 + (ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) / 365 / 12))`; + break; + case 'quarters': + sql = ` + ROUND((JULIANDAY('${datetime_expr1}')) / 365 / 4) - + ROUND((JULIANDAY('${datetime_expr2}')) / 365 / 4) + + (ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) / 365)) * 4 + `; + break; + case 'years': + sql = `ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) / 365)`; + break; + case 'days': + sql = `JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')`; + break; + default: + sql = ''; + } + return knex.raw(`${sql} ${colAlias}`); + }, WEEKDAY: ({ fn, knex, pt, colAlias }: MapFnArgs) => { // strftime('%w', date) - day of week 0 - 6 with Sunday == 0 // WEEKDAY() returns an index from 0 to 6 for Monday to Sunday diff --git a/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/helpers/convertUnits.ts b/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/helpers/convertUnits.ts new file mode 100644 index 0000000000..961a9d26d3 --- /dev/null +++ b/packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/helpers/convertUnits.ts @@ -0,0 +1,137 @@ +export function convertUnits( + unit: string, + type: 'mysql' | 'mssql' | 'pg' | 'sqlite' +) { + switch (unit) { + case 'milliseconds': + case 'ms': { + switch (type) { + case 'mssql': + return 'millisecond'; + case 'mysql': + // MySQL doesn't support millisecond + // hence change from MICROSECOND to millisecond manually + return 'MICROSECOND'; + case 'pg': + case 'sqlite': + return 'milliseconds'; + default: + return unit; + } + } + case 'seconds': + case 's': { + switch (type) { + case 'mssql': + case 'pg': + return 'second'; + case 'mysql': + return 'SECOND'; + case 'sqlite': + return 'seconds'; + default: + return unit; + } + } + case 'minutes': + case 'm': { + switch (type) { + case 'mssql': + case 'pg': + return 'minute'; + case 'mysql': + return 'MINUTE'; + case 'sqlite': + return 'minutes'; + default: + return unit; + } + } + case 'hours': + case 'h': { + switch (type) { + case 'mssql': + case 'pg': + return 'hour'; + case 'mysql': + return 'HOUR'; + case 'sqlite': + return 'hours'; + default: + return unit; + } + } + case 'days': + case 'd': { + switch (type) { + case 'mssql': + case 'pg': + return 'day'; + case 'mysql': + return 'DAY'; + case 'sqlite': + return 'days'; + default: + return unit; + } + } + case 'weeks': + case 'w': { + switch (type) { + case 'mssql': + case 'pg': + return 'week'; + case 'mysql': + return 'WEEK'; + case 'sqlite': + return 'weeks'; + default: + return unit; + } + } + case 'months': + case 'M': { + switch (type) { + case 'mssql': + case 'pg': + return 'month'; + case 'mysql': + return 'MONTH'; + case 'sqlite': + return 'months'; + default: + return unit; + } + } + case 'quarters': + case 'Q': { + switch (type) { + case 'mssql': + case 'pg': + return 'quarter'; + case 'mysql': + return 'QUARTER'; + case 'sqlite': + return 'quarters'; + default: + return unit; + } + } + case 'years': + case 'y': { + switch (type) { + case 'mssql': + case 'pg': + return 'year'; + case 'mysql': + return 'YEAR'; + case 'sqlite': + return 'years'; + default: + return unit; + } + } + default: + return unit; + } +} diff --git a/packages/nocodb/src/lib/utils/dateTimeUtils.ts b/packages/nocodb/src/lib/utils/dateTimeUtils.ts new file mode 100644 index 0000000000..19c058df34 --- /dev/null +++ b/packages/nocodb/src/lib/utils/dateTimeUtils.ts @@ -0,0 +1,75 @@ +import dayjs from 'dayjs'; + +export const dateFormats = [ + 'DD-MM-YYYY', + 'MM-DD-YYYY', + 'YYYY-MM-DD', + 'DD/MM/YYYY', + 'MM/DD/YYYY', + 'YYYY/MM/DD', + 'DD MM YYYY', + 'MM DD YYYY', + 'YYYY MM DD', +]; + +export function validateDateFormat(v: string) { + return dateFormats.includes(v); +} + +export function validateDateWithUnknownFormat(v: string) { + for (const format of dateFormats) { + if (dayjs(v, format, true).isValid() as any) { + return true; + } + for (const timeFormat of ['HH:mm', 'HH:mm:ss', 'HH:mm:ss.SSS']) { + if (dayjs(v, `${format} ${timeFormat}`, true).isValid() as any) { + return true; + } + } + } + return false; +} + +export function getDateFormat(v: string) { + for (const format of dateFormats) { + if (dayjs(v, format, true).isValid()) { + return format; + } + } + return 'YYYY/MM/DD'; +} + +export function getDateTimeFormat(v: string) { + for (const format of dateFormats) { + for (const timeFormat of ['HH:mm', 'HH:mm:ss', 'HH:mm:ss.SSS']) { + const dateTimeFormat = `${format} ${timeFormat}`; + if (dayjs(v, dateTimeFormat, true).isValid() as any) { + return dateTimeFormat; + } + } + } + return 'YYYY/MM/DD'; +} + +export function parseStringDate(v: string, dateFormat: string) { + const dayjsObj = dayjs(v); + if (dayjsObj.isValid()) { + v = dayjsObj.format('YYYY-MM-DD'); + } else { + v = dayjs(v, dateFormat).format('YYYY-MM-DD'); + } + return v; +} + +export function convertToTargetFormat( + v: string, + oldDataFormat, + newDateFormat: string +) { + if ( + !dateFormats.includes(oldDataFormat) || + !dateFormats.includes(newDateFormat) + ) + return v; + return dayjs(v, oldDataFormat).format(newDateFormat); +} diff --git a/tests/playwright/pages/Dashboard/Grid/Column/index.ts b/tests/playwright/pages/Dashboard/Grid/Column/index.ts index 787b2253d1..15bd509e27 100644 --- a/tests/playwright/pages/Dashboard/Grid/Column/index.ts +++ b/tests/playwright/pages/Dashboard/Grid/Column/index.ts @@ -34,7 +34,9 @@ export class ColumnPageObject extends BasePage { childColumn = '', relationType = '', rollupType = '', - format, + format = '', + dateFormat = '', + timeFormat = '', insertAfterColumnTitle, insertBeforeColumnTitle, }: { @@ -47,6 +49,8 @@ export class ColumnPageObject extends BasePage { relationType?: string; rollupType?: string; format?: string; + dateFormat?: string; + timeFormat?: string; insertBeforeColumnTitle?: string; insertAfterColumnTitle?: string; }) { @@ -90,6 +94,14 @@ export class ColumnPageObject extends BasePage { .click(); } break; + case 'DateTime': + // Date Format + await this.get().locator('.nc-date-select').click(); + await this.rootPage.locator('.ant-select-item').locator(`text="${dateFormat}"`).click(); + // Time Format + await this.get().locator('.nc-time-select').click(); + await this.rootPage.locator('.ant-select-item').locator(`text="${timeFormat}"`).click(); + break; case 'Formula': await this.get().locator('.nc-formula-input').fill(formula); break; @@ -222,11 +234,15 @@ export class ColumnPageObject extends BasePage { type = 'SingleLineText', formula = '', format, + dateFormat = '', + timeFormat = '', }: { title: string; type?: string; formula?: string; format?: string; + dateFormat?: string; + timeFormat?: string; }) { await this.getColumnHeader(title).locator('.nc-ui-dt-dropdown').click(); await this.rootPage.locator('li[role="menuitem"]:has-text("Edit")').click(); @@ -245,6 +261,14 @@ export class ColumnPageObject extends BasePage { }) .click(); break; + case 'DateTime': + // Date Format + await this.get().locator('.nc-date-select').click(); + await this.rootPage.locator('.ant-select-item').locator(`text="${dateFormat}"`).click(); + // Time Format + await this.get().locator('.nc-time-select').click(); + await this.rootPage.locator('.ant-select-item').locator(`text="${timeFormat}"`).click(); + break; default: break; } diff --git a/tests/playwright/pages/Dashboard/common/Cell/DateTimeCell.ts b/tests/playwright/pages/Dashboard/common/Cell/DateTimeCell.ts new file mode 100644 index 0000000000..50b762b618 --- /dev/null +++ b/tests/playwright/pages/Dashboard/common/Cell/DateTimeCell.ts @@ -0,0 +1,67 @@ +import { CellPageObject } from '.'; +import BasePage from '../../../Base'; + +export class DateTimeCellPageObject extends BasePage { + readonly cell: CellPageObject; + + constructor(cell: CellPageObject) { + super(cell.rootPage); + this.cell = cell; + } + + get({ index, columnHeader }: { index?: number; columnHeader: string }) { + return this.cell.get({ index, columnHeader }); + } + + async open({ index, columnHeader }: { index: number; columnHeader: string }) { + await this.rootPage.locator('.nc-grid-add-new-cell').click(); + + await this.cell.dblclick({ + index, + columnHeader, + }); + } + + async save() { + await this.rootPage.locator('button:has-text("Ok")').click(); + } + + async selectDate({ + // date formats in `YYYY-MM-DD` + date, + }: { + date: string; + }) { + // title date format needs to be YYYY-MM-DD + await this.rootPage.locator(`td[title="${date}"]`).click(); + } + + async selectTime({ + // hour: 0 - 23 + // minute: 0 - 59 + // second: 0 - 59 + hour, + minute, + second, + }: { + hour: number; + minute: number; + second?: number | null; + }) { + await this.rootPage + .locator(`.ant-picker-time-panel-column:nth-child(1) > .ant-picker-time-panel-cell:nth-child(${hour + 1})`) + .click(); + await this.rootPage + .locator(`.ant-picker-time-panel-column:nth-child(2) > .ant-picker-time-panel-cell:nth-child(${minute + 1})`) + .click(); + if (second != null) { + await this.rootPage + .locator(`.ant-picker-time-panel-column:nth-child(3) > .ant-picker-time-panel-cell:nth-child(${second + 1})`) + .click(); + } + } + + async close() { + await this.rootPage.keyboard.press('Escape'); + } +} diff --git a/tests/playwright/pages/Dashboard/common/Cell/index.ts b/tests/playwright/pages/Dashboard/common/Cell/index.ts index 0e1bc8e04b..a277d31ee6 100644 --- a/tests/playwright/pages/Dashboard/common/Cell/index.ts +++ b/tests/playwright/pages/Dashboard/common/Cell/index.ts @@ -7,6 +7,7 @@ import { SharedFormPage } from '../../../SharedForm'; import { CheckboxCellPageObject } from './CheckboxCell'; import { RatingCellPageObject } from './RatingCell'; import { DateCellPageObject } from './DateCell'; +import { DateTimeCellPageObject } from './DateTimeCell'; export interface CellProps { index?: number; @@ -20,6 +21,7 @@ export class CellPageObject extends BasePage { readonly checkbox: CheckboxCellPageObject; readonly rating: RatingCellPageObject; readonly date: DateCellPageObject; + readonly dateTime: DateTimeCellPageObject; constructor(parent: GridPage | SharedFormPage) { super(parent.rootPage); @@ -29,6 +31,7 @@ export class CellPageObject extends BasePage { this.checkbox = new CheckboxCellPageObject(this); this.rating = new RatingCellPageObject(this); this.date = new DateCellPageObject(this); + this.dateTime = new DateTimeCellPageObject(this); } get({ index, columnHeader }: CellProps): Locator { @@ -113,6 +116,22 @@ export class CellPageObject extends BasePage { } } + async verifyDateCell({ index, columnHeader, value }: { index: number; columnHeader: string; value: string }) { + const _verify = async expectedValue => { + await expect + .poll(async () => { + const cell = await this.get({ + index, + columnHeader, + }).locator('input'); + return await cell.getAttribute('title'); + }) + .toEqual(expectedValue); + }; + + await _verify(value); + } + async verifyQrCodeCell({ index, columnHeader, diff --git a/tests/playwright/tests/columnDateTime.spec.ts b/tests/playwright/tests/columnDateTime.spec.ts new file mode 100644 index 0000000000..c5349a9a98 --- /dev/null +++ b/tests/playwright/tests/columnDateTime.spec.ts @@ -0,0 +1,113 @@ +import { test } from '@playwright/test'; +import { DashboardPage } from '../pages/Dashboard'; +import setup from '../setup'; + +const dateTimeData = [ + { + dateFormat: 'YYYY-MM-DD', + timeFormat: 'HH:mm', + date: '2022-12-12', + hour: 10, + minute: 20, + output: '2022-12-12 10:20', + }, + { + dateFormat: 'YYYY-MM-DD', + timeFormat: 'HH:mm:ss', + date: '2022-12-11', + hour: 20, + minute: 30, + second: 40, + output: '2022-12-11 20:30:40', + }, + { + dateFormat: 'YYYY/MM/DD', + timeFormat: 'HH:mm', + date: '2022-12-13', + hour: 10, + minute: 20, + output: '2022/12/13 10:20', + }, + { + dateFormat: 'YYYY/MM/DD', + timeFormat: 'HH:mm:ss', + date: '2022-12-14', + hour: 5, + minute: 30, + second: 40, + output: '2022/12/14 05:30:40', + }, + { + dateFormat: 'DD-MM-YYYY', + timeFormat: 'HH:mm', + date: '2022-12-10', + hour: 4, + minute: 30, + output: '10-12-2022 04:30', + }, + { + dateFormat: 'DD-MM-YYYY', + timeFormat: 'HH:mm:ss', + date: '2022-12-26', + hour: 2, + minute: 30, + second: 40, + output: '26-12-2022 02:30:40', + }, +]; + +test.describe('DateTime Column', () => { + let dashboard: DashboardPage; + let context: any; + + test.beforeEach(async ({ page }) => { + context = await setup({ page }); + dashboard = new DashboardPage(page, context.project); + }); + + test('Create DateTime Column', async () => { + await dashboard.treeView.createTable({ title: 'test_datetime' }); + // Create DateTime column + await dashboard.grid.column.create({ + title: 'NC_DATETIME_0', + type: 'DateTime', + dateFormat: dateTimeData[0].dateFormat, + timeFormat: dateTimeData[0].timeFormat, + }); + + for (let i = 0; i < dateTimeData.length; i++) { + // Edit DateTime column + await dashboard.grid.column.openEdit({ + title: 'NC_DATETIME_0', + type: 'DateTime', + dateFormat: dateTimeData[i].dateFormat, + timeFormat: dateTimeData[i].timeFormat, + }); + + await dashboard.grid.column.save({ isUpdated: true }); + + await dashboard.grid.cell.dateTime.open({ + index: 0, + columnHeader: 'NC_DATETIME_0', + }); + + await dashboard.grid.cell.dateTime.selectDate({ + date: dateTimeData[i].date, + }); + + await dashboard.grid.cell.dateTime.selectTime({ + hour: dateTimeData[i].hour, + minute: dateTimeData[i].minute, + second: dateTimeData[i].second, + }); + + await dashboard.grid.cell.dateTime.save(); + + await dashboard.grid.cell.verifyDateCell({ + index: 0, + columnHeader: 'NC_DATETIME_0', + value: dateTimeData[i].output, + }); + } + }); +}); diff --git a/tests/playwright/tests/columnFormula.spec.ts b/tests/playwright/tests/columnFormula.spec.ts index d9a1ed5763..bc08da00ba 100644 --- a/tests/playwright/tests/columnFormula.spec.ts +++ b/tests/playwright/tests/columnFormula.spec.ts @@ -30,6 +30,46 @@ const formulaDataByDbType = (context: NcContext) => [ formula: `WEEKDAY("2022-07-19", "sunday")`, result: ['2', '2', '2', '2', '2'], }, + { + formula: `DATETIME_DIFF("2022/10/14", "2022/10/15")`, + result: ['-86400', '-86400', '-86400', '-86400', '-86400'], + }, + { + formula: `DATETIME_DIFF("2022/10/14", "2022/10/15", "minutes")`, + result: ['-1440', '-1440', '-1440', '-1440', '-1440'], + }, + { + formula: `DATETIME_DIFF("2022/10/14", "2022/10/15", "seconds")`, + result: ['-86400', '-86400', '-86400', '-86400', '-86400'], + }, + { + formula: `DATETIME_DIFF("2022/10/14", "2022/10/15", "milliseconds")`, + result: ['-86400000', '-86400000', '-86400000', '-86400000', '-86400000'], + }, + { + formula: `DATETIME_DIFF("2022/10/14", "2022/10/15", "hours")`, + result: ['-24', '-24', '-24', '-24', '-24'], + }, + { + formula: `DATETIME_DIFF("2022/10/14", "2023/10/14", "w")`, + result: ['-52', '-52', '-52', '-52', '-52'], + }, + { + formula: `DATETIME_DIFF("2022/10/14", "2023/10/14", "M")`, + result: ['-12', '-12', '-12', '-12', '-12'], + }, + { + formula: `DATETIME_DIFF("2022/10/14", "2023/10/14", "Q")`, + result: ['-4', '-4', '-4', '-4', '-4'], + }, + { + formula: `DATETIME_DIFF("2022/10/14", "2023/10/14", "y")`, + result: ['-1', '-1', '-1', '-1', '-1'], + }, + { + formula: `DATETIME_DIFF("2022/10/14", "2023/10/14", "d")`, + result: ['-365', '-365', '-365', '-365', '-365'], + }, { formula: `CONCAT(UPPER({City}), LOWER({City}), TRIM(' trimmed '))`, result: [