mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
fix(editor): Improve datetime handling in Data table UI (#19425)
This commit is contained in:
committed by
GitHub
parent
4e2682af62
commit
1853faf032
@@ -294,7 +294,31 @@ describe('AddColumnButton', () => {
|
|||||||
expect(getByText('string')).toBeInTheDocument();
|
expect(getByText('string')).toBeInTheDocument();
|
||||||
expect(getByText('number')).toBeInTheDocument();
|
expect(getByText('number')).toBeInTheDocument();
|
||||||
expect(getByText('boolean')).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',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
DataStoreColumnCreatePayload,
|
DataStoreColumnCreatePayload,
|
||||||
DataStoreColumnType,
|
DataStoreColumnType,
|
||||||
} from '@/features/dataStore/datastore.types';
|
} from '@/features/dataStore/datastore.types';
|
||||||
|
import { DATA_STORE_COLUMN_TYPES } from '@/features/dataStore/datastore.types';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
|
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
|
||||||
import { COLUMN_NAME_REGEX, MAX_COLUMN_NAME_LENGTH } from '@/features/dataStore/constants';
|
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 columnName = ref('');
|
||||||
const columnType = ref<DataStoreColumnType>('string');
|
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);
|
const error = ref<FormError | null>(null);
|
||||||
|
|
||||||
@@ -44,6 +45,15 @@ const isSelectOpen = ref(false);
|
|||||||
|
|
||||||
const popoverId = computed(() => props.popoverId ?? 'add-column-popover');
|
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 () => {
|
const onAddButtonClicked = async () => {
|
||||||
validateName();
|
validateName();
|
||||||
if (!columnName.value || !columnType.value || error.value) {
|
if (!columnName.value || !columnType.value || error.value) {
|
||||||
@@ -178,10 +188,15 @@ const onInput = debounce(validateName, { debounceTime: 100 });
|
|||||||
:append-to="`#${popoverId}`"
|
:append-to="`#${popoverId}`"
|
||||||
@visible-change="isSelectOpen = $event"
|
@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">
|
<div class="add-column-option-content">
|
||||||
<N8nIcon :icon="getIconForType(type)" />
|
<N8nIcon :icon="getIconForType(option.value)" />
|
||||||
<N8nText>{{ type }}</N8nText>
|
<N8nText>{{ option.label }}</N8nText>
|
||||||
</div>
|
</div>
|
||||||
</N8nOption>
|
</N8nOption>
|
||||||
</N8nSelect>
|
</N8nSelect>
|
||||||
|
|||||||
@@ -395,5 +395,13 @@ defineExpose({
|
|||||||
width: var(--spacing-m);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,26 +1,67 @@
|
|||||||
<script setup lang="ts">
|
<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 type { ICellEditorParams } from 'ag-grid-community';
|
||||||
import { DateTime } from 'luxon';
|
import { parseLooseDateInput } from '@/features/dataStore/utils/typeUtils';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
params: ICellEditorParams;
|
params: ICellEditorParams;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const pickerRef = ref<HTMLElement | null>(null);
|
const pickerRef = useTemplateRef('pickerRef');
|
||||||
|
const wrapperRef = useTemplateRef('wrapperRef');
|
||||||
const dateValue = ref<Date | null>(null);
|
const dateValue = ref<Date | null>(null);
|
||||||
const initialValue = ref<Date | null>(null);
|
const initialValue = ref<Date | null>(null);
|
||||||
|
|
||||||
const inputWidth = ref(props.params.column.getActualWidth() - 4); // -4 for the border
|
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 () => {
|
onMounted(async () => {
|
||||||
const initial = props.params.value as unknown;
|
const initial = props.params.value as unknown;
|
||||||
if (initial === null || initial === undefined) {
|
if (initial === null || initial === undefined) {
|
||||||
dateValue.value = null;
|
dateValue.value = null;
|
||||||
} else if (initial instanceof Date) {
|
} else if (initial instanceof Date) {
|
||||||
const dt = DateTime.fromJSDate(initial);
|
// Use the provided Date as-is (local time)
|
||||||
// the date editor shows local time but we want the UTC so we need to offset the value here
|
dateValue.value = initial;
|
||||||
dateValue.value = dt.minus({ minutes: dt.offset }).toJSDate();
|
|
||||||
}
|
}
|
||||||
initialValue.value = dateValue.value;
|
initialValue.value = dateValue.value;
|
||||||
|
|
||||||
@@ -33,48 +74,35 @@ onMounted(async () => {
|
|||||||
} catch {}
|
} 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({
|
defineExpose({
|
||||||
getValue: () => {
|
getValue: () => {
|
||||||
if (dateValue.value === null) return null;
|
// Prefer what's typed in the input
|
||||||
const dt = DateTime.fromJSDate(dateValue.value);
|
// Element plus will not update the v-model if the input is invalid (loose)
|
||||||
// the editor returns local time but we initially offset the value to UTC so we need to add the offset back here
|
const input = getInnerInput();
|
||||||
return dt.plus({ minutes: dt.offset }).toJSDate();
|
const typed = input?.value ?? '';
|
||||||
|
const parsed = parseLooseDateInput(typed);
|
||||||
|
if (parsed) return parsed;
|
||||||
|
|
||||||
|
// Fallback to the v-model value
|
||||||
|
return dateValue.value;
|
||||||
},
|
},
|
||||||
isPopup: () => true,
|
isPopup: () => true,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="datastore-datepicker-wrapper">
|
<div ref="wrapperRef" class="datastore-datepicker-wrapper">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
|
id="datastore-datepicker"
|
||||||
ref="pickerRef"
|
ref="pickerRef"
|
||||||
v-model="dateValue"
|
v-model="dateValue"
|
||||||
type="datetime"
|
type="datetime"
|
||||||
:style="{ width: `${inputWidth}px` }"
|
:style="{ width: `${inputWidth}px` }"
|
||||||
:clearable="true"
|
:clearable="true"
|
||||||
:editable="false"
|
:editable="true"
|
||||||
:teleported="false"
|
:teleported="false"
|
||||||
:placeholder="''"
|
popper-class="ag-custom-component-popup datastore-datepicker-popper"
|
||||||
|
placeholder="YYYY-MM-DD (HH:mm:ss)"
|
||||||
size="small"
|
size="small"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
@clear="onClear"
|
@clear="onClear"
|
||||||
@@ -108,4 +136,11 @@ defineExpose({
|
|||||||
border: var(--grid-cell-editing-border);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
ADD_ROW_ROW_ID,
|
ADD_ROW_ROW_ID,
|
||||||
DATA_STORE_ID_COLUMN_WIDTH,
|
DATA_STORE_ID_COLUMN_WIDTH,
|
||||||
|
DEFAULT_COLUMN_WIDTH,
|
||||||
DEFAULT_ID_COLUMN_NAME,
|
DEFAULT_ID_COLUMN_NAME,
|
||||||
} from '@/features/dataStore/constants';
|
} from '@/features/dataStore/constants';
|
||||||
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
|
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
|
||||||
@@ -102,9 +103,15 @@ export const useDataStoreGridBase = ({
|
|||||||
if (rowNode?.rowIndex === null) return;
|
if (rowNode?.rowIndex === null) return;
|
||||||
const rowIndex = rowNode!.rowIndex;
|
const rowIndex = rowNode!.rowIndex;
|
||||||
|
|
||||||
const firstEditableCol = colDefs.value[1];
|
const displayed = initializedGridApi.value.getAllDisplayedColumns();
|
||||||
if (!firstEditableCol?.colId) return;
|
const firstEditable = displayed.find((col) => {
|
||||||
const columnId = firstEditableCol.colId;
|
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(() => {
|
requestAnimationFrame(() => {
|
||||||
initializedGridApi.value.ensureIndexVisible(rowIndex);
|
initializedGridApi.value.ensureIndexVisible(rowIndex);
|
||||||
@@ -124,7 +131,6 @@ export const useDataStoreGridBase = ({
|
|||||||
field: col.name,
|
field: col.name,
|
||||||
headerName: col.name,
|
headerName: col.name,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
flex: 1,
|
|
||||||
editable: (params) => params.data?.id !== ADD_ROW_ROW_ID,
|
editable: (params) => params.data?.id !== ADD_ROW_ROW_ID,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
lockPinned: true,
|
lockPinned: true,
|
||||||
@@ -135,6 +141,7 @@ export const useDataStoreGridBase = ({
|
|||||||
cellClass: getCellClass,
|
cellClass: getCellClass,
|
||||||
valueGetter: createValueGetter(col),
|
valueGetter: createValueGetter(col),
|
||||||
cellRendererSelector: createCellRendererSelector(col),
|
cellRendererSelector: createCellRendererSelector(col),
|
||||||
|
width: DEFAULT_COLUMN_WIDTH,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (col.type === 'string') {
|
if (col.type === 'string') {
|
||||||
@@ -148,6 +155,7 @@ export const useDataStoreGridBase = ({
|
|||||||
component: ElDatePickerCellEditor,
|
component: ElDatePickerCellEditor,
|
||||||
});
|
});
|
||||||
columnDef.valueFormatter = dateValueFormatter;
|
columnDef.valueFormatter = dateValueFormatter;
|
||||||
|
columnDef.cellEditorPopup = true;
|
||||||
} else if (col.type === 'number') {
|
} else if (col.type === 'number') {
|
||||||
columnDef.valueFormatter = numberValueFormatter;
|
columnDef.valueFormatter = numberValueFormatter;
|
||||||
}
|
}
|
||||||
@@ -183,6 +191,7 @@ export const useDataStoreGridBase = ({
|
|||||||
},
|
},
|
||||||
cellClass: (params) => (params.data?.id === ADD_ROW_ROW_ID ? 'add-row-cell' : 'system-cell'),
|
cellClass: (params) => (params.data?.id === ADD_ROW_ROW_ID ? 'add-row-cell' : 'system-cell'),
|
||||||
headerClass: 'system-column',
|
headerClass: 'system-column',
|
||||||
|
width: DEFAULT_COLUMN_WIDTH,
|
||||||
};
|
};
|
||||||
return [
|
return [
|
||||||
// Always add the ID column, it's not returned by the back-end but all data stores have it
|
// 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,
|
lockPinned: true,
|
||||||
lockPosition: 'right',
|
lockPosition: 'right',
|
||||||
resizable: false,
|
resizable: false,
|
||||||
|
flex: 1,
|
||||||
headerComponent: AddColumnButton,
|
headerComponent: AddColumnButton,
|
||||||
headerComponentParams: { onAddColumn },
|
headerComponentParams: { onAddColumn },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ import type {
|
|||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
||||||
import { MODAL_CONFIRM } from '@/constants';
|
import { MODAL_CONFIRM } from '@/constants';
|
||||||
import { isDataStoreValue } from '@/features/dataStore/typeGuards';
|
import { isDataStoreValue, isAGGridCellType } from '@/features/dataStore/typeGuards';
|
||||||
import { useDataStoreTypes } from './useDataStoreTypes';
|
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
|
||||||
|
import { areValuesEqual } from '@/features/dataStore/utils/typeUtils';
|
||||||
|
|
||||||
export type UseDataStoreOperationsParams = {
|
export type UseDataStoreOperationsParams = {
|
||||||
colDefs: Ref<ColDef[]>;
|
colDefs: Ref<ColDef[]>;
|
||||||
@@ -197,7 +198,11 @@ export const useDataStoreOperations = ({
|
|||||||
const { data, api, oldValue, colDef } = params;
|
const { data, api, oldValue, colDef } = params;
|
||||||
const value = params.data[colDef.field!];
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export const DEFAULT_DATA_STORE_PAGE_SIZE = 10;
|
|||||||
|
|
||||||
export const DATA_STORE_ID_COLUMN_WIDTH = 60;
|
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_HEADER_HEIGHT = 36;
|
||||||
export const DATA_STORE_ROW_HEIGHT = 33;
|
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_WITH_SPACES_REGEX = /\B(?=(\d{3})+(?!\d))/g;
|
||||||
export const NUMBER_THOUSAND_SEPARATOR = ' ';
|
export const NUMBER_THOUSAND_SEPARATOR = ' ';
|
||||||
export const NUMBER_DECIMAL_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}))?)?$/;
|
||||||
|
|||||||
@@ -11,9 +11,19 @@ export type DataStore = {
|
|||||||
project?: Project;
|
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 = {
|
export type DataStoreColumn = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -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 => {
|
export const isDataStoreValue = (value: unknown): value is DataStoreValue => {
|
||||||
return (
|
return (
|
||||||
@@ -11,8 +16,9 @@ export const isDataStoreValue = (value: unknown): value is DataStoreValue => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const isAGGridCellType = (value: unknown): value is AGGridCellType => {
|
export const isAGGridCellType = (value: unknown): value is AGGridCellType => {
|
||||||
return (
|
return typeof value === 'string' && (AG_GRID_CELL_TYPES as readonly string[]).includes(value);
|
||||||
typeof value === 'string' &&
|
};
|
||||||
['text', 'number', 'boolean', 'date', 'dateString', 'object'].includes(value)
|
|
||||||
);
|
export const isDataStoreColumnType = (type: unknown): type is DataStoreColumnType => {
|
||||||
|
return typeof type === 'string' && (DATA_STORE_COLUMN_TYPES as readonly string[]).includes(type);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
ValueFormatterParams,
|
ValueFormatterParams,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import type { Ref } from 'vue';
|
import type { Ref } from 'vue';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import type { DataStoreColumn, DataStoreRow } from '@/features/dataStore/datastore.types';
|
import type { DataStoreColumn, DataStoreRow } from '@/features/dataStore/datastore.types';
|
||||||
import {
|
import {
|
||||||
ADD_ROW_ROW_ID,
|
ADD_ROW_ROW_ID,
|
||||||
@@ -98,7 +99,8 @@ export const dateValueFormatter = (
|
|||||||
): string => {
|
): string => {
|
||||||
const value = params.value;
|
const value = params.value;
|
||||||
if (value === null || value === undefined) return '';
|
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) => {
|
const numberWithSpaces = (num: number) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user