mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
feat(editor): Add custom data store column headers (no-changelog) (#18390)
Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
@@ -795,6 +795,7 @@ describe('dataStore', () => {
|
||||
createdAt: expect.any(Date),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
updatedAt: expect.any(Date),
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
@@ -805,6 +806,7 @@ describe('dataStore', () => {
|
||||
createdAt: expect.any(Date),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
updatedAt: expect.any(Date),
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
@@ -815,6 +817,7 @@ describe('dataStore', () => {
|
||||
createdAt: expect.any(Date),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
updatedAt: expect.any(Date),
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
@@ -825,6 +828,7 @@ describe('dataStore', () => {
|
||||
createdAt: expect.any(Date),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
updatedAt: expect.any(Date),
|
||||
index: 3,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -221,8 +221,7 @@ export class DataStoreRepository extends Repository<DataStore> {
|
||||
'dataStore',
|
||||
...this.getDataStoreColumnFields('data_store_column'),
|
||||
...this.getProjectFields('project'),
|
||||
])
|
||||
.addOrderBy('data_store_column.index', 'ASC');
|
||||
]);
|
||||
}
|
||||
|
||||
private getDataStoreColumnFields(alias: string): string[] {
|
||||
@@ -232,6 +231,7 @@ export class DataStoreRepository extends Repository<DataStore> {
|
||||
`${alias}.type`,
|
||||
`${alias}.createdAt`,
|
||||
`${alias}.updatedAt`,
|
||||
`${alias}.index`,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2869,6 +2869,10 @@
|
||||
"dataStore.addColumn.nameInput.placeholder": "Enter column name",
|
||||
"dataStore.addColumn.typeInput.label": "@:_reusableBaseText.type",
|
||||
"dataStore.addColumn.error": "Error adding column",
|
||||
"dataStore.moveColumn.error": "Error moving column",
|
||||
"dataStore.deleteColumn.error": "Error deleting column",
|
||||
"dataStore.deleteColumn.confirm.title": "Delete column",
|
||||
"dataStore.deleteColumn.confirm.message": "Are you sure you want to delete the column \"{name}\"? This action cannot be undone.",
|
||||
"dataStore.addColumn.invalidName.error": "Invalid column name",
|
||||
"dataStore.addColumn.invalidName.description": "Only alphanumeric characters and non-leading dashes are allowed for column names",
|
||||
"dataStore.fetchContent.error": "Error fetching data store content",
|
||||
|
||||
@@ -89,6 +89,7 @@ const onInput = debounce(validateName, { debounceTime: 300 });
|
||||
<template #trigger>
|
||||
<N8nIconButton
|
||||
data-test-id="data-store-add-column-trigger-button"
|
||||
text
|
||||
icon="plus"
|
||||
type="tertiary"
|
||||
/>
|
||||
@@ -168,7 +169,7 @@ const onInput = debounce(validateName, { debounceTime: 300 });
|
||||
padding: var(--spacing-2xs);
|
||||
border: var(--border-base);
|
||||
border-left: none;
|
||||
height: 50px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import { vi } from 'vitest';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import ColumnHeader from '@/features/dataStore/components/dataGrid/ColumnHeader.vue';
|
||||
|
||||
// Mock N8nActionDropdown to make it easy to trigger item selection
|
||||
vi.mock('@n8n/design-system', async (importOriginal) => {
|
||||
const original = await importOriginal<object>();
|
||||
return {
|
||||
...original,
|
||||
N8nActionDropdown: {
|
||||
name: 'N8nActionDropdown',
|
||||
props: {
|
||||
items: { type: Array, required: true },
|
||||
},
|
||||
emits: ['select'],
|
||||
template: `
|
||||
<div>
|
||||
<ul>
|
||||
<li v-for="item in items" :key="item.id">
|
||||
<button :data-test-id="'action-' + item.id" @click="$emit('select', item.id)">
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const onDeleteMock = vi.fn();
|
||||
|
||||
const renderComponent = createComponentRenderer(ColumnHeader, {
|
||||
props: {
|
||||
params: {
|
||||
displayName: 'My Column',
|
||||
column: { getColId: () => 'col-1', getColDef: () => ({ cellDataType: 'string' }) },
|
||||
onDelete: onDeleteMock,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('ColumnHeader', () => {
|
||||
it('renders the column display name', () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
expect(getByTestId('data-store-column-header-text')).toHaveTextContent('My Column');
|
||||
});
|
||||
|
||||
it('shows actions dropdown only on hover', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
const wrapper = getByTestId('data-store-column-header');
|
||||
expect(wrapper).not.toBeNull();
|
||||
|
||||
const deleteButton = getByTestId('action-delete');
|
||||
expect(deleteButton).not.toBeVisible();
|
||||
|
||||
await fireEvent.mouseEnter(wrapper as Element);
|
||||
expect(deleteButton).toBeVisible();
|
||||
|
||||
await fireEvent.mouseLeave(wrapper as Element);
|
||||
expect(deleteButton).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('calls onDelete with the column id when delete is selected', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
await userEvent.click(getByTestId('action-delete'));
|
||||
expect(onDeleteMock).toHaveBeenCalledWith('col-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import type { IHeaderParams } from 'ag-grid-community';
|
||||
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { isAGGridCellType } from '@/features/dataStore/typeGuards';
|
||||
|
||||
type HeaderParamsWithDelete = IHeaderParams & {
|
||||
onDelete: (columnId: string) => void;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
params: HeaderParamsWithDelete;
|
||||
}>();
|
||||
|
||||
const { getIconForType, mapToDataStoreColumnType } = useDataStoreTypes();
|
||||
const i18n = useI18n();
|
||||
|
||||
const isHovered = ref(false);
|
||||
const isDropdownOpen = ref(false);
|
||||
|
||||
const enum ItemAction {
|
||||
Delete = 'delete',
|
||||
}
|
||||
|
||||
const onItemClick = (action: string) => {
|
||||
if (action === (ItemAction.Delete as string)) {
|
||||
props.params.onDelete(props.params.column.getColId());
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseEnter = () => {
|
||||
isHovered.value = true;
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
isHovered.value = false;
|
||||
};
|
||||
|
||||
const onDropdownVisibleChange = (visible: boolean) => {
|
||||
isDropdownOpen.value = visible;
|
||||
};
|
||||
|
||||
const isDropdownVisible = computed(() => {
|
||||
return isHovered.value || isDropdownOpen.value;
|
||||
});
|
||||
|
||||
const typeIcon = computed(() => {
|
||||
const cellDataType = props.params.column.getColDef().cellDataType;
|
||||
if (!isAGGridCellType(cellDataType)) {
|
||||
return null;
|
||||
}
|
||||
return getIconForType(mapToDataStoreColumnType(cellDataType));
|
||||
});
|
||||
|
||||
const columnActionItems = [
|
||||
{
|
||||
id: ItemAction.Delete,
|
||||
label: i18n.baseText('dataStore.deleteColumn.confirm.title'),
|
||||
icon: 'trash-2',
|
||||
customClass: 'data-store-column-header-action-item',
|
||||
} as const,
|
||||
];
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="ag-header-cell-label data-store-column-header-wrapper"
|
||||
data-test-id="data-store-column-header"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<div class="data-store-column-header-icon-wrapper">
|
||||
<N8nIcon v-if="typeIcon" :icon="typeIcon" />
|
||||
<span class="ag-header-cell-text" data-test-id="data-store-column-header-text">{{
|
||||
props.params.displayName
|
||||
}}</span>
|
||||
</div>
|
||||
<N8nActionDropdown
|
||||
v-show="isDropdownVisible"
|
||||
data-test-id="data-store-column-header-actions"
|
||||
:items="columnActionItems"
|
||||
:placement="'bottom-start'"
|
||||
:activator-icon="'ellipsis'"
|
||||
@select="onItemClick"
|
||||
@visible-change="onDropdownVisibleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
// TODO: neither scoped nor module works here. Is there a way to resolve this?
|
||||
.data-store-column-header-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.data-store-column-header-action-item {
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.data-store-column-header-icon-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-store-column-header-icon-wrapper .n8n-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ag-header-cell-text {
|
||||
@include mixins.utils-ellipsis;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import type {
|
||||
DataStore,
|
||||
DataStoreColumn,
|
||||
@@ -7,6 +8,15 @@ import type {
|
||||
DataStoreRow,
|
||||
} from '@/features/dataStore/datastore.types';
|
||||
import { AgGridVue } from 'ag-grid-vue3';
|
||||
import type {
|
||||
GridApi,
|
||||
GridReadyEvent,
|
||||
ColDef,
|
||||
ColumnMovedEvent,
|
||||
ValueGetterParams,
|
||||
RowSelectionOptions,
|
||||
CellValueChangedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import {
|
||||
ModuleRegistry,
|
||||
ClientSideRowModelModule,
|
||||
@@ -22,19 +32,14 @@ import {
|
||||
ValidationModule,
|
||||
UndoRedoEditModule,
|
||||
} from 'ag-grid-community';
|
||||
import type {
|
||||
GridApi,
|
||||
GridReadyEvent,
|
||||
ColDef,
|
||||
RowSelectionOptions,
|
||||
CellValueChangedEvent,
|
||||
ValueGetterParams,
|
||||
} from 'ag-grid-community';
|
||||
import { n8nTheme } from '@/features/dataStore/components/dataGrid/n8nTheme';
|
||||
import AddColumnPopover from '@/features/dataStore/components/dataGrid/AddColumnPopover.vue';
|
||||
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { MODAL_CONFIRM } from '@/constants';
|
||||
import ColumnHeader from '@/features/dataStore/components/dataGrid/ColumnHeader.vue';
|
||||
import { DEFAULT_ID_COLUMN_NAME, NO_TABLE_YET_MESSAGE } from '@/features/dataStore/constants';
|
||||
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
|
||||
|
||||
@@ -66,6 +71,7 @@ const emit = defineEmits<{
|
||||
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const message = useMessage();
|
||||
const dataStoreTypes = useDataStoreTypes();
|
||||
|
||||
const dataStoreStore = useDataStoreStore();
|
||||
@@ -102,6 +108,13 @@ const onGridReady = (params: GridReadyEvent) => {
|
||||
gridApi.value = params.api;
|
||||
};
|
||||
|
||||
const refreshGridData = () => {
|
||||
if (gridApi.value) {
|
||||
gridApi.value.setGridOption('columnDefs', colDefs.value);
|
||||
gridApi.value.setGridOption('rowData', rowData.value);
|
||||
}
|
||||
};
|
||||
|
||||
const setCurrentPage = async (page: number) => {
|
||||
currentPage.value = page;
|
||||
await fetchDataStoreContent();
|
||||
@@ -128,12 +141,59 @@ const onAddColumn = async ({ column }: { column: DataStoreColumnCreatePayload })
|
||||
}
|
||||
};
|
||||
|
||||
const createColumnDef = (col: DataStoreColumn) => {
|
||||
const onDeleteColumn = async (columnId: string) => {
|
||||
if (!gridApi.value) return;
|
||||
|
||||
const columnToDelete = colDefs.value.find((col) => col.colId === columnId);
|
||||
if (!columnToDelete) return;
|
||||
|
||||
const promptResponse = await message.confirm(
|
||||
i18n.baseText('dataStore.deleteColumn.confirm.message', {
|
||||
interpolate: { name: columnToDelete.headerName ?? '' },
|
||||
}),
|
||||
i18n.baseText('dataStore.deleteColumn.confirm.title'),
|
||||
{
|
||||
confirmButtonText: i18n.baseText('generic.delete'),
|
||||
cancelButtonText: i18n.baseText('generic.cancel'),
|
||||
},
|
||||
);
|
||||
|
||||
if (promptResponse !== MODAL_CONFIRM) {
|
||||
return;
|
||||
}
|
||||
|
||||
const columnToDeleteIndex = colDefs.value.findIndex((col) => col.colId === columnId);
|
||||
colDefs.value = colDefs.value.filter((def) => def.colId !== columnId);
|
||||
const rowDataOldValue = [...rowData.value];
|
||||
rowData.value = rowData.value.map((row) => {
|
||||
const { [columnToDelete.field ?? '']: _, ...rest } = row;
|
||||
return rest;
|
||||
});
|
||||
refreshGridData();
|
||||
try {
|
||||
await dataStoreStore.deleteDataStoreColumn(
|
||||
props.dataStore.id,
|
||||
props.dataStore.projectId,
|
||||
columnId,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('dataStore.deleteColumn.error'));
|
||||
colDefs.value.splice(columnToDeleteIndex, 0, columnToDelete);
|
||||
rowData.value = rowDataOldValue;
|
||||
refreshGridData();
|
||||
}
|
||||
};
|
||||
|
||||
const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {}) => {
|
||||
const columnDef: ColDef = {
|
||||
colId: col.id,
|
||||
field: col.name,
|
||||
headerName: col.name,
|
||||
editable: col.name !== DEFAULT_ID_COLUMN_NAME,
|
||||
editable: true,
|
||||
resizable: true,
|
||||
headerComponent: ColumnHeader,
|
||||
headerComponentParams: { onDelete: onDeleteColumn },
|
||||
...extraProps,
|
||||
cellDataType: dataStoreTypes.mapToAGCellType(col.type),
|
||||
valueGetter: (params: ValueGetterParams<DataStoreRow>) => {
|
||||
// If the value is null, return null to show empty cell
|
||||
@@ -162,6 +222,30 @@ const createColumnDef = (col: DataStoreColumn) => {
|
||||
return columnDef;
|
||||
};
|
||||
|
||||
const onColumnMoved = async (moveEvent: ColumnMovedEvent) => {
|
||||
if (
|
||||
!moveEvent.finished ||
|
||||
moveEvent.source !== 'uiColumnMoved' ||
|
||||
moveEvent.toIndex === undefined ||
|
||||
!moveEvent.column
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldIndex = colDefs.value.findIndex((col) => col.colId === moveEvent.column!.getColId());
|
||||
try {
|
||||
await dataStoreStore.moveDataStoreColumn(
|
||||
props.dataStore.id,
|
||||
props.dataStore.projectId,
|
||||
moveEvent.column.getColId(),
|
||||
moveEvent.toIndex - 1,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('dataStore.moveColumn.error'));
|
||||
gridApi.value?.moveColumnByIndex(moveEvent.toIndex, oldIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const onAddRowClick = async () => {
|
||||
try {
|
||||
// Go to last page if we are not there already
|
||||
@@ -185,14 +269,22 @@ const initColumnDefinitions = () => {
|
||||
colDefs.value = [
|
||||
// Always add the ID column, it's not returned by the back-end but all data stores have it
|
||||
// We use it as a placeholder for new datastores
|
||||
createColumnDef({
|
||||
index: 0,
|
||||
id: DEFAULT_ID_COLUMN_NAME,
|
||||
name: DEFAULT_ID_COLUMN_NAME,
|
||||
type: 'string',
|
||||
}),
|
||||
createColumnDef(
|
||||
{
|
||||
index: 0,
|
||||
id: DEFAULT_ID_COLUMN_NAME,
|
||||
name: DEFAULT_ID_COLUMN_NAME,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
editable: false,
|
||||
suppressMovable: true,
|
||||
headerComponent: null,
|
||||
lockPosition: true,
|
||||
},
|
||||
),
|
||||
// Append other columns
|
||||
...props.dataStore.columns.map(createColumnDef),
|
||||
...orderBy(props.dataStore.columns, 'index').map((col) => createColumnDef(col)),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -256,8 +348,11 @@ onMounted(async () => {
|
||||
:column-defs="colDefs"
|
||||
:default-col-def="defaultColumnDef"
|
||||
:dom-layout="'autoHeight'"
|
||||
:row-height="36"
|
||||
:header-height="36"
|
||||
:animate-rows="false"
|
||||
:theme="n8nTheme"
|
||||
:suppress-drag-leave-hides-columns="true"
|
||||
:loading="contentLoading"
|
||||
:row-selection="rowSelection"
|
||||
:get-row-id="(params) => String(params.data.id)"
|
||||
@@ -266,6 +361,7 @@ onMounted(async () => {
|
||||
:undo-redo-cell-editing="true"
|
||||
@grid-ready="onGridReady"
|
||||
@cell-value-changed="onCellValueChanged"
|
||||
@column-moved="onColumnMoved"
|
||||
/>
|
||||
<AddColumnPopover
|
||||
:data-store="props.dataStore"
|
||||
@@ -326,7 +422,7 @@ onMounted(async () => {
|
||||
--ag-header-font-size: var(--font-size-xs);
|
||||
--ag-header-font-weight: var(--font-weight-bold);
|
||||
--ag-header-foreground-color: var(--color-text-dark);
|
||||
--ag-cell-horizontal-padding: calc(var(--ag-grid-size) * 0.7);
|
||||
--ag-cell-horizontal-padding: var(--spacing-2xs);
|
||||
--ag-header-column-resize-handle-color: var(--border-color-base);
|
||||
--ag-header-column-resize-handle-height: 100%;
|
||||
--ag-header-height: calc(var(--ag-grid-size) * 0.8 + 32px);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
DataStoreColumnType,
|
||||
DataStoreValue,
|
||||
} from '@/features/dataStore/datastore.types';
|
||||
import { isAGGridCellType } from '@/features/dataStore/typeGuards';
|
||||
|
||||
/* eslint-disable id-denylist */
|
||||
const COLUMN_TYPE_ICONS: Record<DataStoreColumnType, IconName> = {
|
||||
@@ -31,6 +32,16 @@ export const useDataStoreTypes = () => {
|
||||
return colType;
|
||||
};
|
||||
|
||||
const mapToDataStoreColumnType = (colType: AGGridCellType): DataStoreColumnType => {
|
||||
if (!isAGGridCellType(colType)) {
|
||||
return 'string';
|
||||
}
|
||||
if (colType === 'text') {
|
||||
return 'string';
|
||||
}
|
||||
return colType as DataStoreColumnType;
|
||||
};
|
||||
|
||||
const getDefaultValueForType = (colType: DataStoreColumnType): DataStoreValue => {
|
||||
switch (colType) {
|
||||
case 'string':
|
||||
@@ -49,6 +60,7 @@ export const useDataStoreTypes = () => {
|
||||
return {
|
||||
getIconForType,
|
||||
mapToAGCellType,
|
||||
mapToDataStoreColumnType,
|
||||
getDefaultValueForType,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -98,6 +98,36 @@ export const addDataStoreColumnApi = async (
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteDataStoreColumnApi = async (
|
||||
context: IRestApiContext,
|
||||
dataStoreId: string,
|
||||
projectId: string,
|
||||
columnId: string,
|
||||
) => {
|
||||
return await makeRestApiRequest<boolean>(
|
||||
context,
|
||||
'DELETE',
|
||||
`/projects/${projectId}/data-stores/${dataStoreId}/columns/${columnId}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const moveDataStoreColumnApi = async (
|
||||
context: IRestApiContext,
|
||||
dataStoreId: string,
|
||||
projectId: string,
|
||||
columnId: string,
|
||||
targetIndex: number,
|
||||
) => {
|
||||
return await makeRestApiRequest<boolean>(
|
||||
context,
|
||||
'PATCH',
|
||||
`/projects/${projectId}/data-stores/${dataStoreId}/columns/${columnId}/move`,
|
||||
{
|
||||
targetIndex,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getDataStoreRowsApi = async (
|
||||
context: IRestApiContext,
|
||||
dataStoreId: string,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import * as dataStoreApi from '@/features/dataStore/dataStore.api';
|
||||
|
||||
describe('dataStore.store', () => {
|
||||
let dataStoreStore: ReturnType<typeof useDataStoreStore>;
|
||||
let rootStore: ReturnType<typeof useRootStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
rootStore = useRootStore();
|
||||
dataStoreStore = useDataStoreStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('can move a column', async () => {
|
||||
const datastoreId = faker.string.alphanumeric(10);
|
||||
const columnId = 'phone';
|
||||
const targetIndex = 3;
|
||||
const projectId = 'p1';
|
||||
dataStoreStore.$patch({
|
||||
dataStores: [
|
||||
{
|
||||
id: datastoreId,
|
||||
name: 'Test',
|
||||
sizeBytes: 0,
|
||||
recordCount: 0,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
projectId,
|
||||
columns: [
|
||||
{ id: 'name', index: 0, name: 'name', type: 'string' },
|
||||
{ id: columnId, index: 1, name: 'phone', type: 'string' },
|
||||
{ id: 'email', index: 2, name: 'email', type: 'string' },
|
||||
{ id: 'col4', index: 3, name: 'col4', type: 'string' },
|
||||
{ id: 'col5', index: 4, name: 'col5', type: 'string' },
|
||||
],
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
vi.spyOn(dataStoreApi, 'moveDataStoreColumnApi').mockResolvedValue(true);
|
||||
|
||||
const moved = await dataStoreStore.moveDataStoreColumn(
|
||||
datastoreId,
|
||||
projectId,
|
||||
columnId,
|
||||
targetIndex,
|
||||
);
|
||||
|
||||
expect(moved).toBe(true);
|
||||
expect(dataStoreApi.moveDataStoreColumnApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
datastoreId,
|
||||
projectId,
|
||||
columnId,
|
||||
targetIndex,
|
||||
);
|
||||
expect(dataStoreStore.dataStores[0].columns.find((c) => c.id === columnId)?.index).toBe(
|
||||
targetIndex,
|
||||
);
|
||||
});
|
||||
|
||||
it('can delete a column', async () => {
|
||||
const datastoreId = faker.string.alphanumeric(10);
|
||||
const columnId = 'phone';
|
||||
const projectId = 'p1';
|
||||
dataStoreStore.$patch({
|
||||
dataStores: [
|
||||
{ id: datastoreId, columns: [{ id: columnId, index: 0, name: 'phone', type: 'string' }] },
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
vi.spyOn(dataStoreApi, 'deleteDataStoreColumnApi').mockResolvedValue(true);
|
||||
|
||||
const deleted = await dataStoreStore.deleteDataStoreColumn(datastoreId, projectId, columnId);
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(dataStoreApi.deleteDataStoreColumnApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
datastoreId,
|
||||
projectId,
|
||||
columnId,
|
||||
);
|
||||
expect(dataStoreStore.dataStores[0].columns.find((c) => c.id === columnId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
deleteDataStoreApi,
|
||||
updateDataStoreApi,
|
||||
addDataStoreColumnApi,
|
||||
deleteDataStoreColumnApi,
|
||||
moveDataStoreColumnApi,
|
||||
getDataStoreRowsApi,
|
||||
insertDataStoreRowApi,
|
||||
upsertDataStoreRowsApi,
|
||||
@@ -60,6 +62,28 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
|
||||
return deleted;
|
||||
};
|
||||
|
||||
const deleteDataStoreColumn = async (
|
||||
datastoreId: string,
|
||||
projectId: string,
|
||||
columnId: string,
|
||||
) => {
|
||||
const deleted = await deleteDataStoreColumnApi(
|
||||
rootStore.restApiContext,
|
||||
datastoreId,
|
||||
projectId,
|
||||
columnId,
|
||||
);
|
||||
if (deleted) {
|
||||
const index = dataStores.value.findIndex((store) => store.id === datastoreId);
|
||||
if (index !== -1) {
|
||||
dataStores.value[index].columns = dataStores.value[index].columns.filter(
|
||||
(col) => col.id !== columnId,
|
||||
);
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
};
|
||||
|
||||
const updateDataStore = async (datastoreId: string, name: string, projectId: string) => {
|
||||
const updated = await updateDataStoreApi(
|
||||
rootStore.restApiContext,
|
||||
@@ -82,6 +106,7 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
|
||||
id: datastoreId,
|
||||
});
|
||||
if (response.data.length > 0) {
|
||||
dataStores.value = response.data;
|
||||
return response.data[0];
|
||||
}
|
||||
return null;
|
||||
@@ -115,6 +140,36 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
|
||||
return newColumn;
|
||||
};
|
||||
|
||||
const moveDataStoreColumn = async (
|
||||
datastoreId: string,
|
||||
projectId: string,
|
||||
columnId: string,
|
||||
targetIndex: number,
|
||||
) => {
|
||||
const moved = await moveDataStoreColumnApi(
|
||||
rootStore.restApiContext,
|
||||
datastoreId,
|
||||
projectId,
|
||||
columnId,
|
||||
targetIndex,
|
||||
);
|
||||
if (moved) {
|
||||
const dsIndex = dataStores.value.findIndex((store) => store.id === datastoreId);
|
||||
const fromIndex = dataStores.value[dsIndex].columns.findIndex((col) => col.id === columnId);
|
||||
dataStores.value[dsIndex].columns = dataStores.value[dsIndex].columns.map((col) => {
|
||||
if (col.id === columnId) return { ...col, index: targetIndex };
|
||||
if (fromIndex < targetIndex && col.index > fromIndex && col.index <= targetIndex) {
|
||||
return { ...col, index: col.index - 1 };
|
||||
}
|
||||
if (fromIndex > targetIndex && col.index >= targetIndex && col.index < fromIndex) {
|
||||
return { ...col, index: col.index + 1 };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
}
|
||||
return moved;
|
||||
};
|
||||
|
||||
const fetchDataStoreContent = async (
|
||||
datastoreId: string,
|
||||
projectId: string,
|
||||
@@ -155,6 +210,8 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
|
||||
fetchDataStoreDetails,
|
||||
fetchOrFindDataStore,
|
||||
addDataStoreColumn,
|
||||
deleteDataStoreColumn,
|
||||
moveDataStoreColumn,
|
||||
fetchDataStoreContent,
|
||||
insertEmptyRow,
|
||||
upsertRow,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DataStoreValue } from '@/features/dataStore/datastore.types';
|
||||
import type { AGGridCellType, DataStoreValue } from '@/features/dataStore/datastore.types';
|
||||
|
||||
export const isDataStoreValue = (value: unknown): value is DataStoreValue => {
|
||||
return (
|
||||
@@ -9,3 +9,10 @@ export const isDataStoreValue = (value: unknown): value is DataStoreValue => {
|
||||
value instanceof Date
|
||||
);
|
||||
};
|
||||
|
||||
export const isAGGridCellType = (value: unknown): value is AGGridCellType => {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
['text', 'number', 'boolean', 'date', 'dateString', 'object'].includes(value)
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user