fix(editor): Improve datetime handling in Data table UI (#19425)

This commit is contained in:
Milorad FIlipović
2025-09-12 15:31:07 +02:00
committed by GitHub
parent 4e2682af62
commit 1853faf032
12 changed files with 362 additions and 53 deletions

View File

@@ -294,7 +294,31 @@ describe('AddColumnButton', () => {
expect(getByText('string')).toBeInTheDocument();
expect(getByText('number')).toBeInTheDocument();
expect(getByText('boolean')).toBeInTheDocument();
expect(getByText('date')).toBeInTheDocument();
expect(getByText('datetime')).toBeInTheDocument();
});
});
it('should set value to "date" when selecting "datetime" option', async () => {
const { getByTestId, getByRole, getByText, getByPlaceholderText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name');
await fireEvent.update(nameInput, 'dateColumn');
const selectElement = getByRole('combobox');
await fireEvent.click(selectElement);
const dateOption = getByText('datetime');
await fireEvent.click(dateOption);
const submitButton = getByTestId('data-store-add-column-submit-button');
await fireEvent.click(submitButton);
expect(addColumnHandler).toHaveBeenCalledWith({
name: 'dateColumn',
type: 'date',
});
});

View File

@@ -5,6 +5,7 @@ import type {
DataStoreColumnCreatePayload,
DataStoreColumnType,
} from '@/features/dataStore/datastore.types';
import { DATA_STORE_COLUMN_TYPES } from '@/features/dataStore/datastore.types';
import { useI18n } from '@n8n/i18n';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
import { COLUMN_NAME_REGEX, MAX_COLUMN_NAME_LENGTH } from '@/features/dataStore/constants';
@@ -34,7 +35,7 @@ const nameInputRef = ref<HTMLInputElement | null>(null);
const columnName = ref('');
const columnType = ref<DataStoreColumnType>('string');
const columnTypes: DataStoreColumnType[] = ['string', 'number', 'boolean', 'date'];
const columnTypes: DataStoreColumnType[] = [...DATA_STORE_COLUMN_TYPES];
const error = ref<FormError | null>(null);
@@ -44,6 +45,15 @@ const isSelectOpen = ref(false);
const popoverId = computed(() => props.popoverId ?? 'add-column-popover');
const columnTypeOptions = computed(() => {
// Renaming 'date' to 'datetime' but only in UI label
// we still want to use 'date' as value so nothing breaks
return columnTypes.map((type) => ({
label: type === 'date' ? 'datetime' : type,
value: type,
}));
});
const onAddButtonClicked = async () => {
validateName();
if (!columnName.value || !columnType.value || error.value) {
@@ -178,10 +188,15 @@ const onInput = debounce(validateName, { debounceTime: 100 });
:append-to="`#${popoverId}`"
@visible-change="isSelectOpen = $event"
>
<N8nOption v-for="type in columnTypes" :key="type" :value="type">
<N8nOption
v-for="option in columnTypeOptions"
:key="option.value"
:label="option.label"
:value="option.value"
>
<div class="add-column-option-content">
<N8nIcon :icon="getIconForType(type)" />
<N8nText>{{ type }}</N8nText>
<N8nIcon :icon="getIconForType(option.value)" />
<N8nText>{{ option.label }}</N8nText>
</div>
</N8nOption>
</N8nSelect>

View File

@@ -395,5 +395,13 @@ defineExpose({
width: var(--spacing-m);
}
}
// A hacky solution for element ui bug where clicking svg inside .more button does not work
:global(.el-pager .more) {
background: transparent !important;
svg {
z-index: -1;
}
}
}
</style>

View File

@@ -1,26 +1,67 @@
<script setup lang="ts">
import { onMounted, ref, nextTick } from 'vue';
import { onMounted, ref, nextTick, useTemplateRef } from 'vue';
import type { ICellEditorParams } from 'ag-grid-community';
import { DateTime } from 'luxon';
import { parseLooseDateInput } from '@/features/dataStore/utils/typeUtils';
const props = defineProps<{
params: ICellEditorParams;
}>();
const pickerRef = ref<HTMLElement | null>(null);
const pickerRef = useTemplateRef('pickerRef');
const wrapperRef = useTemplateRef('wrapperRef');
const dateValue = ref<Date | null>(null);
const initialValue = ref<Date | null>(null);
const inputWidth = ref(props.params.column.getActualWidth() - 4); // -4 for the border
function commitIfParsedFromInput(target?: EventTarget | null) {
const input = target instanceof HTMLInputElement ? target : null;
const value = input?.value ?? '';
const parsed = parseLooseDateInput(value);
if (parsed) {
dateValue.value = parsed;
props.params.stopEditing();
return true;
}
return false;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.stopPropagation();
dateValue.value = initialValue.value;
props.params.stopEditing();
return;
}
if (e.key === 'Enter') {
const committed = commitIfParsedFromInput(e.target);
if (committed) {
e.preventDefault();
e.stopPropagation();
}
}
}
function getInnerInput(): HTMLInputElement | null {
return (wrapperRef.value?.querySelector('input') ?? null) as HTMLInputElement | null;
}
function onChange() {
props.params.stopEditing();
}
function onClear() {
dateValue.value = null;
props.params.stopEditing();
}
onMounted(async () => {
const initial = props.params.value as unknown;
if (initial === null || initial === undefined) {
dateValue.value = null;
} else if (initial instanceof Date) {
const dt = DateTime.fromJSDate(initial);
// the date editor shows local time but we want the UTC so we need to offset the value here
dateValue.value = dt.minus({ minutes: dt.offset }).toJSDate();
// Use the provided Date as-is (local time)
dateValue.value = initial;
}
initialValue.value = dateValue.value;
@@ -33,48 +74,35 @@ onMounted(async () => {
} catch {}
});
function onChange() {
props.params.stopEditing();
}
function onClear() {
dateValue.value = null;
props.params.stopEditing();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.stopPropagation();
dateValue.value = initialValue.value;
props.params.stopEditing();
} else if (e.key === 'Enter') {
e.stopPropagation();
props.params.stopEditing();
}
}
defineExpose({
getValue: () => {
if (dateValue.value === null) return null;
const dt = DateTime.fromJSDate(dateValue.value);
// the editor returns local time but we initially offset the value to UTC so we need to add the offset back here
return dt.plus({ minutes: dt.offset }).toJSDate();
// Prefer what's typed in the input
// Element plus will not update the v-model if the input is invalid (loose)
const input = getInnerInput();
const typed = input?.value ?? '';
const parsed = parseLooseDateInput(typed);
if (parsed) return parsed;
// Fallback to the v-model value
return dateValue.value;
},
isPopup: () => true,
});
</script>
<template>
<div class="datastore-datepicker-wrapper">
<div ref="wrapperRef" class="datastore-datepicker-wrapper">
<el-date-picker
id="datastore-datepicker"
ref="pickerRef"
v-model="dateValue"
type="datetime"
:style="{ width: `${inputWidth}px` }"
:clearable="true"
:editable="false"
:editable="true"
:teleported="false"
:placeholder="''"
popper-class="ag-custom-component-popup datastore-datepicker-popper"
placeholder="YYYY-MM-DD (HH:mm:ss)"
size="small"
@change="onChange"
@clear="onClear"
@@ -108,4 +136,11 @@ defineExpose({
border: var(--grid-cell-editing-border);
}
}
.datastore-datepicker-popper {
// Hide the date input in the popper
.el-date-picker__time-header .el-date-picker__editor-wrap:first-child {
display: none;
}
}
</style>

View File

@@ -20,6 +20,7 @@ import type {
import {
ADD_ROW_ROW_ID,
DATA_STORE_ID_COLUMN_WIDTH,
DEFAULT_COLUMN_WIDTH,
DEFAULT_ID_COLUMN_NAME,
} from '@/features/dataStore/constants';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
@@ -102,9 +103,15 @@ export const useDataStoreGridBase = ({
if (rowNode?.rowIndex === null) return;
const rowIndex = rowNode!.rowIndex;
const firstEditableCol = colDefs.value[1];
if (!firstEditableCol?.colId) return;
const columnId = firstEditableCol.colId;
const displayed = initializedGridApi.value.getAllDisplayedColumns();
const firstEditable = displayed.find((col) => {
const def = col.getColDef();
if (!def) return false;
if (def.colId === DEFAULT_ID_COLUMN_NAME) return false;
return !!def.editable;
});
if (!firstEditable) return;
const columnId = firstEditable.getColId();
requestAnimationFrame(() => {
initializedGridApi.value.ensureIndexVisible(rowIndex);
@@ -124,7 +131,6 @@ export const useDataStoreGridBase = ({
field: col.name,
headerName: col.name,
sortable: true,
flex: 1,
editable: (params) => params.data?.id !== ADD_ROW_ROW_ID,
resizable: true,
lockPinned: true,
@@ -135,6 +141,7 @@ export const useDataStoreGridBase = ({
cellClass: getCellClass,
valueGetter: createValueGetter(col),
cellRendererSelector: createCellRendererSelector(col),
width: DEFAULT_COLUMN_WIDTH,
};
if (col.type === 'string') {
@@ -148,6 +155,7 @@ export const useDataStoreGridBase = ({
component: ElDatePickerCellEditor,
});
columnDef.valueFormatter = dateValueFormatter;
columnDef.cellEditorPopup = true;
} else if (col.type === 'number') {
columnDef.valueFormatter = numberValueFormatter;
}
@@ -183,6 +191,7 @@ export const useDataStoreGridBase = ({
},
cellClass: (params) => (params.data?.id === ADD_ROW_ROW_ID ? 'add-row-cell' : 'system-cell'),
headerClass: 'system-column',
width: DEFAULT_COLUMN_WIDTH,
};
return [
// Always add the ID column, it's not returned by the back-end but all data stores have it
@@ -250,6 +259,7 @@ export const useDataStoreGridBase = ({
lockPinned: true,
lockPosition: 'right',
resizable: false,
flex: 1,
headerComponent: AddColumnButton,
headerComponentParams: { onAddColumn },
},

View File

@@ -18,8 +18,9 @@ import type {
} from 'ag-grid-community';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { MODAL_CONFIRM } from '@/constants';
import { isDataStoreValue } from '@/features/dataStore/typeGuards';
import { useDataStoreTypes } from './useDataStoreTypes';
import { isDataStoreValue, isAGGridCellType } from '@/features/dataStore/typeGuards';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
import { areValuesEqual } from '@/features/dataStore/utils/typeUtils';
export type UseDataStoreOperationsParams = {
colDefs: Ref<ColDef[]>;
@@ -197,7 +198,11 @@ export const useDataStoreOperations = ({
const { data, api, oldValue, colDef } = params;
const value = params.data[colDef.field!];
if (value === undefined || value === oldValue) {
const cellType = isAGGridCellType(colDef.cellDataType)
? dataStoreTypes.mapToDataStoreColumnType(colDef.cellDataType)
: undefined;
if (value === undefined || areValuesEqual(oldValue, value, cellType)) {
return;
}

View File

@@ -10,6 +10,8 @@ export const DEFAULT_DATA_STORE_PAGE_SIZE = 10;
export const DATA_STORE_ID_COLUMN_WIDTH = 60;
export const DEFAULT_COLUMN_WIDTH = 250;
export const DATA_STORE_HEADER_HEIGHT = 36;
export const DATA_STORE_ROW_HEIGHT = 33;
@@ -39,3 +41,7 @@ export const DATA_STORE_MODULE_NAME = 'data-table';
export const NUMBER_WITH_SPACES_REGEX = /\B(?=(\d{3})+(?!\d))/g;
export const NUMBER_THOUSAND_SEPARATOR = ' ';
export const NUMBER_DECIMAL_SEPARATOR = '.';
// Allows 1-2 digit month/day/time parts (e.g., 2025-1-1 2:3:4)
export const LOOSE_DATE_REGEX =
/^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2})(?:[ T]([0-9]{1,2}):([0-9]{1,2})(?::([0-9]{1,2}))?)?$/;

View File

@@ -11,9 +11,19 @@ export type DataStore = {
project?: Project;
};
export type DataStoreColumnType = 'string' | 'number' | 'boolean' | 'date';
// Single sources of truth for supported types
export const DATA_STORE_COLUMN_TYPES = ['string', 'number', 'boolean', 'date'] as const;
export type DataStoreColumnType = (typeof DATA_STORE_COLUMN_TYPES)[number];
export type AGGridCellType = 'text' | 'number' | 'boolean' | 'date' | 'dateString' | 'object';
export const AG_GRID_CELL_TYPES = [
'text',
'number',
'boolean',
'date',
'dateString',
'object',
] as const;
export type AGGridCellType = (typeof AG_GRID_CELL_TYPES)[number];
export type DataStoreColumn = {
id: string;

View File

@@ -1,4 +1,9 @@
import type { AGGridCellType, DataStoreValue } from '@/features/dataStore/datastore.types';
import type {
AGGridCellType,
DataStoreValue,
DataStoreColumnType,
} from '@/features/dataStore/datastore.types';
import { AG_GRID_CELL_TYPES, DATA_STORE_COLUMN_TYPES } from '@/features/dataStore/datastore.types';
export const isDataStoreValue = (value: unknown): value is DataStoreValue => {
return (
@@ -11,8 +16,9 @@ export const isDataStoreValue = (value: unknown): value is DataStoreValue => {
};
export const isAGGridCellType = (value: unknown): value is AGGridCellType => {
return (
typeof value === 'string' &&
['text', 'number', 'boolean', 'date', 'dateString', 'object'].includes(value)
);
return typeof value === 'string' && (AG_GRID_CELL_TYPES as readonly string[]).includes(value);
};
export const isDataStoreColumnType = (type: unknown): type is DataStoreColumnType => {
return typeof type === 'string' && (DATA_STORE_COLUMN_TYPES as readonly string[]).includes(type);
};

View File

@@ -7,6 +7,7 @@ import type {
ValueFormatterParams,
} from 'ag-grid-community';
import type { Ref } from 'vue';
import { DateTime } from 'luxon';
import type { DataStoreColumn, DataStoreRow } from '@/features/dataStore/datastore.types';
import {
ADD_ROW_ROW_ID,
@@ -98,7 +99,8 @@ export const dateValueFormatter = (
): string => {
const value = params.value;
if (value === null || value === undefined) return '';
return value.toISOString();
// Format using user's local timezone (includes offset)
return DateTime.fromJSDate(value).toISO() ?? '';
};
const numberWithSpaces = (num: number) => {

View File

@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest';
import { parseLooseDateInput, areValuesEqual } from '@/features/dataStore/utils/typeUtils';
describe('Loose Date Parsing', () => {
it('parses full loose datetime with single-digit parts', () => {
const d = parseLooseDateInput('2023-7-9 5:6:7');
expect(d).not.toBeNull();
expect(d?.getFullYear()).toBe(2023);
expect(d?.getMonth()).toBe(6); // July is 6 (0-indexed)
expect(d?.getDate()).toBe(9);
expect(d?.getHours()).toBe(5);
expect(d?.getMinutes()).toBe(6);
expect(d?.getSeconds()).toBe(7);
});
it('parses date-only and defaults time to 00:00:00', () => {
const d = parseLooseDateInput('2023-07-09');
expect(d).not.toBeNull();
expect(d?.getHours()).toBe(0);
expect(d?.getMinutes()).toBe(0);
expect(d?.getSeconds()).toBe(0);
});
it('parses without seconds and defaults seconds to 00', () => {
const d = parseLooseDateInput('2023-07-09 05:06');
expect(d).not.toBeNull();
expect(d?.getHours()).toBe(5);
expect(d?.getMinutes()).toBe(6);
expect(d?.getSeconds()).toBe(0);
});
it('trims surrounding whitespace', () => {
const d = parseLooseDateInput(' 2023-07-09 05:06:07 ');
expect(d).not.toBeNull();
expect(d?.getFullYear()).toBe(2023);
});
it('accepts T separator between date and time', () => {
const d = parseLooseDateInput('2023-01-02T3:4:5');
expect(d).not.toBeNull();
expect(d?.getFullYear()).toBe(2023);
expect(d?.getMonth()).toBe(0);
expect(d?.getDate()).toBe(2);
expect(d?.getHours()).toBe(3);
expect(d?.getMinutes()).toBe(4);
expect(d?.getSeconds()).toBe(5);
});
it('accepts T separator without seconds and defaults seconds to 00', () => {
const d = parseLooseDateInput('2023-01-02T3:4');
expect(d).not.toBeNull();
expect(d?.getFullYear()).toBe(2023);
expect(d?.getMonth()).toBe(0);
expect(d?.getDate()).toBe(2);
expect(d?.getHours()).toBe(3);
expect(d?.getMinutes()).toBe(4);
expect(d?.getSeconds()).toBe(0);
});
it('validates real calendar dates (e.g., leap day)', () => {
expect(parseLooseDateInput('2024-02-29')).not.toBeNull();
expect(parseLooseDateInput('2023-02-29')).toBeNull();
});
it('accepts single-digit date-only input', () => {
const d = parseLooseDateInput('2023-7-9');
expect(d).not.toBeNull();
expect(d?.getFullYear()).toBe(2023);
expect(d?.getMonth()).toBe(6);
expect(d?.getDate()).toBe(9);
expect(d?.getHours()).toBe(0);
expect(d?.getMinutes()).toBe(0);
expect(d?.getSeconds()).toBe(0);
});
it('returns null for invalid inputs', () => {
const invalidInputs = [
// Invalid ranges
'2023-13-01',
'2023-00-10',
'2023-01-32',
'2023-01-00',
'2023-01-01 24:00:00',
'2023-01-01 23:60:00',
'2023-01-01 23:59:60',
// Invalid calendar dates
'2023-02-30',
'2023-04-31',
// Invalid separators/spacing
'2023-01-02 3:4:5',
// Non-matching strings
'not a date',
'2023/07/09',
'07-09-2023',
];
invalidInputs.forEach((input) => {
expect(parseLooseDateInput(input)).toBeNull();
});
});
});
describe('Cell value comparison', () => {
it('compares Date values by timestamp when type is date', () => {
const a = new Date(2023, 6, 9, 5, 6, 7);
const b = new Date(2023, 6, 9, 5, 6, 7);
const c = new Date(2023, 6, 9, 5, 6, 8);
expect(areValuesEqual(a, b, 'date')).toBe(true);
expect(areValuesEqual(a, c, 'date')).toBe(false);
});
it('falls back to strict equality for non-date types', () => {
expect(areValuesEqual(1, 1, 'number')).toBe(true);
expect(areValuesEqual(1, 2, 'number')).toBe(false);
const obj = { a: 1 };
expect(areValuesEqual(obj, obj, 'string')).toBe(true);
expect(areValuesEqual({ a: 1 }, { a: 1 }, 'string')).toBe(false);
});
it('when type is date but values are not Date instances, uses strict equality', () => {
const date = new Date(2023, 6, 9);
expect(areValuesEqual(date, date.toISOString(), 'date')).toBe(false);
const sameRef: unknown = { when: date };
expect(areValuesEqual(sameRef, sameRef, 'date')).toBe(true);
});
});

View File

@@ -0,0 +1,60 @@
import { LOOSE_DATE_REGEX } from '@/features/dataStore/constants';
import type { DataStoreColumnType } from '@/features/dataStore/datastore.types';
/**
* Parses a loose date string into a Date object.
* Allows:
* - Missing leading zeros (e.g., 2023-7-9 5:6:7)
* - Optional time part (e.g., 2023-07-09), defaulting to 00:00:00
* - Optional seconds part (e.g., 2023-07-09 05:06), defaulting to 00
* @param text The loose date string to parse
* @returns Date | null
*/
export const parseLooseDateInput = (text: string): Date | null => {
const trimmed = text.trim();
// Validate shape and extract parts
const m = LOOSE_DATE_REGEX.exec(trimmed);
if (!m) return null;
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
const hh = m[4] !== undefined ? Number(m[4]) : 0;
const mm = m[5] !== undefined ? Number(m[5]) : 0;
const ss = m[6] !== undefined ? Number(m[6]) : 0;
// Check if constructed date matches input parts
// this ensures Date constructor didn't auto-correct invalid dates
const dt = new Date(y, mo - 1, d, hh, mm, ss, 0);
if (
dt.getFullYear() !== y ||
dt.getMonth() !== mo - 1 ||
dt.getDate() !== d ||
dt.getHours() !== hh ||
dt.getMinutes() !== mm ||
dt.getSeconds() !== ss
) {
return null;
}
return dt;
};
/**
* Check if two column values are equal, with special handling for date types.
* @param oldValue unknown
* @param newValue unknown
* @param type DataStoreColumnType | undefined
* @returns boolean
*/
export const areValuesEqual = (
oldValue: unknown,
newValue: unknown,
type: DataStoreColumnType | undefined,
) => {
if (type && type === 'date') {
if (oldValue instanceof Date && newValue instanceof Date) {
return oldValue.getTime() === newValue.getTime();
}
}
return oldValue === newValue;
};