feat(editor): Add initial data-store details page (no-changelog) (#18211)

This commit is contained in:
Milorad FIlipović
2025-08-12 11:16:37 +02:00
committed by GitHub
parent 14787fd5a4
commit ecc4f41a11
16 changed files with 926 additions and 128 deletions

View File

@@ -2829,7 +2829,7 @@
"contextual.users.settings.unavailable.button.cloud": "Upgrade now",
"contextual.feature.unavailable.title": "Available on the Enterprise Plan",
"contextual.feature.unavailable.title.cloud": "Available on the Pro Plan",
"dataStore.tab.label": "Data Store",
"dataStore.dataStores": "Data Stores",
"dataStore.empty.label": "You don't have any data stores yet",
"dataStore.empty.description": "Once you create data stores for your projects, they will appear here",
"dataStore.empty.button.label": "Create data store in \"{projectName}\"",
@@ -2852,6 +2852,11 @@
"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",
"dataStore.getDetails.error": "Error fetching data store details",
"dataStore.notFound": "Data store not found",
"dataStore.noColumns.heading": "No columns yet",
"dataStore.noColumns.description": "Add columns to start storing data in this data store.",
"dataStore.noColumns.button.label": "Add first column",
"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

@@ -0,0 +1,124 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import type { DataStoreEntity } from '@/features/dataStore/datastore.types';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useToast } from '@/composables/useToast';
import { useI18n } from '@n8n/i18n';
import { useRouter } from 'vue-router';
import { DATA_STORE_VIEW } from '@/features/dataStore/constants';
import DataStoreBreadcrumbs from '@/features/dataStore/components/DataStoreBreadcrumbs.vue';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
type Props = {
id: string;
projectId: string;
};
const props = defineProps<Props>();
const toast = useToast();
const i18n = useI18n();
const router = useRouter();
const documentTitle = useDocumentTitle();
const dataStoreStore = useDataStoreStore();
const loading = ref(false);
const dataStore = ref<DataStoreEntity | null>(null);
const showErrorAndGoBackToList = async (error: unknown) => {
if (!(error instanceof Error)) {
error = new Error(String(i18n.baseText('dataStore.getDetails.error')));
}
toast.showError(error, i18n.baseText('dataStore.getDetails.error'));
await router.push({ name: DATA_STORE_VIEW, params: { projectId: props.projectId } });
};
const initialize = async () => {
loading.value = true;
try {
const response = await dataStoreStore.fetchOrFindDataStore(props.id, props.projectId);
if (response) {
dataStore.value = response;
} else {
await showErrorAndGoBackToList(new Error(i18n.baseText('dataStore.notFound')));
}
} catch (error) {
await showErrorAndGoBackToList(error);
} finally {
loading.value = false;
}
};
const onAddColumnClick = () => {
toast.showMessage({
type: 'warning',
message: 'Coming soon',
duration: 3000,
});
};
onMounted(async () => {
documentTitle.set(i18n.baseText('dataStore.dataStores'));
await initialize();
});
</script>
<template>
<div :class="$style['data-store-details-view']">
<div v-if="loading" class="loading">
<n8n-loading
variant="h1"
:loading="true"
:rows="1"
:shrink-last="false"
:class="$style['header-loading']"
/>
<n8n-loading :loading="true" variant="h1" :rows="10" :shrink-last="false" />
</div>
<div v-else-if="dataStore">
<div :class="$style.header">
<DataStoreBreadcrumbs :data-store="dataStore" />
</div>
<div :class="$style.content">
<n8n-action-box
v-if="dataStore.columns.length === 0"
data-test-id="empty-shared-action-box"
:heading="i18n.baseText('dataStore.noColumns.heading')"
:description="i18n.baseText('dataStore.noColumns.description')"
:button-text="i18n.baseText('dataStore.noColumns.button.label')"
button-type="secondary"
@click:button="onAddColumnClick"
/>
</div>
</div>
</div>
</template>
<style lang="scss" module>
.data-store-details-view {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
max-width: var(--content-container-width);
box-sizing: border-box;
align-content: start;
padding: var(--spacing-l) var(--spacing-2xl) 0;
}
.header-loading {
margin-bottom: var(--spacing-xl);
div {
height: 2em;
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-xl);
}
</style>

View File

@@ -23,7 +23,7 @@ vi.mock('@n8n/i18n', async (importOriginal) => {
...actualObj,
useI18n: vi.fn(() => ({
baseText: vi.fn((key: string) => {
if (key === 'dataStore.tab.label') return 'Data Store';
if (key === 'dataStore.dataStores') return 'Data Stores';
if (key === 'projects.menu.personal') return 'Personal';
if (key === 'dataStore.empty.label') return 'No data stores';
if (key === 'dataStore.empty.description') return 'No data stores description';
@@ -142,7 +142,7 @@ describe('DataStoreView', () => {
renderComponent({ pinia });
await waitAllPromises();
expect(mockDocumentTitle.set).toHaveBeenCalledWith('Data Store');
expect(mockDocumentTitle.set).toHaveBeenCalledWith('Data Stores');
});
it('should handle initialization error', async () => {

View File

@@ -10,14 +10,12 @@ import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { ProjectTypes } from '@/types/projects.types';
import { useProjectsStore } from '@/stores/projects.store';
import type { SortingAndPaginationUpdates, UserAction } from '@/Interface';
import type { IUser } from '@n8n/rest-api-client/api/users';
import type { SortingAndPaginationUpdates } from '@/Interface';
import type { DataStoreResource } from '@/features/dataStore/types';
import DataStoreCard from '@/features/dataStore/components/DataStoreCard.vue';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import {
ADD_DATA_STORE_MODAL_KEY,
DATA_STORE_CARD_ACTIONS,
DEFAULT_DATA_STORE_PAGE_SIZE,
} from '@/features/dataStore/constants';
import { useDebounce } from '@/composables/useDebounce';
@@ -25,8 +23,6 @@ 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();
@@ -34,7 +30,6 @@ const projectPages = useProjectPages();
const { callDebounced } = useDebounce();
const documentTitle = useDocumentTitle();
const toast = useToast();
const message = useMessage();
const dataStoreStore = useDataStoreStore();
const insightsStore = useInsightsStore();
@@ -89,19 +84,6 @@ const emptyCalloutButtonText = computed(() => {
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
const cardActions = computed<Array<UserAction<IUser>>>(() => [
{
label: i18n.baseText('generic.rename'),
value: DATA_STORE_CARD_ACTIONS.RENAME,
disabled: readOnlyEnv.value,
},
{
label: i18n.baseText('generic.delete'),
value: DATA_STORE_CARD_ACTIONS.DELETE,
disabled: readOnlyEnv.value,
},
]);
const initialize = async () => {
loading.value = true;
const projectId = Array.isArray(route.params.projectId)
@@ -132,66 +114,32 @@ 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);
}
};
const onCardRename = async (payload: { dataStore: DataStoreResource }) => {
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'));
}
};
onMounted(() => {
documentTitle.set(i18n.baseText('dataStore.tab.label'));
documentTitle.set(i18n.baseText('dataStore.dataStores'));
});
</script>
<template>
@@ -241,9 +189,8 @@ onMounted(() => {
class="mb-2xs"
:data-store="data as DataStoreResource"
:show-ownership-badge="projectPages.isOverviewSubPage"
:actions="cardActions"
:read-only="readOnlyEnv"
@action="onCardAction"
@rename="onCardRename"
/>
</template>
</ResourcesListLayout>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import { onMounted, ref } from 'vue';
import { useDataStoreStore } from '../dataStore.store';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useUIStore } from '@/stores/ui.store';
import { useToast } from '@/composables/useToast';
import { useRoute } from 'vue-router';

View File

@@ -0,0 +1,239 @@
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import DataStoreActions from '@/features/dataStore/components/DataStoreActions.vue';
import { DATA_STORE_CARD_ACTIONS } from '@/features/dataStore/constants';
import { MODAL_CONFIRM } from '@/constants';
import type { DataStoreEntity } from '@/features/dataStore/datastore.types';
const mockMessage = {
confirm: vi.fn(),
};
const mockToast = {
showError: vi.fn(),
};
const mockDeleteDataStore = vi.fn();
vi.mock('@/composables/useMessage', () => ({
useMessage: () => mockMessage,
}));
vi.mock('@/composables/useToast', () => ({
useToast: () => mockToast,
}));
vi.mock('@/features/dataStore/dataStore.store', () => ({
useDataStoreStore: () => ({
deleteDataStore: mockDeleteDataStore,
}),
}));
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({
baseText: (key: string, options?: { interpolate?: { name?: string } }) => {
if (key === 'generic.rename') return 'Rename';
if (key === 'generic.delete') return 'Delete';
if (key === 'generic.cancel') return 'Cancel';
if (key === 'generic.unknownError') return 'Something went wrong';
if (key === 'dataStore.delete.confirm.message')
return `Are you sure that you want to delete "${options?.interpolate?.name}"?`;
if (key === 'dataStore.delete.confirm.title') return 'Delete Data Store';
if (key === 'dataStore.delete.error')
return 'Something went wrong while deleting the data store.';
return key;
},
}),
}));
const mockDataStore: DataStoreEntity = {
id: '1',
name: 'Test DataStore',
sizeBytes: 1024,
recordCount: 100,
columns: [],
createdAt: '2023-01-01T00:00:00.000Z',
updatedAt: '2023-01-01T00:00:00.000Z',
projectId: 'project-1',
};
const renderComponent = createComponentRenderer(DataStoreActions, {
props: {
dataStore: mockDataStore,
isReadOnly: false,
},
});
describe('DataStoreActions', () => {
beforeEach(() => {
vi.clearAllMocks();
mockDeleteDataStore.mockResolvedValue(true);
mockMessage.confirm.mockResolvedValue(MODAL_CONFIRM);
});
it('should render N8nActionToggle with correct props', () => {
const { getByTestId } = renderComponent({
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
const actionToggle = getByTestId('data-store-card-actions');
expect(actionToggle).toBeInTheDocument();
});
it('should render actions when read-only', () => {
const { getByTestId } = renderComponent({
props: {
isReadOnly: true,
},
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
const actionToggle = getByTestId('data-store-card-actions');
expect(actionToggle).toBeInTheDocument();
});
it('should emit rename event when rename action is triggered', async () => {
const { getByTestId, emitted } = renderComponent({
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
// Click on the action toggle to open dropdown
await userEvent.click(getByTestId('data-store-card-actions'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
// Click on the rename action
await userEvent.click(getByTestId(`action-${DATA_STORE_CARD_ACTIONS.RENAME}`));
expect(emitted().rename).toBeTruthy();
expect(emitted().rename[0]).toEqual([
{
dataStore: mockDataStore,
action: 'rename',
},
]);
});
it('should show confirmation dialog when delete action is triggered', async () => {
const { getByTestId } = renderComponent({
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
// Click on the action toggle to open dropdown
await userEvent.click(getByTestId('data-store-card-actions'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
// Click on the delete action
await userEvent.click(getByTestId(`action-${DATA_STORE_CARD_ACTIONS.DELETE}`));
expect(mockMessage.confirm).toHaveBeenCalledWith(
'Are you sure that you want to delete "Test DataStore"?',
'Delete Data Store',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
},
);
});
it('should call delete when confirmed and emit onDeleted', async () => {
const { getByTestId, emitted } = renderComponent({
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
// Click on the action toggle to open dropdown
await userEvent.click(getByTestId('data-store-card-actions'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
// Click on the delete action
await userEvent.click(getByTestId(`action-${DATA_STORE_CARD_ACTIONS.DELETE}`));
expect(mockDeleteDataStore).toHaveBeenCalledWith('1', 'project-1');
expect(emitted().onDeleted).toBeTruthy();
});
it('should not delete when confirmation is cancelled', async () => {
mockMessage.confirm.mockResolvedValue('cancel');
const { getByTestId, emitted } = renderComponent({
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
// Click on the action toggle to open dropdown
await userEvent.click(getByTestId('data-store-card-actions'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
// Click on the delete action
await userEvent.click(getByTestId(`action-${DATA_STORE_CARD_ACTIONS.DELETE}`));
expect(mockDeleteDataStore).not.toHaveBeenCalled();
expect(emitted().onDeleted).toBeFalsy();
});
it('should show error when delete fails', async () => {
mockDeleteDataStore.mockResolvedValue(false);
const { getByTestId } = renderComponent({
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
// Click on the action toggle to open dropdown
await userEvent.click(getByTestId('data-store-card-actions'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
// Click on the delete action
await userEvent.click(getByTestId(`action-${DATA_STORE_CARD_ACTIONS.DELETE}`));
expect(mockToast.showError).toHaveBeenCalledWith(
expect.any(Error),
'Something went wrong while deleting the data store.',
);
});
it('should show error when delete throws exception', async () => {
const deleteError = new Error('Delete failed');
mockDeleteDataStore.mockRejectedValue(deleteError);
const { getByTestId } = renderComponent({
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
// Click on the action toggle to open dropdown
await userEvent.click(getByTestId('data-store-card-actions'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
// Click on the delete action
await userEvent.click(getByTestId(`action-${DATA_STORE_CARD_ACTIONS.DELETE}`));
expect(mockToast.showError).toHaveBeenCalledWith(
deleteError,
'Something went wrong while deleting the data store.',
);
});
});

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import type { DataStoreEntity } from '@/features/dataStore/datastore.types';
import type { IUser, UserAction } from '@n8n/design-system';
import { DATA_STORE_CARD_ACTIONS } from '@/features/dataStore/constants';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import { useMessage } from '@/composables/useMessage';
import { MODAL_CONFIRM } from '@/constants';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useToast } from '@/composables/useToast';
type Props = {
dataStore: DataStoreEntity;
isReadOnly?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
isReadOnly: false,
});
const emit = defineEmits<{
rename: [
value: {
dataStore: DataStoreEntity;
action: string;
},
];
onDeleted: [];
}>();
const dataStoreStore = useDataStoreStore();
const i18n = useI18n();
const message = useMessage();
const toast = useToast();
const actions = computed<Array<UserAction<IUser>>>(() => [
{
label: i18n.baseText('generic.rename'),
value: DATA_STORE_CARD_ACTIONS.RENAME,
disabled: props.isReadOnly,
},
{
label: i18n.baseText('generic.delete'),
value: DATA_STORE_CARD_ACTIONS.DELETE,
disabled: props.isReadOnly,
},
]);
const onAction = async (action: string) => {
switch (action) {
case DATA_STORE_CARD_ACTIONS.RENAME: {
// This is handled outside of this component
// where editable label component is used
emit('rename', {
dataStore: props.dataStore,
action: 'rename',
});
break;
}
case DATA_STORE_CARD_ACTIONS.DELETE: {
const promptResponse = await message.confirm(
i18n.baseText('dataStore.delete.confirm.message', {
interpolate: { name: props.dataStore.name },
}),
i18n.baseText('dataStore.delete.confirm.title'),
{
confirmButtonText: i18n.baseText('generic.delete'),
cancelButtonText: i18n.baseText('generic.cancel'),
},
);
if (promptResponse === MODAL_CONFIRM) {
await deleteDataStore();
}
break;
}
}
};
const deleteDataStore = async () => {
try {
const deleted = await dataStoreStore.deleteDataStore(
props.dataStore.id,
props.dataStore.projectId,
);
if (!deleted) {
throw new Error(i18n.baseText('generic.unknownError'));
}
emit('onDeleted');
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.delete.error'));
}
};
</script>
<template>
<N8nActionToggle
:actions="actions"
theme="dark"
data-test-id="data-store-card-actions"
@action="onAction"
/>
</template>

View File

@@ -0,0 +1,231 @@
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import DataStoreBreadcrumbs from '@/features/dataStore/components/DataStoreBreadcrumbs.vue';
import type { DataStoreEntity } from '@/features/dataStore/datastore.types';
const mockRouter = {
push: vi.fn(),
};
const mockToast = {
showError: vi.fn(),
};
const mockUpdateDataStore = vi.fn();
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as object),
useRouter: () => mockRouter,
};
});
vi.mock('@/composables/useToast', () => ({
useToast: () => mockToast,
}));
vi.mock('@/features/dataStore/dataStore.store', () => ({
useDataStoreStore: () => ({
updateDataStore: mockUpdateDataStore,
}),
}));
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({
baseText: (key: string) => {
const translations: Record<string, string> = {
'dataStore.dataStores': 'Data Stores',
'dataStore.add.input.name.label': 'Data store name',
'dataStore.rename.error': 'Something went wrong while renaming the data store.',
'generic.unknownError': 'Something went wrong',
};
return translations[key] || key;
},
}),
}));
const mockDataStore: DataStoreEntity = {
id: '1',
name: 'Test DataStore',
sizeBytes: 1024,
recordCount: 100,
columns: [],
createdAt: '2023-01-01T00:00:00.000Z',
updatedAt: '2023-01-01T00:00:00.000Z',
projectId: 'project-1',
project: {
id: 'project-1',
name: 'Test Project',
type: 'personal',
icon: { type: 'icon', value: 'projects' },
createdAt: '2023-01-01T00:00:00.000Z',
updatedAt: '2023-01-01T00:00:00.000Z',
relations: [],
scopes: [],
},
};
const mockDataStoreWithoutProject: DataStoreEntity = {
...mockDataStore,
project: undefined,
};
const renderComponent = createComponentRenderer(DataStoreBreadcrumbs, {
props: {
dataStore: mockDataStore,
},
});
describe('DataStoreBreadcrumbs', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUpdateDataStore.mockResolvedValue(true);
});
describe('Breadcrumbs rendering', () => {
it('should render breadcrumbs with project data', () => {
const { getByText, getAllByText } = renderComponent({
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
expect(getByText('Data Stores')).toBeInTheDocument();
const separators = getAllByText('');
expect(separators.length).toBeGreaterThan(0);
});
it('should render breadcrumbs component when project is null', () => {
const { container } = renderComponent({
props: {
dataStore: mockDataStoreWithoutProject,
},
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
// Should still render the breadcrumbs container even without project
const breadcrumbsContainer = container.querySelector('.data-store-breadcrumbs');
expect(breadcrumbsContainer).toBeInTheDocument();
});
it('should render inline text edit for datastore name', () => {
const { getByTestId } = renderComponent({
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
const nameInput = getByTestId('datastore-header-name-input');
expect(nameInput).toBeInTheDocument();
});
it('should render DataStoreActions component', () => {
const { container } = renderComponent({
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
const actionsComponent = container.querySelector('[data-test-id="data-store-card-actions"]');
expect(actionsComponent).toBeInTheDocument();
});
});
describe('Navigation', () => {
it('should navigate to datastores list when breadcrumb item is clicked', async () => {
const { getByText } = renderComponent({
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
const datastoresLink = getByText('Data Stores');
await userEvent.click(datastoresLink);
expect(mockRouter.push).toHaveBeenCalledWith('/projects/project-1/datastores');
});
it('should render DataStoreActions component that can trigger navigation', () => {
const { container } = renderComponent({
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
// Verify DataStoreActions component is rendered
const actionsComponent = container.querySelector('[data-test-id="data-store-card-actions"]');
expect(actionsComponent).toBeInTheDocument();
});
});
describe('Name editing', () => {
it('should show current datastore name', () => {
const { getByDisplayValue } = renderComponent({
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
expect(getByDisplayValue('Test DataStore')).toBeInTheDocument();
});
});
describe('Component integration', () => {
it('should render component structure correctly', () => {
const { container, getByTestId } = renderComponent({
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
// Check main structure
const breadcrumbsContainer = container.querySelector('.data-store-breadcrumbs');
expect(breadcrumbsContainer).toBeInTheDocument();
// Check name input
const nameInput = getByTestId('datastore-header-name-input');
expect(nameInput).toBeInTheDocument();
// Check actions component
const actionsComponent = container.querySelector('[data-test-id="data-store-card-actions"]');
expect(actionsComponent).toBeInTheDocument();
});
it('should display correct datastore name', () => {
const { getByDisplayValue } = renderComponent({
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
expect(getByDisplayValue('Test DataStore')).toBeInTheDocument();
});
it('should show breadcrumbs separator', () => {
const { getAllByText } = renderComponent({
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
const separators = getAllByText('');
expect(separators.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import type { DataStoreEntity } from '@/features/dataStore/datastore.types';
import { useI18n } from '@n8n/i18n';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { useRouter } from 'vue-router';
import DataStoreActions from '@/features/dataStore/components/DataStoreActions.vue';
import { DATA_STORE_VIEW } from '@/features/dataStore/constants';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useToast } from '@/composables/useToast';
const BREADCRUMBS_SEPARATOR = '';
type Props = {
dataStore: DataStoreEntity;
};
const props = defineProps<Props>();
const renameInput = useTemplateRef('renameInput');
const dataStoreStore = useDataStoreStore();
const i18n = useI18n();
const router = useRouter();
const toast = useToast();
const editableName = ref(props.dataStore.name);
const project = computed(() => {
return props.dataStore.project ?? null;
});
const breadcrumbs = computed<PathItem[]>(() => {
if (!project.value) {
return [];
}
return [
{
id: 'datastores',
label: i18n.baseText('dataStore.dataStores'),
href: `/projects/${project.value.id}/datastores`,
},
];
});
const onItemClicked = async (item: PathItem) => {
if (item.href) {
await router.push(item.href);
}
};
const onDelete = async () => {
await router.push({ name: DATA_STORE_VIEW, params: { projectId: props.dataStore.projectId } });
};
const onRename = async () => {
// Focus rename input if the action is rename
// We need this timeout to ensure action toggle is closed before focusing
await nextTick();
if (renameInput.value?.forceFocus) {
renameInput.value.forceFocus();
}
};
const onNameSubmit = async (name: string) => {
try {
const updated = await dataStoreStore.updateDataStore(
props.dataStore.id,
name,
props.dataStore.projectId,
);
if (!updated) {
throw new Error(i18n.baseText('generic.unknownError'));
}
editableName.value = name;
} catch (error) {
// Revert to original name if rename fails
editableName.value = props.dataStore.name;
toast.showError(error, i18n.baseText('dataStore.rename.error'));
}
};
watch(
() => props.dataStore.name,
(newName) => {
editableName.value = newName;
},
);
</script>
<template>
<div :class="$style['data-store-breadcrumbs']">
<n8n-breadcrumbs
:items="breadcrumbs"
:separator="BREADCRUMBS_SEPARATOR"
@item-selected="onItemClicked"
>
<template #prepend>
<ProjectBreadcrumb v-if="project" :current-project="project" />
</template>
<template #append>
<span :class="$style.separator">{{ BREADCRUMBS_SEPARATOR }}</span>
<N8nInlineTextEdit
ref="renameInput"
v-model="editableName"
data-test-id="datastore-header-name-input"
:placeholder="i18n.baseText('dataStore.add.input.name.label')"
:class="$style['breadcrumb-current']"
:max-length="30"
:read-only="false"
:disabled="false"
@update:model-value="onNameSubmit"
/>
</template>
</n8n-breadcrumbs>
<div :class="$style['data-store-actions']">
<DataStoreActions :data-store="props.dataStore" @rename="onRename" @on-deleted="onDelete" />
</div>
</div>
</template>
<style lang="scss" module>
.data-store-breadcrumbs {
display: flex;
align-items: end;
}
.data-store-actions {
position: relative;
}
.separator {
font-size: var(--font-size-xl);
color: var(--color-foreground-base);
padding: var(--spacing-3xs) var(--spacing-4xs) var(--spacing-4xs);
}
.breadcrumb-current {
color: $custom-font-dark;
font-size: var(--font-size-s);
padding: var(--spacing-3xs) var(--spacing-4xs) var(--spacing-4xs);
}
</style>

View File

@@ -1,5 +1,5 @@
import { createComponentRenderer } from '@/__tests__/render';
import DataStoreCard from './DataStoreCard.vue';
import DataStoreCard from '@/features/dataStore/components/DataStoreCard.vue';
import { createPinia, setActivePinia } from 'pinia';
import type { DataStoreResource } from '@/features/dataStore/types';
import type { UserAction } from '@/Interface';
@@ -94,20 +94,6 @@ describe('DataStoreCard', () => {
expect(getByText('Read only')).toBeInTheDocument();
});
it('should not render action dropdown if no actions are provided', () => {
const { queryByTestId } = renderComponent({
props: {
actions: [],
},
});
expect(queryByTestId('data-store-card-actions')).not.toBeInTheDocument();
});
it('should render action dropdown if actions are provided', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('data-store-card-actions')).toBeInTheDocument();
});
it('should render correct route to data store details', () => {
const wrapper = renderComponent();
const link = wrapper.getByTestId('data-store-card-link');

View File

@@ -1,14 +1,12 @@
<script setup lang="ts">
import type { IUser } from '@n8n/rest-api-client/api/users';
import type { UserAction } from '@/Interface';
import type { DataStoreResource } from '@/features/dataStore/types';
import { DATA_STORE_DETAILS } from '../constants';
import { DATA_STORE_DETAILS } from '@/features/dataStore/constants';
import { useI18n } from '@n8n/i18n';
import { computed, useTemplateRef } from 'vue';
import DataStoreActions from '@/features/dataStore/components/DataStoreActions.vue';
type Props = {
dataStore: DataStoreResource;
actions?: Array<UserAction<IUser>>;
readOnly?: boolean;
showOwnershipBadge?: boolean;
};
@@ -22,10 +20,9 @@ const props = withDefaults(defineProps<Props>(), {
});
const emit = defineEmits<{
action: [
rename: [
value: {
dataStore: DataStoreResource;
action: string;
},
];
}>();
@@ -42,30 +39,21 @@ const dataStoreRoute = computed(() => {
};
});
const onCardAction = (action: string) => {
const onRename = () => {
// 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;
if (renameInput.value && typeof renameInput.value.forceFocus === 'function') {
setTimeout(() => {
renameInput.value?.forceFocus?.();
}, 100);
}
// Otherwise, emit the action directly
emit('action', {
dataStore: props.dataStore,
action,
});
};
const onNameSubmit = (name: string) => {
if (props.dataStore.name === name) return;
emit('action', {
emit('rename', {
dataStore: { ...props.dataStore, name },
action: 'rename',
});
};
</script>
@@ -148,12 +136,10 @@ const onNameSubmit = (name: string) => {
</template>
<template #append>
<div :class="$style['card-actions']" @click.prevent>
<N8nActionToggle
v-if="props.actions.length"
:actions="props.actions"
theme="dark"
data-test-id="data-store-card-actions"
@action="onCardAction"
<DataStoreActions
:data-store="props.dataStore"
:is-read-only="props.readOnly"
@rename="onRename"
/>
</div>
</template>

View File

@@ -9,9 +9,11 @@ import {
updateDataStoreApi,
} from '@/features/dataStore/datastore.api';
import type { DataStoreEntity } from '@/features/dataStore/datastore.types';
import { useProjectsStore } from '@/stores/projects.store';
export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
const rootStore = useRootStore();
const projectStore = useProjectsStore();
const dataStores = ref<DataStoreEntity[]>([]);
const totalCount = ref(0);
@@ -21,14 +23,18 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
skip: (page - 1) * pageSize,
take: pageSize,
});
console.log('Data stores fetched:', response);
dataStores.value = response.data;
totalCount.value = response.count;
};
const createDataStore = async (name: string, projectId?: string) => {
const newStore = await createDataStoreApi(rootStore.restApiContext, name, projectId);
if (!newStore.project && projectId) {
const project = await projectStore.fetchProject(projectId);
if (project) {
newStore.project = project;
}
}
dataStores.value.push(newStore);
totalCount.value += 1;
return newStore;
@@ -59,6 +65,24 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
return updated;
};
const fetchDataStoreDetails = async (datastoreId: string, projectId: string) => {
const response = await fetchDataStoresApi(rootStore.restApiContext, projectId, undefined, {
id: datastoreId,
});
if (response.data.length > 0) {
return response.data[0];
}
return null;
};
const fetchOrFindDataStore = async (datastoreId: string, projectId: string) => {
const existingStore = dataStores.value.find((store) => store.id === datastoreId);
if (existingStore) {
return existingStore;
}
return await fetchDataStoreDetails(datastoreId, projectId);
};
return {
dataStores,
totalCount,
@@ -66,5 +90,7 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
createDataStore,
deleteDataStore,
updateDataStore,
fetchDataStoreDetails,
fetchOrFindDataStore,
};
});

View File

@@ -10,6 +10,11 @@ export const fetchDataStoresApi = async (
skip?: number;
take?: number;
},
filter?: {
id?: string | string[];
name?: string | string[];
projectId?: string | string[];
},
) => {
const apiEndpoint = projectId ? `/projects/${projectId}/data-stores` : '/data-stores-global';
return await makeRestApiRequest<{ count: number; data: DataStoreEntity[] }>(
@@ -18,6 +23,7 @@ export const fetchDataStoresApi = async (
apiEndpoint,
{
...options,
...(filter ?? {}),
},
);
};

View File

@@ -1,4 +1,4 @@
import type { ProjectSharingData } from 'n8n-workflow';
import type { Project } from '@/types/projects.types';
export type DataStoreEntity = {
id: string;
@@ -9,7 +9,7 @@ export type DataStoreEntity = {
createdAt: string;
updatedAt: string;
projectId?: string;
project?: ProjectSharingData;
project?: Project;
};
export type DataStoreColumnEntity = {

View File

@@ -11,6 +11,8 @@ const i18n = useI18n();
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
const DataStoreView = async () => await import('@/features/dataStore/DataStoreView.vue');
const DataStoreDetailsView = async () =>
await import('@/features/dataStore/DataStoreDetailsView.vue');
export const DataStoreModule: FrontendModuleDescription = {
id: 'data-store',
@@ -54,7 +56,7 @@ export const DataStoreModule: FrontendModuleDescription = {
path: 'datastores/:id',
props: true,
components: {
default: DataStoreView,
default: DataStoreDetailsView,
sidebar: MainSidebar,
},
meta: {
@@ -66,7 +68,7 @@ export const DataStoreModule: FrontendModuleDescription = {
projectTabs: {
overview: [
{
label: i18n.baseText('dataStore.tab.label'),
label: i18n.baseText('dataStore.dataStores'),
value: DATA_STORE_VIEW,
to: {
name: DATA_STORE_VIEW,
@@ -75,7 +77,7 @@ export const DataStoreModule: FrontendModuleDescription = {
],
project: [
{
label: i18n.baseText('dataStore.tab.label'),
label: i18n.baseText('dataStore.dataStores'),
value: PROJECT_DATA_STORES,
dynamicRoute: {
name: PROJECT_DATA_STORES,

View File

@@ -1,5 +1,5 @@
import type { BaseResource } from '@/Interface';
import type { DataStoreEntity } from './datastore.types';
import type { DataStoreEntity } from '@/features/dataStore/datastore.types';
/**
* Data Store resource type definition