feat(editor): Add custom data store column headers (no-changelog) (#18390)

Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
Svetoslav Dekov
2025-08-19 17:57:35 +02:00
committed by GitHub
parent 169acd12bd
commit 3d2e165ac5
13 changed files with 517 additions and 23 deletions

View File

@@ -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,
}, },
]), ]),
); );

View File

@@ -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`,
]; ];
} }

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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');
});
});

View File

@@ -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>

View File

@@ -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);

View File

@@ -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,
}; };
}; };

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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
View File

@@ -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