feat(editor): Update data store front-end (no-changelog) (#18000)

This commit is contained in:
Milorad FIlipović
2025-08-05 22:47:57 +02:00
committed by GitHub
parent acfb79bd97
commit d6ee6067cf
10 changed files with 301 additions and 70 deletions

View File

@@ -105,6 +105,7 @@
"generic.rename": "Rename",
"generic.missing.permissions": "Missing permissions to perform this action",
"generic.shortcutHint": "Or press",
"generic.unknownError": "An unknown error occurred",
"generic.upgradeToEnterprise": "Upgrade to Enterprise",
"generic.never": "Never",
"about.aboutN8n": "About n8n",
@@ -2817,10 +2818,14 @@
"dataStore.error.fetching": "Error loading data stores",
"dataStore.add.title": "Create data store",
"dataStore.add.description": "Set up a new data store to organize and manage your data.",
"dataStore.add.button.label": "Create data store",
"dataStore.add.button.label": "Create Data Store",
"dataStore.add.input.name.label": "Data Store Name",
"dataStore.add.input.name.placeholder": "Enter data store name",
"dataStore.add.error": "Error creating data store",
"dataStore.delete.confirm.title": "Delete data store",
"dataStore.delete.confirm.message": "Are you sure you want to delete the data store \"{name}\"? This action cannot be undone.",
"dataStore.delete.error": "Error deleting data store",
"dataStore.rename.error": "Error renaming data store",
"settings.ldap": "LDAP",
"settings.ldap.note": "LDAP allows users to authenticate with their centralized account. It's compatible with services that provide an LDAP interface like Active Directory, Okta and Jumpcloud.",
"settings.ldap.infoTip": "Learn more about <a href='https://docs.n8n.io/user-management/ldap/' target='_blank'>LDAP in the Docs</a>",

View File

@@ -21,6 +21,12 @@ import type { IUser } from 'n8n-workflow';
import { type IconOrEmoji, isIconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
import { useUIStore } from '@/stores/ui.store';
export type CustomAction = {
id: string;
label: string;
disabled?: boolean;
};
const route = useRoute();
const router = useRouter();
const i18n = useI18n();
@@ -31,8 +37,17 @@ const uiStore = useUIStore();
const projectPages = useProjectPages();
type Props = {
customActions?: CustomAction[];
};
const props = withDefaults(defineProps<Props>(), {
customActions: () => [],
});
const emit = defineEmits<{
createFolder: [];
customActionSelected: [actionId: string, projectId: string];
}>();
const headerIcon = computed((): IconOrEmoji => {
@@ -139,6 +154,17 @@ const menu = computed(() => {
!getResourcePermissions(homeProject.value?.scopes).folder.create,
});
}
// Append custom actions
if (props.customActions?.length) {
props.customActions.forEach((customAction) => {
items.push({
value: customAction.id,
label: customAction.label,
disabled: customAction.disabled ?? false,
});
});
}
return items;
});
@@ -240,6 +266,13 @@ const onSelect = (action: string) => {
if (!homeProject.value) {
return;
}
// Check if this is a custom action
if (!executableAction) {
emit('customActionSelected', action, homeProject.value.id);
return;
}
executableAction(homeProject.value.id);
};
</script>

View File

@@ -100,9 +100,9 @@ const initialState = {
const TEST_DATA_STORE: DataStoreResource = {
id: '1',
name: 'Test Data Store',
size: 1024,
sizeBytes: 1024,
recordCount: 100,
columnCount: 5,
columns: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
resourceType: 'datastore',
@@ -123,7 +123,7 @@ describe('DataStoreView', () => {
// Mock dataStore store state
dataStoreStore.dataStores = [TEST_DATA_STORE];
dataStoreStore.totalCount = 1;
dataStoreStore.loadDataStores = vi.fn().mockResolvedValue(undefined);
dataStoreStore.fetchDataStores = vi.fn().mockResolvedValue(undefined);
projectsStore.getCurrentProjectId = vi.fn(() => 'test-project');
sourceControlStore.isProjectShared = vi.fn(() => false);
@@ -134,7 +134,7 @@ describe('DataStoreView', () => {
const { getByTestId } = renderComponent({ pinia });
await waitAllPromises();
expect(dataStoreStore.loadDataStores).toHaveBeenCalledWith('test-project', 1, 25);
expect(dataStoreStore.fetchDataStores).toHaveBeenCalledWith('test-project', 1, 25);
expect(getByTestId('resources-list-wrapper')).toBeInTheDocument();
});
@@ -147,7 +147,7 @@ describe('DataStoreView', () => {
it('should handle initialization error', async () => {
const error = new Error('Store Error');
dataStoreStore.loadDataStores = vi.fn().mockRejectedValue(error);
dataStoreStore.fetchDataStores = vi.fn().mockRejectedValue(error);
renderComponent({ pinia });
await waitAllPromises();
@@ -201,7 +201,7 @@ describe('DataStoreView', () => {
await waitAllPromises();
// Clear the initial call
dataStoreStore.loadDataStores = vi.fn().mockClear();
dataStoreStore.fetchDataStores = vi.fn().mockClear();
mockDebounce.callDebounced.mockClear();
// The component should be rendered and ready to handle pagination
@@ -223,7 +223,7 @@ describe('DataStoreView', () => {
await waitAllPromises();
// Initial call should use default page size of 25
expect(dataStoreStore.loadDataStores).toHaveBeenCalledWith('test-project', 1, 25);
expect(dataStoreStore.fetchDataStores).toHaveBeenCalledWith('test-project', 1, 25);
});
});
});

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import ProjectHeader, { type CustomAction } from '@/components/Projects/ProjectHeader.vue';
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { useProjectPages } from '@/composables/useProjectPages';
@@ -24,6 +24,8 @@ import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useToast } from '@/composables/useToast';
import { useUIStore } from '@/stores/ui.store';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useMessage } from '@/composables/useMessage';
import { MODAL_CONFIRM } from '@/constants';
const i18n = useI18n();
const route = useRoute();
@@ -31,6 +33,7 @@ const projectPages = useProjectPages();
const { callDebounced } = useDebounce();
const documentTitle = useDocumentTitle();
const toast = useToast();
const message = useMessage();
const dataStoreStore = useDataStoreStore();
const insightsStore = useInsightsStore();
@@ -42,6 +45,14 @@ const loading = ref(true);
const currentPage = ref(1);
const pageSize = ref(DEFAULT_DATA_STORE_PAGE_SIZE);
const customProjectActions = computed<CustomAction[]>(() => [
{
id: 'add-data-store',
label: i18n.baseText('dataStore.add.button.label'),
disabled: loading.value || projectPages.isOverviewSubPage,
},
]);
const dataStoreResources = computed<DataStoreResource[]>(() =>
dataStoreStore.dataStores.map((ds) => {
return {
@@ -88,11 +99,6 @@ const cardActions = computed<Array<UserAction<IUser>>>(() => [
value: DATA_STORE_CARD_ACTIONS.DELETE,
disabled: readOnlyEnv.value,
},
{
label: i18n.baseText('generic.clear'),
value: DATA_STORE_CARD_ACTIONS.CLEAR,
disabled: readOnlyEnv.value,
},
]);
const initialize = async () => {
@@ -101,7 +107,7 @@ const initialize = async () => {
? route.params.projectId[0]
: route.params.projectId;
try {
await dataStoreStore.loadDataStores(projectId, currentPage.value, pageSize.value);
await dataStoreStore.fetchDataStores(projectId, currentPage.value, pageSize.value);
} catch (error) {
toast.showError(error, 'Error loading data stores');
} finally {
@@ -125,6 +131,64 @@ const onAddModalClick = () => {
useUIStore().openModal(ADD_DATA_STORE_MODAL_KEY);
};
const onCardAction = async (payload: { action: string; dataStore: DataStoreResource }) => {
switch (payload.action) {
case DATA_STORE_CARD_ACTIONS.DELETE: {
const promptResponse = await message.confirm(
i18n.baseText('dataStore.delete.confirm.message', {
interpolate: { name: payload.dataStore.name },
}),
i18n.baseText('dataStore.delete.confirm.title'),
{
confirmButtonText: i18n.baseText('generic.delete'),
cancelButtonText: i18n.baseText('generic.cancel'),
},
);
if (promptResponse === MODAL_CONFIRM) {
try {
const deleted = await dataStoreStore.deleteDataStore(
payload.dataStore.id,
payload.dataStore.projectId,
);
if (!deleted) {
toast.showError(
new Error(i18n.baseText('generic.unknownError')),
i18n.baseText('dataStore.delete.error'),
);
}
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.delete.error'));
}
}
break;
}
case DATA_STORE_CARD_ACTIONS.RENAME: {
try {
const updated = await dataStoreStore.updateDataStore(
payload.dataStore.id,
payload.dataStore.name,
payload.dataStore.projectId,
);
if (!updated) {
toast.showError(
new Error(i18n.baseText('generic.unknownError')),
i18n.baseText('dataStore.rename.error'),
);
}
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.rename.error'));
}
break;
}
}
};
const onProjectHeaderAction = (action: string) => {
if (action === 'add-data-store') {
useUIStore().openModal(ADD_DATA_STORE_MODAL_KEY);
}
};
onMounted(() => {
documentTitle.set(i18n.baseText('dataStore.tab.label'));
});
@@ -149,7 +213,10 @@ onMounted(() => {
@update:pagination-and-sort="onPaginationUpdate"
>
<template #header>
<ProjectHeader>
<ProjectHeader
:custom-actions="customProjectActions"
@custom-action-selected="onProjectHeaderAction"
>
<InsightsSummary
v-if="projectPages.isOverviewSubPage && insightsStore.isSummaryEnabled"
:loading="insightsStore.weeklySummary.isLoading"
@@ -165,7 +232,7 @@ onMounted(() => {
:description="emptyCalloutDescription"
:button-text="emptyCalloutButtonText"
button-type="secondary"
@click="onAddModalClick"
@click:button="onAddModalClick"
/>
</template>
<template #item="{ item: data }">
@@ -175,6 +242,7 @@ onMounted(() => {
:show-ownership-badge="projectPages.isOverviewSubPage"
:actions="cardActions"
:read-only="readOnlyEnv"
@action="onCardAction"
/>
</template>
</ResourcesListLayout>

View File

@@ -31,7 +31,7 @@ onMounted(() => {
const onSubmit = async () => {
try {
await dataStoreStore.createNewDataStore(dataStoreName.value, route.params.projectId as string);
await dataStoreStore.createDataStore(dataStoreName.value, route.params.projectId as string);
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.add.error'));
} finally {

View File

@@ -26,9 +26,9 @@ vi.mock('vue-router', () => {
const DEFAULT_DATA_STORE: DataStoreResource = {
id: '1',
name: 'Test Data Store',
size: 1024,
sizeBytes: 1024,
recordCount: 100,
columnCount: 5,
columns: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
resourceType: 'datastore',
@@ -72,7 +72,7 @@ describe('DataStoreCard', () => {
it('should render data store info correctly', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('data-store-card-icon')).toBeInTheDocument();
expect(getByTestId('folder-card-name')).toHaveTextContent(DEFAULT_DATA_STORE.name);
expect(getByTestId('datastore-name-input')).toHaveTextContent(DEFAULT_DATA_STORE.name);
expect(getByTestId('data-store-card-record-count')).toBeInTheDocument();
expect(getByTestId('data-store-card-column-count')).toBeInTheDocument();
expect(getByTestId('data-store-card-last-updated')).toHaveTextContent('Last updated');
@@ -99,12 +99,12 @@ describe('DataStoreCard', () => {
actions: [],
},
});
expect(queryByTestId('folder-card-actions')).not.toBeInTheDocument();
expect(queryByTestId('data-store-card-actions')).not.toBeInTheDocument();
});
it('should render action dropdown if actions are provided', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('folder-card-actions')).toBeInTheDocument();
expect(getByTestId('data-store-card-actions')).toBeInTheDocument();
});
it('should render correct route to data store details', () => {
@@ -113,12 +113,6 @@ describe('DataStoreCard', () => {
expect(link).toBeInTheDocument();
});
it('should display size information', () => {
const { getByTestId } = renderComponent();
const sizeElement = getByTestId('folder-card-folder-count');
expect(sizeElement).toBeInTheDocument();
});
it('should display record count information', () => {
const { getByTestId } = renderComponent();
const recordCountElement = getByTestId('data-store-card-record-count');

View File

@@ -3,7 +3,7 @@ import type { IUser, UserAction } from '@/Interface';
import type { DataStoreResource } from '@/features/dataStore/types';
import { DATA_STORE_DETAILS } from '../constants';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import { computed, useTemplateRef } from 'vue';
type Props = {
dataStore: DataStoreResource;
@@ -20,6 +20,17 @@ const props = withDefaults(defineProps<Props>(), {
showOwnershipBadge: false,
});
const emit = defineEmits<{
action: [
value: {
dataStore: DataStoreResource;
action: string;
},
];
}>();
const renameInput = useTemplateRef('renameInput');
const dataStoreRoute = computed(() => {
return {
name: DATA_STORE_DETAILS,
@@ -29,6 +40,33 @@ const dataStoreRoute = computed(() => {
},
};
});
const onCardAction = (action: string) => {
// Focus rename input if the action is rename
// We need this timeout to ensure action toggle is closed before focusing
if (action === 'rename') {
if (renameInput.value?.forceFocus) {
setTimeout(() => {
renameInput.value?.forceFocus();
}, 100);
}
return;
}
// Otherwise, emit the action directly
emit('action', {
dataStore: props.dataStore,
action,
});
};
const onNameSubmit = (name: string) => {
if (props.dataStore.name === name) return;
emit('action', {
dataStore: { ...props.dataStore, name },
action: 'rename',
});
};
</script>
<template>
<div data-test-id="data-store-card">
@@ -44,10 +82,18 @@ const dataStoreRoute = computed(() => {
/>
</template>
<template #header>
<div :class="$style['card-header']">
<N8nHeading tag="h2" bold size="small" data-test-id="folder-card-name">
{{ props.dataStore.name }}
</N8nHeading>
<div :class="$style['card-header']" @click.prevent>
<N8nInlineTextEdit
ref="renameInput"
data-test-id="datastore-name-input"
:placeholder="i18n.baseText('dataStore.add.input.name.label')"
:class="$style['card-name']"
:model-value="props.dataStore.name"
:max-length="50"
:read-only="props.readOnly"
:disabled="props.readOnly"
@update:model-value="onNameSubmit"
/>
<N8nBadge v-if="props.readOnly" class="ml-3xs" theme="tertiary" bold>
{{ i18n.baseText('workflows.item.readonly') }}
</N8nBadge>
@@ -55,14 +101,6 @@ const dataStoreRoute = computed(() => {
</template>
<template #footer>
<div :class="$style['card-footer']">
<N8nText
size="small"
color="text-light"
:class="[$style['info-cell'], $style['info-cell--size']]"
data-test-id="folder-card-folder-count"
>
{{ i18n.baseText('dataStore.card.size', { interpolate: { size: dataStore.size } }) }}
</N8nText>
<N8nText
size="small"
color="text-light"
@@ -71,7 +109,7 @@ const dataStoreRoute = computed(() => {
>
{{
i18n.baseText('dataStore.card.row.count', {
interpolate: { count: props.dataStore.recordCount },
interpolate: { count: props.dataStore.recordCount ?? 0 },
})
}}
</N8nText>
@@ -83,7 +121,7 @@ const dataStoreRoute = computed(() => {
>
{{
i18n.baseText('dataStore.card.column.count', {
interpolate: { count: props.dataStore.columnCount },
interpolate: { count: props.dataStore.columns.length },
})
}}
</N8nText>
@@ -113,7 +151,8 @@ const dataStoreRoute = computed(() => {
v-if="props.actions.length"
:actions="props.actions"
theme="dark"
data-test-id="folder-card-actions"
data-test-id="data-store-card-actions"
@action="onCardAction"
/>
</div>
</template>
@@ -132,6 +171,12 @@ const dataStoreRoute = computed(() => {
}
}
.card-name {
color: $custom-font-dark;
font-size: var(--font-size-m);
margin-bottom: var(--spacing-5xs);
}
.card-icon {
flex-shrink: 0;
color: var(--color-text-base);

View File

@@ -2,7 +2,12 @@ import { defineStore } from 'pinia';
import { DATA_STORE_STORE } from '@/features/dataStore/constants';
import { ref } from 'vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import { fetchDataStores, createDataStore } from '@/features/dataStore/datastore.api';
import {
fetchDataStoresApi,
createDataStoreApi,
deleteDataStoreApi,
updateDataStoreApi,
} from '@/features/dataStore/datastore.api';
import type { DataStoreEntity } from '@/features/dataStore/datastore.types';
export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
@@ -11,26 +16,55 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
const dataStores = ref<DataStoreEntity[]>([]);
const totalCount = ref(0);
const loadDataStores = async (projectId: string, page: number, pageSize: number) => {
const response = await fetchDataStores(rootStore.restApiContext, projectId, {
page,
pageSize,
const fetchDataStores = async (projectId: string, page: number, pageSize: number) => {
const response = await fetchDataStoresApi(rootStore.restApiContext, projectId, {
skip: (page - 1) * pageSize,
take: pageSize,
});
console.log('Data stores fetched:', response);
dataStores.value = response.data;
totalCount.value = response.count;
};
const createNewDataStore = async (name: string, projectId?: string) => {
const newStore = await createDataStore(rootStore.restApiContext, name, projectId);
dataStores.value.push(newStore.data);
const createDataStore = async (name: string, projectId?: string) => {
const newStore = await createDataStoreApi(rootStore.restApiContext, name, projectId);
dataStores.value.push(newStore);
totalCount.value += 1;
return newStore;
};
const deleteDataStore = async (datastoreId: string, projectId?: string) => {
const deleted = await deleteDataStoreApi(rootStore.restApiContext, datastoreId, projectId);
if (deleted) {
dataStores.value = dataStores.value.filter((store) => store.id !== datastoreId);
totalCount.value -= 1;
}
return deleted;
};
const updateDataStore = async (datastoreId: string, name: string, projectId?: string) => {
const updated = await updateDataStoreApi(
rootStore.restApiContext,
datastoreId,
name,
projectId,
);
if (updated) {
const index = dataStores.value.findIndex((store) => store.id === datastoreId);
if (index !== -1) {
dataStores.value[index] = { ...dataStores.value[index], name };
}
}
return updated;
};
return {
dataStores,
totalCount,
loadDataStores,
createNewDataStore,
fetchDataStores,
createDataStore,
deleteDataStore,
updateDataStore,
};
});

View File

@@ -1,29 +1,71 @@
import { getFullApiResponse } from '@n8n/rest-api-client';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
import { type DataStoreEntity } from '@/features/dataStore/datastore.types';
export const fetchDataStores = async (
export const fetchDataStoresApi = async (
context: IRestApiContext,
projectId?: string,
options?: {
page?: number;
pageSize?: number;
skip?: number;
take?: number;
},
) => {
return await getFullApiResponse<DataStoreEntity[]>(context, 'GET', '/data-stores', {
projectId,
options,
});
const apiEndpoint = projectId ? `/projects/${projectId}/data-stores` : '/data-stores-global';
return await makeRestApiRequest<{ count: number; data: DataStoreEntity[] }>(
context,
'GET',
apiEndpoint,
{
...options,
},
);
};
export const createDataStore = async (
export const createDataStoreApi = async (
context: IRestApiContext,
name: string,
projectId?: string,
) => {
return await getFullApiResponse<DataStoreEntity>(context, 'POST', '/data-stores', {
name,
projectId,
});
return await makeRestApiRequest<DataStoreEntity>(
context,
'POST',
`/projects/${projectId}/data-stores`,
{
name,
columns: [],
},
);
};
export const deleteDataStoreApi = async (
context: IRestApiContext,
dataStoreId: string,
projectId?: string,
) => {
return await makeRestApiRequest<boolean>(
context,
'DELETE',
`/projects/${projectId}/data-stores/${dataStoreId}`,
{
dataStoreId,
projectId,
},
);
};
export const updateDataStoreApi = async (
context: IRestApiContext,
dataStoreId: string,
name: string,
projectId?: string,
) => {
return await makeRestApiRequest<DataStoreEntity>(
context,
'PATCH',
`/projects/${projectId}/data-stores/${dataStoreId}`,
{
name,
},
);
};

View File

@@ -1,10 +1,20 @@
import type { ProjectSharingData } from 'n8n-workflow';
export type DataStoreEntity = {
id: string;
name: string;
size: number;
sizeBytes: number;
recordCount: number;
columnCount: number;
columns: DataStoreColumnEntity[];
createdAt: string;
updatedAt: string;
projectId?: string;
project?: ProjectSharingData;
};
export type DataStoreColumnEntity = {
id: string;
name: string;
type: string;
index: number;
};