mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +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),
|
createdAt: expect.any(Date),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
|
index: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
@@ -805,6 +806,7 @@ describe('dataStore', () => {
|
|||||||
createdAt: expect.any(Date),
|
createdAt: expect.any(Date),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
|
index: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
@@ -815,6 +817,7 @@ describe('dataStore', () => {
|
|||||||
createdAt: expect.any(Date),
|
createdAt: expect.any(Date),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
|
index: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
@@ -825,6 +828,7 @@ describe('dataStore', () => {
|
|||||||
createdAt: expect.any(Date),
|
createdAt: expect.any(Date),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
|
index: 3,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -221,8 +221,7 @@ export class DataStoreRepository extends Repository<DataStore> {
|
|||||||
'dataStore',
|
'dataStore',
|
||||||
...this.getDataStoreColumnFields('data_store_column'),
|
...this.getDataStoreColumnFields('data_store_column'),
|
||||||
...this.getProjectFields('project'),
|
...this.getProjectFields('project'),
|
||||||
])
|
]);
|
||||||
.addOrderBy('data_store_column.index', 'ASC');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDataStoreColumnFields(alias: string): string[] {
|
private getDataStoreColumnFields(alias: string): string[] {
|
||||||
@@ -232,6 +231,7 @@ export class DataStoreRepository extends Repository<DataStore> {
|
|||||||
`${alias}.type`,
|
`${alias}.type`,
|
||||||
`${alias}.createdAt`,
|
`${alias}.createdAt`,
|
||||||
`${alias}.updatedAt`,
|
`${alias}.updatedAt`,
|
||||||
|
`${alias}.index`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2869,6 +2869,10 @@
|
|||||||
"dataStore.addColumn.nameInput.placeholder": "Enter column name",
|
"dataStore.addColumn.nameInput.placeholder": "Enter column name",
|
||||||
"dataStore.addColumn.typeInput.label": "@:_reusableBaseText.type",
|
"dataStore.addColumn.typeInput.label": "@:_reusableBaseText.type",
|
||||||
"dataStore.addColumn.error": "Error adding column",
|
"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.error": "Invalid column name",
|
||||||
"dataStore.addColumn.invalidName.description": "Only alphanumeric characters and non-leading dashes are allowed for column names",
|
"dataStore.addColumn.invalidName.description": "Only alphanumeric characters and non-leading dashes are allowed for column names",
|
||||||
"dataStore.fetchContent.error": "Error fetching data store content",
|
"dataStore.fetchContent.error": "Error fetching data store content",
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ const onInput = debounce(validateName, { debounceTime: 300 });
|
|||||||
<template #trigger>
|
<template #trigger>
|
||||||
<N8nIconButton
|
<N8nIconButton
|
||||||
data-test-id="data-store-add-column-trigger-button"
|
data-test-id="data-store-add-column-trigger-button"
|
||||||
|
text
|
||||||
icon="plus"
|
icon="plus"
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
/>
|
/>
|
||||||
@@ -168,7 +169,7 @@ const onInput = debounce(validateName, { debounceTime: 300 });
|
|||||||
padding: var(--spacing-2xs);
|
padding: var(--spacing-2xs);
|
||||||
border: var(--border-base);
|
border: var(--border-base);
|
||||||
border-left: none;
|
border-left: none;
|
||||||
height: 50px;
|
height: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popover-content {
|
.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">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
import orderBy from 'lodash/orderBy';
|
||||||
import type {
|
import type {
|
||||||
DataStore,
|
DataStore,
|
||||||
DataStoreColumn,
|
DataStoreColumn,
|
||||||
@@ -7,6 +8,15 @@ import type {
|
|||||||
DataStoreRow,
|
DataStoreRow,
|
||||||
} from '@/features/dataStore/datastore.types';
|
} from '@/features/dataStore/datastore.types';
|
||||||
import { AgGridVue } from 'ag-grid-vue3';
|
import { AgGridVue } from 'ag-grid-vue3';
|
||||||
|
import type {
|
||||||
|
GridApi,
|
||||||
|
GridReadyEvent,
|
||||||
|
ColDef,
|
||||||
|
ColumnMovedEvent,
|
||||||
|
ValueGetterParams,
|
||||||
|
RowSelectionOptions,
|
||||||
|
CellValueChangedEvent,
|
||||||
|
} from 'ag-grid-community';
|
||||||
import {
|
import {
|
||||||
ModuleRegistry,
|
ModuleRegistry,
|
||||||
ClientSideRowModelModule,
|
ClientSideRowModelModule,
|
||||||
@@ -22,19 +32,14 @@ import {
|
|||||||
ValidationModule,
|
ValidationModule,
|
||||||
UndoRedoEditModule,
|
UndoRedoEditModule,
|
||||||
} from 'ag-grid-community';
|
} 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 { n8nTheme } from '@/features/dataStore/components/dataGrid/n8nTheme';
|
||||||
import AddColumnPopover from '@/features/dataStore/components/dataGrid/AddColumnPopover.vue';
|
import AddColumnPopover from '@/features/dataStore/components/dataGrid/AddColumnPopover.vue';
|
||||||
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { useToast } from '@/composables/useToast';
|
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 { DEFAULT_ID_COLUMN_NAME, NO_TABLE_YET_MESSAGE } from '@/features/dataStore/constants';
|
||||||
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
|
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
|
||||||
|
|
||||||
@@ -66,6 +71,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const message = useMessage();
|
||||||
const dataStoreTypes = useDataStoreTypes();
|
const dataStoreTypes = useDataStoreTypes();
|
||||||
|
|
||||||
const dataStoreStore = useDataStoreStore();
|
const dataStoreStore = useDataStoreStore();
|
||||||
@@ -102,6 +108,13 @@ const onGridReady = (params: GridReadyEvent) => {
|
|||||||
gridApi.value = params.api;
|
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) => {
|
const setCurrentPage = async (page: number) => {
|
||||||
currentPage.value = page;
|
currentPage.value = page;
|
||||||
await fetchDataStoreContent();
|
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 = {
|
const columnDef: ColDef = {
|
||||||
colId: col.id,
|
colId: col.id,
|
||||||
field: col.name,
|
field: col.name,
|
||||||
headerName: 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),
|
cellDataType: dataStoreTypes.mapToAGCellType(col.type),
|
||||||
valueGetter: (params: ValueGetterParams<DataStoreRow>) => {
|
valueGetter: (params: ValueGetterParams<DataStoreRow>) => {
|
||||||
// If the value is null, return null to show empty cell
|
// If the value is null, return null to show empty cell
|
||||||
@@ -162,6 +222,30 @@ const createColumnDef = (col: DataStoreColumn) => {
|
|||||||
return columnDef;
|
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 () => {
|
const onAddRowClick = async () => {
|
||||||
try {
|
try {
|
||||||
// Go to last page if we are not there already
|
// Go to last page if we are not there already
|
||||||
@@ -185,14 +269,22 @@ const initColumnDefinitions = () => {
|
|||||||
colDefs.value = [
|
colDefs.value = [
|
||||||
// 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
|
||||||
// We use it as a placeholder for new datastores
|
// We use it as a placeholder for new datastores
|
||||||
createColumnDef({
|
createColumnDef(
|
||||||
index: 0,
|
{
|
||||||
id: DEFAULT_ID_COLUMN_NAME,
|
index: 0,
|
||||||
name: DEFAULT_ID_COLUMN_NAME,
|
id: DEFAULT_ID_COLUMN_NAME,
|
||||||
type: 'string',
|
name: DEFAULT_ID_COLUMN_NAME,
|
||||||
}),
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
editable: false,
|
||||||
|
suppressMovable: true,
|
||||||
|
headerComponent: null,
|
||||||
|
lockPosition: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
// Append other columns
|
// 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"
|
:column-defs="colDefs"
|
||||||
:default-col-def="defaultColumnDef"
|
:default-col-def="defaultColumnDef"
|
||||||
:dom-layout="'autoHeight'"
|
:dom-layout="'autoHeight'"
|
||||||
|
:row-height="36"
|
||||||
|
:header-height="36"
|
||||||
:animate-rows="false"
|
:animate-rows="false"
|
||||||
:theme="n8nTheme"
|
:theme="n8nTheme"
|
||||||
|
:suppress-drag-leave-hides-columns="true"
|
||||||
:loading="contentLoading"
|
:loading="contentLoading"
|
||||||
:row-selection="rowSelection"
|
:row-selection="rowSelection"
|
||||||
:get-row-id="(params) => String(params.data.id)"
|
:get-row-id="(params) => String(params.data.id)"
|
||||||
@@ -266,6 +361,7 @@ onMounted(async () => {
|
|||||||
:undo-redo-cell-editing="true"
|
:undo-redo-cell-editing="true"
|
||||||
@grid-ready="onGridReady"
|
@grid-ready="onGridReady"
|
||||||
@cell-value-changed="onCellValueChanged"
|
@cell-value-changed="onCellValueChanged"
|
||||||
|
@column-moved="onColumnMoved"
|
||||||
/>
|
/>
|
||||||
<AddColumnPopover
|
<AddColumnPopover
|
||||||
:data-store="props.dataStore"
|
:data-store="props.dataStore"
|
||||||
@@ -326,7 +422,7 @@ onMounted(async () => {
|
|||||||
--ag-header-font-size: var(--font-size-xs);
|
--ag-header-font-size: var(--font-size-xs);
|
||||||
--ag-header-font-weight: var(--font-weight-bold);
|
--ag-header-font-weight: var(--font-weight-bold);
|
||||||
--ag-header-foreground-color: var(--color-text-dark);
|
--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-color: var(--border-color-base);
|
||||||
--ag-header-column-resize-handle-height: 100%;
|
--ag-header-column-resize-handle-height: 100%;
|
||||||
--ag-header-height: calc(var(--ag-grid-size) * 0.8 + 32px);
|
--ag-header-height: calc(var(--ag-grid-size) * 0.8 + 32px);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
DataStoreColumnType,
|
DataStoreColumnType,
|
||||||
DataStoreValue,
|
DataStoreValue,
|
||||||
} from '@/features/dataStore/datastore.types';
|
} from '@/features/dataStore/datastore.types';
|
||||||
|
import { isAGGridCellType } from '@/features/dataStore/typeGuards';
|
||||||
|
|
||||||
/* eslint-disable id-denylist */
|
/* eslint-disable id-denylist */
|
||||||
const COLUMN_TYPE_ICONS: Record<DataStoreColumnType, IconName> = {
|
const COLUMN_TYPE_ICONS: Record<DataStoreColumnType, IconName> = {
|
||||||
@@ -31,6 +32,16 @@ export const useDataStoreTypes = () => {
|
|||||||
return colType;
|
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 => {
|
const getDefaultValueForType = (colType: DataStoreColumnType): DataStoreValue => {
|
||||||
switch (colType) {
|
switch (colType) {
|
||||||
case 'string':
|
case 'string':
|
||||||
@@ -49,6 +60,7 @@ export const useDataStoreTypes = () => {
|
|||||||
return {
|
return {
|
||||||
getIconForType,
|
getIconForType,
|
||||||
mapToAGCellType,
|
mapToAGCellType,
|
||||||
|
mapToDataStoreColumnType,
|
||||||
getDefaultValueForType,
|
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 (
|
export const getDataStoreRowsApi = async (
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
dataStoreId: string,
|
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,
|
deleteDataStoreApi,
|
||||||
updateDataStoreApi,
|
updateDataStoreApi,
|
||||||
addDataStoreColumnApi,
|
addDataStoreColumnApi,
|
||||||
|
deleteDataStoreColumnApi,
|
||||||
|
moveDataStoreColumnApi,
|
||||||
getDataStoreRowsApi,
|
getDataStoreRowsApi,
|
||||||
insertDataStoreRowApi,
|
insertDataStoreRowApi,
|
||||||
upsertDataStoreRowsApi,
|
upsertDataStoreRowsApi,
|
||||||
@@ -60,6 +62,28 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
|
|||||||
return deleted;
|
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 updateDataStore = async (datastoreId: string, name: string, projectId: string) => {
|
||||||
const updated = await updateDataStoreApi(
|
const updated = await updateDataStoreApi(
|
||||||
rootStore.restApiContext,
|
rootStore.restApiContext,
|
||||||
@@ -82,6 +106,7 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
|
|||||||
id: datastoreId,
|
id: datastoreId,
|
||||||
});
|
});
|
||||||
if (response.data.length > 0) {
|
if (response.data.length > 0) {
|
||||||
|
dataStores.value = response.data;
|
||||||
return response.data[0];
|
return response.data[0];
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -115,6 +140,36 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
|
|||||||
return newColumn;
|
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 (
|
const fetchDataStoreContent = async (
|
||||||
datastoreId: string,
|
datastoreId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
@@ -155,6 +210,8 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
|
|||||||
fetchDataStoreDetails,
|
fetchDataStoreDetails,
|
||||||
fetchOrFindDataStore,
|
fetchOrFindDataStore,
|
||||||
addDataStoreColumn,
|
addDataStoreColumn,
|
||||||
|
deleteDataStoreColumn,
|
||||||
|
moveDataStoreColumn,
|
||||||
fetchDataStoreContent,
|
fetchDataStoreContent,
|
||||||
insertEmptyRow,
|
insertEmptyRow,
|
||||||
upsertRow,
|
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 => {
|
export const isDataStoreValue = (value: unknown): value is DataStoreValue => {
|
||||||
return (
|
return (
|
||||||
@@ -9,3 +9,10 @@ export const isDataStoreValue = (value: unknown): value is DataStoreValue => {
|
|||||||
value instanceof Date
|
value instanceof Date
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isAGGridCellType = (value: unknown): value is AGGridCellType => {
|
||||||
|
return (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
['text', 'number', 'boolean', 'date', 'dateString', 'object'].includes(value)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -33587,4 +33587,4 @@ snapshots:
|
|||||||
zx@8.1.4:
|
zx@8.1.4:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/fs-extra': 11.0.4
|
'@types/fs-extra': 11.0.4
|
||||||
'@types/node': 20.17.57
|
'@types/node': 20.17.57
|
||||||
Reference in New Issue
Block a user