feat(editor): Add data store grid component (no-changelog) (#18392)

This commit is contained in:
Milorad FIlipović
2025-08-18 10:09:56 +02:00
committed by GitHub
parent 20bbc7f80b
commit 55776f5cf4
19 changed files with 1301 additions and 47 deletions

View File

@@ -179,9 +179,11 @@ import IconLucideTags from '~icons/lucide/tags';
import IconLucideTerminal from '~icons/lucide/terminal';
import IconLucideThumbsDown from '~icons/lucide/thumbs-down';
import IconLucideThumbsUp from '~icons/lucide/thumbs-up';
import IconLucideToggleRight from '~icons/lucide/toggle-right';
import IconLucideTrash2 from '~icons/lucide/trash-2';
import IconLucideTreePine from '~icons/lucide/tree-pine';
import IconLucideTriangleAlert from '~icons/lucide/triangle-alert';
import IconLucideType from '~icons/lucide/type';
import IconLucideUndo2 from '~icons/lucide/undo-2';
import IconLucideUnlink from '~icons/lucide/unlink';
import IconLucideUser from '~icons/lucide/user';
@@ -593,6 +595,8 @@ export const updatedIconSet = {
'trash-2': IconLucideTrash2,
'tree-pine': IconLucideTreePine,
'triangle-alert': IconLucideTriangleAlert,
type: IconLucideType,
'toggle-right': IconLucideToggleRight,
'undo-2': IconLucideUndo2,
unlink: IconLucideUnlink,
user: IconLucideUser,

View File

@@ -19,7 +19,8 @@
"activate": "Activate",
"user": "User",
"enabled": "Enabled",
"disabled": "Disabled"
"disabled": "Disabled",
"type": "Type"
},
"_reusableDynamicText": {
"readMore": "Read more",
@@ -2858,6 +2859,13 @@
"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",
"dataStore.addColumn.label": "Add Column",
"dataStore.addColumn.nameInput.label": "@:_reusableBaseText.name",
"dataStore.addColumn.nameInput.placeholder": "Enter column name",
"dataStore.addColumn.typeInput.label": "@:_reusableBaseText.type",
"dataStore.addColumn.error": "Error adding column",
"dataStore.addColumn.invalidName.error": "Invalid column name",
"dataStore.addColumn.invalidName.description": "Only alphanumeric characters and non-leading dashes are allowed for column names",
"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

@@ -57,6 +57,7 @@
"@vue-flow/node-resizer": "^1.4.0",
"@vueuse/components": "^10.11.0",
"@vueuse/core": "catalog:frontend",
"ag-grid-vue3": "^34.1.1",
"array.prototype.tosorted": "1.1.4",
"axios": "catalog:",
"bowser": "2.11.0",

View File

@@ -0,0 +1,185 @@
import { createComponentRenderer } from '@/__tests__/render';
import DataStoreDetailsView from '@/features/dataStore/DataStoreDetailsView.vue';
import { createTestingPinia } from '@pinia/testing';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useToast } from '@/composables/useToast';
import { useRouter } from 'vue-router';
import type { DataStore } from '@/features/dataStore/datastore.types';
import { waitFor } from '@testing-library/vue';
vi.mock('@/composables/useToast');
vi.mock('vue-router');
vi.mock('@/composables/useDocumentTitle', () => ({
useDocumentTitle: vi.fn(() => ({
set: vi.fn(),
})),
}));
vi.mock('@n8n/i18n', () => {
const baseText = (key: string) => {
const translations: Record<string, string> = {
'dataStore.getDetails.error': 'Error fetching data store details',
'dataStore.notFound': 'Data store not found',
'dataStore.dataStores': 'Data Stores',
};
return translations[key] || key;
};
return {
useI18n: () => ({ baseText }),
i18n: { baseText },
i18nInstance: {
global: {
t: baseText,
te: () => true,
},
},
};
});
const mockRouter = {
push: vi.fn(),
};
const mockToast = {
showError: vi.fn(),
};
const DEFAULT_DATA_STORE: DataStore = {
id: 'ds1',
name: 'Test Data Store',
sizeBytes: 2048,
recordCount: 50,
columns: [
{ id: '1', name: 'id', type: 'string', index: 0 },
{ id: '2', name: 'name', type: 'string', index: 1 },
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
projectId: 'proj1',
};
const renderComponent = createComponentRenderer(DataStoreDetailsView, {
props: {
id: 'ds1',
projectId: 'proj1',
},
global: {
stubs: {
DataStoreBreadcrumbs: true,
DataStoreTable: true,
},
},
});
describe('DataStoreDetailsView', () => {
beforeEach(() => {
(useToast as ReturnType<typeof vi.fn>).mockReturnValue(mockToast);
(useRouter as ReturnType<typeof vi.fn>).mockReturnValue(mockRouter);
vi.clearAllMocks();
});
describe('Loading states', () => {
it('should show loading state initially', async () => {
const pinia = createTestingPinia({ stubActions: false });
const dataStoreStore = useDataStoreStore();
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockImplementation(
async () => await new Promise(() => {}),
);
const { getByTestId } = renderComponent({ pinia });
await waitFor(() => {
expect(getByTestId('data-store-details-loading')).toBeInTheDocument();
});
});
it('should hide loading state after successful data fetch', async () => {
const pinia = createTestingPinia({ stubActions: false });
const dataStoreStore = useDataStoreStore();
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockResolvedValue(DEFAULT_DATA_STORE);
const { queryByTestId } = renderComponent({ pinia });
await waitFor(() => {
expect(queryByTestId('data-store-details-loading')).not.toBeInTheDocument();
});
});
it('should hide loading state after error', async () => {
const pinia = createTestingPinia({ stubActions: false });
const dataStoreStore = useDataStoreStore();
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockRejectedValue(new Error('Failed'));
const { queryByTestId } = renderComponent({ pinia });
await waitFor(() => {
expect(mockToast.showError).toHaveBeenCalled();
});
await waitFor(() => {
expect(queryByTestId('data-store-details-loading')).not.toBeInTheDocument();
});
});
});
describe('Data rendering', () => {
it('should render breadcrumbs and table when data is loaded', async () => {
const pinia = createTestingPinia({ stubActions: false });
const dataStoreStore = useDataStoreStore();
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockResolvedValue(DEFAULT_DATA_STORE);
const { container } = renderComponent({ pinia });
await waitFor(() => {
expect(container.querySelector('data-store-breadcrumbs-stub')).toBeInTheDocument();
expect(container.querySelector('data-store-table-stub')).toBeInTheDocument();
});
});
it('should not render content when data store is null', async () => {
const pinia = createTestingPinia({ stubActions: false });
const dataStoreStore = useDataStoreStore();
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockResolvedValue(null);
const { container } = renderComponent({ pinia });
await waitFor(() => {
expect(mockToast.showError).toHaveBeenCalled();
});
expect(container.querySelector('data-store-breadcrumbs-stub')).not.toBeInTheDocument();
expect(container.querySelector('data-store-table-stub')).not.toBeInTheDocument();
});
});
describe('Error handling', () => {
it('should show error and redirect when data store not found', async () => {
const pinia = createTestingPinia({ stubActions: false });
const dataStoreStore = useDataStoreStore();
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockResolvedValue(null);
renderComponent({ pinia });
await waitFor(() => {
expect(mockToast.showError).toHaveBeenCalled();
expect(mockRouter.push).toHaveBeenCalled();
});
});
it('should handle API errors', async () => {
const pinia = createTestingPinia({ stubActions: false });
const dataStoreStore = useDataStoreStore();
const error = new Error('API Error');
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockRejectedValue(error);
renderComponent({ pinia });
await waitFor(() => {
expect(mockToast.showError).toHaveBeenCalledWith(
error,
'Error fetching data store details',
);
expect(mockRouter.push).toHaveBeenCalled();
});
});
});
});

View File

@@ -8,6 +8,7 @@ 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';
import DataStoreTable from './components/dataGrid/DataStoreTable.vue';
type Props = {
id: string;
@@ -50,14 +51,6 @@ const initialize = async () => {
}
};
const onAddColumnClick = () => {
toast.showMessage({
type: 'warning',
message: 'Coming soon',
duration: 3000,
});
};
onMounted(async () => {
documentTitle.set(i18n.baseText('dataStore.dataStores'));
await initialize();
@@ -66,7 +59,7 @@ onMounted(async () => {
<template>
<div :class="$style['data-store-details-view']">
<div v-if="loading" class="loading">
<div v-if="loading" data-test-id="data-store-details-loading">
<n8n-loading
variant="h1"
:loading="true"
@@ -81,15 +74,7 @@ onMounted(async () => {
<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"
/>
<DataStoreTable :data-store="dataStore" />
</div>
</div>
</div>
@@ -108,7 +93,7 @@ onMounted(async () => {
}
.header-loading {
margin-bottom: var(--spacing-xl);
margin-bottom: var(--spacing-2xl);
div {
height: 2em;

View File

@@ -5,7 +5,7 @@ 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 { PROJECT_DATA_STORES } from '@/features/dataStore/constants';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useToast } from '@/composables/useToast';
@@ -51,7 +51,10 @@ const onItemClicked = async (item: PathItem) => {
};
const onDelete = async () => {
await router.push({ name: DATA_STORE_VIEW, params: { projectId: props.dataStore.projectId } });
await router.push({
name: PROJECT_DATA_STORES,
params: { projectId: props.dataStore.projectId },
});
};
const onRename = async () => {

View File

@@ -49,7 +49,17 @@ const renderComponent = createComponentRenderer(DataStoreCard, {
global: {
stubs: {
N8nLink: {
template: '<div data-test-id="data-store-card-link"><slot /></div>',
template: '<a :href="href" data-test-id="data-store-card-link"><slot /></a>',
props: ['to'],
computed: {
href() {
// Generate href from the route object
if (this.to && typeof this.to === 'object') {
return `/projects/${this.to.params.projectId}/datastores/${this.to.params.id}`;
}
return '#';
},
},
},
TimeAgo: {
template: '<span>just now</span>',
@@ -98,29 +108,23 @@ describe('DataStoreCard', () => {
const wrapper = renderComponent();
const link = wrapper.getByTestId('data-store-card-link');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
`/projects/${DEFAULT_DATA_STORE.projectId}/datastores/${DEFAULT_DATA_STORE.id}`,
);
});
it('should display record count information', () => {
const { getByTestId } = renderComponent();
const recordCountElement = getByTestId('data-store-card-record-count');
expect(recordCountElement).toBeInTheDocument();
expect(recordCountElement).toHaveTextContent(`${DEFAULT_DATA_STORE.recordCount}`);
});
it('should display column count information', () => {
const { getByTestId } = renderComponent();
const columnCountElement = getByTestId('data-store-card-column-count');
expect(columnCountElement).toBeInTheDocument();
});
it('should display last updated information', () => {
const { getByTestId } = renderComponent();
const lastUpdatedElement = getByTestId('data-store-card-last-updated');
expect(lastUpdatedElement).toBeInTheDocument();
});
it('should display created information', () => {
const { getByTestId } = renderComponent();
const createdElement = getByTestId('data-store-card-created');
expect(createdElement).toBeInTheDocument();
expect(columnCountElement).toHaveTextContent(`${DEFAULT_DATA_STORE.columns.length + 1}`);
});
});

View File

@@ -110,7 +110,7 @@ const onNameSubmit = (name: string) => {
>
{{
i18n.baseText('dataStore.card.column.count', {
interpolate: { count: props.dataStore.columns.length },
interpolate: { count: props.dataStore.columns.length + 1 },
})
}}
</N8nText>

View File

@@ -0,0 +1,305 @@
import { createComponentRenderer } from '@/__tests__/render';
import AddColumnPopover from '@/features/dataStore/components/dataGrid/AddColumnPopover.vue';
import { fireEvent, waitFor } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import { MAX_COLUMN_NAME_LENGTH } from '@/features/dataStore/constants';
vi.mock('@/features/dataStore/composables/useDataStoreTypes', () => ({
useDataStoreTypes: () => ({
getIconForType: (type: string) => {
const iconMap: Record<string, string> = {
string: 'abc',
number: '123',
boolean: 'toggle-off',
date: 'calendar',
};
return iconMap[type] || 'abc';
},
}),
}));
vi.mock('@/composables/useDebounce', () => ({
useDebounce: () => ({
debounce: (fn: Function) => fn,
}),
}));
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({
baseText: (key: string) => {
const translations: Record<string, string> = {
'dataStore.addColumn.label': 'Add column',
'dataStore.addColumn.nameInput.label': 'Column name',
'dataStore.addColumn.nameInput.placeholder': 'Enter column name',
'dataStore.addColumn.typeInput.label': 'Column type',
'dataStore.addColumn.invalidName.error': 'Invalid column name',
'dataStore.addColumn.invalidName.description':
'Column names must start with a letter and contain only letters, numbers, and hyphens',
};
return translations[key] || key;
},
}),
}));
describe('AddColumnPopover', () => {
const renderComponent = createComponentRenderer(AddColumnPopover);
beforeEach(() => {
setActivePinia(createPinia());
});
it('should render the add column button', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('data-store-add-column-trigger-button')).toBeInTheDocument();
});
it('should focus name input when popover opens', async () => {
const { getByTestId, getByPlaceholderText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
await waitFor(() => {
const nameInput = getByPlaceholderText('Enter column name');
expect(nameInput).toHaveFocus();
});
});
it('should emit addColumn event with correct payload', async () => {
const { getByTestId, getByPlaceholderText, emitted } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name');
await fireEvent.update(nameInput, 'newColumn');
const submitButton = getByTestId('data-store-add-column-submit-button');
expect(submitButton).not.toBeDisabled();
await fireEvent.click(submitButton);
expect(emitted().addColumn).toBeTruthy();
expect(emitted().addColumn[0]).toEqual([
{
column: {
name: 'newColumn',
type: 'string',
},
},
]);
});
it('should disable submit button when name is empty', async () => {
const { getByTestId } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
await waitFor(() => {
const submitButton = getByTestId('data-store-add-column-submit-button');
expect(submitButton).toBeDisabled();
});
});
it('should show error for invalid column names', async () => {
const { getByPlaceholderText, getByText, getByTestId } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name');
// Test invalid name starting with hyphen
await fireEvent.update(nameInput, '-invalid');
await fireEvent.blur(nameInput);
await waitFor(() => {
expect(getByText('Invalid column name')).toBeInTheDocument();
const submitButton = getByTestId('data-store-add-column-submit-button');
expect(submitButton).toBeDisabled();
});
});
it('should allow valid column names', async () => {
const { getByTestId, getByPlaceholderText, queryByText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name');
// Test valid names
const validNames = ['column1', 'my-column', 'Column123', 'a1b2c3'];
for (const name of validNames) {
await fireEvent.update(nameInput, name);
await fireEvent.blur(nameInput);
await waitFor(() => {
expect(queryByText('Invalid column name')).not.toBeInTheDocument();
});
}
});
it('should clear error when correcting invalid name', async () => {
const { getByTestId, getByPlaceholderText, getByText, queryByText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name');
// Enter invalid name
await fireEvent.update(nameInput, '-invalid');
await fireEvent.blur(nameInput);
await waitFor(() => {
expect(getByText('Invalid column name')).toBeInTheDocument();
});
// Correct the name
await fireEvent.update(nameInput, 'valid');
await fireEvent.blur(nameInput);
await waitFor(() => {
expect(queryByText('Invalid column name')).not.toBeInTheDocument();
});
});
it('should respect max column name length', async () => {
const { getByTestId, getByPlaceholderText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name') as HTMLInputElement;
expect(nameInput.maxLength).toBe(MAX_COLUMN_NAME_LENGTH);
});
it('should allow selecting different column types', async () => {
const { getByPlaceholderText, getByRole, getByText, getByTestId, emitted } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name');
await fireEvent.update(nameInput, 'numberColumn');
// Click on the select to open dropdown
const selectElement = getByRole('combobox');
await fireEvent.click(selectElement);
// Select 'number' type
const numberOption = getByText('number');
await fireEvent.click(numberOption);
const submitButton = getByTestId('data-store-add-column-submit-button');
await fireEvent.click(submitButton);
expect(emitted().addColumn).toBeTruthy();
expect(emitted().addColumn[0]).toEqual([
{
column: {
name: 'numberColumn',
type: 'number',
},
},
]);
});
it('should reset form after successful submission', async () => {
const { getByPlaceholderText, getByTestId } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name') as HTMLInputElement;
await fireEvent.update(nameInput, 'testColumn');
const submitButton = getByTestId('data-store-add-column-submit-button');
await fireEvent.click(submitButton);
// Click button again to open popover
await fireEvent.click(addButton);
await waitFor(() => {
const resetNameInput = getByPlaceholderText('Enter column name') as HTMLInputElement;
expect(resetNameInput.value).toBe('');
});
});
it('should close popover after successful submission', async () => {
const { getByPlaceholderText, getByTestId, queryByText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name');
await fireEvent.update(nameInput, 'testColumn');
const submitButton = getByTestId('data-store-add-column-submit-button');
await fireEvent.click(submitButton);
await waitFor(() => {
expect(queryByText('Column name')).not.toBeInTheDocument();
});
});
it('should allow submission with Enter key', async () => {
const { getByTestId, getByPlaceholderText, emitted } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name');
await fireEvent.update(nameInput, 'enterColumn');
await fireEvent.keyUp(nameInput, { key: 'Enter' });
expect(emitted().addColumn).toBeTruthy();
expect(emitted().addColumn[0]).toEqual([
{
column: {
name: 'enterColumn',
type: 'string',
},
},
]);
});
it('should display all column type options', async () => {
const { getByTestId, getByRole, getByText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
const selectElement = getByRole('combobox');
await fireEvent.click(selectElement);
await waitFor(() => {
expect(getByText('string')).toBeInTheDocument();
expect(getByText('number')).toBeInTheDocument();
expect(getByText('boolean')).toBeInTheDocument();
expect(getByText('date')).toBeInTheDocument();
});
});
it('should show tooltip with error description', async () => {
const { getByPlaceholderText, getByText, getByTestId } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name');
await fireEvent.update(nameInput, '-invalid');
await fireEvent.blur(nameInput);
await waitFor(() => {
expect(getByText('Invalid column name')).toBeInTheDocument();
// Check for help icon that shows tooltip
const helpIcon = getByTestId('add-column-error-help-icon');
expect(helpIcon).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,208 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue';
import type {
DataStoreColumnCreatePayload,
DataStoreColumnType,
} from '@/features/dataStore/datastore.types';
import { useI18n } from '@n8n/i18n';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
import { COLUMN_NAME_REGEX, MAX_COLUMN_NAME_LENGTH } from '@/features/dataStore/constants';
import Tooltip from '@n8n/design-system/components/N8nTooltip/Tooltip.vue';
import { useDebounce } from '@/composables/useDebounce';
const emit = defineEmits<{
addColumn: [
value: {
column: DataStoreColumnCreatePayload;
},
];
}>();
const i18n = useI18n();
const { getIconForType } = useDataStoreTypes();
const { debounce } = useDebounce();
const nameInputRef = ref<HTMLInputElement | null>(null);
const columnName = ref('');
const columnType = ref<DataStoreColumnType>('string');
const columnTypes: DataStoreColumnType[] = ['string', 'number', 'boolean', 'date'];
const error = ref<string | null>(null);
// Handling popover state manually to prevent it closing when interacting with dropdown
const popoverOpen = ref(false);
const isSelectOpen = ref(false);
const onAddButtonClicked = () => {
if (!columnName.value || !columnType.value) {
return;
}
emit('addColumn', {
column: {
name: columnName.value,
type: columnType.value,
},
});
columnName.value = '';
columnType.value = 'string';
popoverOpen.value = false;
};
const handlePopoverOpenChange = async (open: boolean) => {
// Don't close the popover if the select is open
if (!open && isSelectOpen.value) {
return;
}
popoverOpen.value = open;
// Focus name input when opening popover
if (open) {
await nextTick(() => {
nameInputRef.value?.focus();
});
}
};
const validateName = () => {
if (error.value) {
error.value = null;
}
if (columnName.value && !COLUMN_NAME_REGEX.test(columnName.value)) {
error.value = i18n.baseText('dataStore.addColumn.invalidName.error');
}
};
const onInput = debounce(validateName, { debounceTime: 300 });
</script>
<template>
<N8nTooltip :disabled="popoverOpen" :content="i18n.baseText('dataStore.addColumn.label')">
<div :class="$style.wrapper">
<N8nPopoverReka
id="add-column-popover"
:open="popoverOpen"
:popper-options="{ strategy: 'fixed' }"
:show-arrow="false"
@update:open="handlePopoverOpenChange"
>
<template #trigger>
<N8nIconButton
data-test-id="data-store-add-column-trigger-button"
icon="plus"
type="tertiary"
/>
</template>
<template #content>
<div :class="$style['popover-content']">
<div :class="$style['popover-body']">
<N8nInputLabel
:label="i18n.baseText('dataStore.addColumn.nameInput.label')"
:required="true"
:class="error ? '' : 'mb-s'"
>
<N8nInput
ref="nameInputRef"
v-model="columnName"
:placeholder="i18n.baseText('dataStore.addColumn.nameInput.placeholder')"
:maxlength="MAX_COLUMN_NAME_LENGTH"
@keyup.enter="onAddButtonClicked"
@input="onInput"
/>
<div v-if="error" :class="$style['error-message']">
<n8n-text size="small" color="danger" tag="span">
{{ error }}
</n8n-text>
<Tooltip :content="i18n.baseText('dataStore.addColumn.invalidName.description')">
<N8nIcon
icon="circle-help"
size="small"
:class="$style['error-tooltip']"
color="text-base"
data-test-id="add-column-error-help-icon"
/>
</Tooltip>
</div>
</N8nInputLabel>
<N8nInputLabel
:label="i18n.baseText('dataStore.addColumn.typeInput.label')"
:required="true"
:class="$style['type-label']"
>
<N8nSelect
v-model="columnType"
append-to="#add-column-popover"
@visible-change="isSelectOpen = $event"
>
<N8nOption v-for="type in columnTypes" :key="type" :value="type">
<div :class="$style['option-content']">
<N8nIcon :icon="getIconForType(type)" />
<N8nText>{{ type }}</N8nText>
</div>
</N8nOption>
</N8nSelect>
</N8nInputLabel>
<N8nButton
data-test-id="data-store-add-column-submit-button"
type="primary"
class="mt-m"
size="large"
:disabled="!columnName || !columnType || !!error"
@click="onAddButtonClicked"
>
{{ i18n.baseText('dataStore.addColumn.label') }}
</N8nButton>
</div>
</div>
</template>
</N8nPopoverReka>
</div>
</N8nTooltip>
</template>
<style module lang="scss">
.wrapper {
display: flex;
align-items: center;
background: var(--color-background-base);
padding: var(--spacing-2xs);
border: var(--border-base);
border-left: none;
height: 50px;
}
.popover-content {
display: flex;
flex-direction: column;
width: 300px;
}
.popover-header {
padding: var(--spacing-2xs);
border-bottom: var(--border-base);
}
.popover-body {
padding: var(--spacing-xs);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.option-content {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.error-message {
display: flex;
align-items: center;
gap: var(--spacing-4xs);
color: var(--color-text-danger);
}
.error-tooltip {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,179 @@
import { createComponentRenderer } from '@/__tests__/render';
import DataStoreTable from '@/features/dataStore/components/dataGrid/DataStoreTable.vue';
import { fireEvent, waitFor } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import type { DataStore } from '@/features/dataStore/datastore.types';
// Mock ag-grid-vue3
interface MockComponentInstance {
$emit: (event: string, payload: unknown) => void;
}
vi.mock('ag-grid-vue3', () => ({
AgGridVue: {
name: 'AgGridVue',
template: '<div data-test-id="ag-grid-vue" />',
props: ['rowData', 'columnDefs', 'defaultColDef', 'domLayout', 'animateRows', 'theme'],
emits: ['gridReady'],
mounted(this: MockComponentInstance) {
this.$emit('gridReady', {
api: {
// Mock API methods
},
});
},
},
}));
// Mock ag-grid-community modules
vi.mock('ag-grid-community', () => ({
ModuleRegistry: {
registerModules: vi.fn(),
},
ClientSideRowModelModule: {},
TextEditorModule: {},
LargeTextEditorModule: {},
ColumnAutoSizeModule: {},
CheckboxEditorModule: {},
NumberEditorModule: {},
}));
// Mock the n8n theme
vi.mock('@/features/dataStore/components/dataGrid/n8nTheme', () => ({
n8nTheme: 'n8n-theme',
}));
// Mock AddColumnPopover
vi.mock('@/features/dataStore/components/dataGrid/AddColumnPopover.vue', () => ({
default: {
name: 'AddColumnPopover',
template:
'<div data-test-id="add-column-popover"><button data-test-id="data-store-add-column-button" @click="$emit(\'add-column\', { column: { name: \'newColumn\', type: \'string\' } })">Add Column</button></div>',
props: ['dataStore'],
emits: ['add-column'],
},
}));
// Mock composables
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
}),
}));
vi.mock('@/features/dataStore/composables/useDataStoreTypes', () => ({
useDataStoreTypes: () => ({
mapToAGCellType: (type: string) => {
const typeMap: Record<string, string> = {
string: 'text',
number: 'number',
boolean: 'boolean',
date: 'date',
};
return typeMap[type] || 'text';
},
}),
}));
const mockDataStore: DataStore = {
id: 'test-datastore-1',
name: 'Test DataStore',
projectId: 'project-1',
columns: [
{ id: 'col1', name: 'firstName', type: 'string', index: 1 },
{ id: 'col2', name: 'age', type: 'number', index: 2 },
{ id: 'col3', name: 'isActive', type: 'boolean', index: 3 },
],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
sizeBytes: 0,
recordCount: 0,
};
describe('DataStoreTable', () => {
const renderComponent = createComponentRenderer(DataStoreTable, {
props: {
dataStore: mockDataStore,
},
});
let dataStoreStore: ReturnType<typeof useDataStoreStore>;
beforeEach(() => {
setActivePinia(createPinia());
dataStoreStore = useDataStoreStore();
dataStoreStore.addDataStoreColumn = vi.fn().mockResolvedValue({
id: 'new-col',
name: 'newColumn',
type: 'string',
index: 4,
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Component Initialization', () => {
it('should render the component with AG Grid and AddColumnPopover', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('ag-grid-vue')).toBeInTheDocument();
expect(getByTestId('add-column-popover')).toBeInTheDocument();
});
it('should render pagination controls', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('data-store-content-pagination')).toBeInTheDocument();
});
it('should render add row button', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('data-store-add-row-button')).toBeInTheDocument();
});
});
describe('Add Column Functionality', () => {
it('should handle add column event from AddColumnPopover', async () => {
const { getByTestId } = renderComponent();
const addColumnPopover = getByTestId('add-column-popover');
const addButton = addColumnPopover.querySelector(
'[data-test-id="data-store-add-column-button"]',
);
expect(addButton).toBeInTheDocument();
await fireEvent.click(addButton!);
await waitFor(() => {
expect(dataStoreStore.addDataStoreColumn).toHaveBeenCalledWith(
mockDataStore.id,
mockDataStore.projectId,
{ name: 'newColumn', type: 'string' },
);
});
});
});
describe('Empty Data Store', () => {
it('should show grid for empty data store', () => {
const emptyDataStore: DataStore = {
...mockDataStore,
columns: [],
};
const { getByTestId } = renderComponent({
props: {
dataStore: emptyDataStore,
},
});
expect(getByTestId('ag-grid-vue')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,245 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import type {
DataStore,
DataStoreColumn,
DataStoreColumnCreatePayload,
DataStoreRow,
} from '@/features/dataStore/datastore.types';
import { AgGridVue } from 'ag-grid-vue3';
import {
ModuleRegistry,
ClientSideRowModelModule,
TextEditorModule,
LargeTextEditorModule,
ColumnAutoSizeModule,
CheckboxEditorModule,
NumberEditorModule,
} from 'ag-grid-community';
import type { GridApi, GridReadyEvent, ColDef } from 'ag-grid-community';
import { n8nTheme } from '@/features/dataStore/components/dataGrid/n8nTheme';
import AddColumnPopover from '@/features/dataStore/components/dataGrid/AddColumnPopover.vue';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@/composables/useToast';
import { DEFAULT_ID_COLUMN_NAME } from '@/features/dataStore/constants';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
// Register only the modules we actually use
ModuleRegistry.registerModules([
ClientSideRowModelModule,
TextEditorModule,
LargeTextEditorModule,
ColumnAutoSizeModule,
CheckboxEditorModule,
NumberEditorModule,
]);
type Props = {
dataStore: DataStore;
};
const props = defineProps<Props>();
const i18n = useI18n();
const toast = useToast();
const dataStoreTypes = useDataStoreTypes();
const dataStoreStore = useDataStoreStore();
// AG Grid State
const gridApi = ref<GridApi | null>(null);
const colDefs = ref<ColDef[]>([]);
const rowData = ref<DataStoreRow[]>([]);
// Shared config for all columns
const defaultColumnDef = {
flex: 1,
sortable: false,
filter: false,
};
// Pagination
const currentPage = ref(1);
const pageSize = ref(20);
const pageSizeOptions = ref([10, 20, 50]);
const totalItems = ref(0);
const onGridReady = (params: GridReadyEvent) => {
gridApi.value = params.api;
};
const setCurrentPage = (page: number) => {
currentPage.value = page;
};
const setPageSize = (size: number) => {
pageSize.value = size;
currentPage.value = 1; // Reset to first page on page size change
};
const onAddColumn = async ({ column }: { column: DataStoreColumnCreatePayload }) => {
try {
const newColumn = await dataStoreStore.addDataStoreColumn(
props.dataStore.id,
props.dataStore.projectId,
column,
);
if (!newColumn) {
throw new Error(i18n.baseText('generic.unknownError'));
}
colDefs.value = [...colDefs.value, createColumnDef(newColumn)];
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.addColumn.error'));
}
};
const createColumnDef = (col: DataStoreColumn) => {
const columnDef: ColDef = {
colId: col.id,
field: col.name,
headerName: col.name,
editable: col.name !== DEFAULT_ID_COLUMN_NAME,
cellDataType: dataStoreTypes.mapToAGCellType(col.type),
};
// Enable large text editor for text columns
if (col.type === 'string') {
columnDef.cellEditor = 'agLargeTextCellEditor';
columnDef.cellEditorPopup = true;
}
return columnDef;
};
const initColumnDefinitions = () => {
colDefs.value = [
// Always add the ID column, it's not returned by the back-end but all data stores have it
// We use it as a placeholder for new datastores
createColumnDef({
index: 0,
id: DEFAULT_ID_COLUMN_NAME,
name: DEFAULT_ID_COLUMN_NAME,
type: 'string',
}),
// Append other columns
...props.dataStore.columns.map(createColumnDef),
];
};
const initialize = () => {
initColumnDefinitions();
};
onMounted(() => {
initialize();
});
</script>
<template>
<div :class="$style.wrapper">
<div :class="$style['grid-container']" data-test-id="data-store-grid">
<AgGridVue
style="width: 100%"
:row-data="rowData"
:column-defs="colDefs"
:default-col-def="defaultColumnDef"
:dom-layout="'autoHeight'"
:animate-rows="false"
:theme="n8nTheme"
@grid-ready="onGridReady"
/>
<AddColumnPopover
:data-store="props.dataStore"
:class="$style['add-column-popover']"
@add-column="onAddColumn"
/>
</div>
<div :class="$style.footer">
<n8n-icon-button
icon="plus"
class="mb-xl"
type="secondary"
data-test-id="data-store-add-row-button"
/>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
background
:total="totalItems"
:page-sizes="pageSizeOptions"
layout="total, prev, pager, next, sizes"
data-test-id="data-store-content-pagination"
@update:current-page="setCurrentPage"
@size-change="setPageSize"
/>
</div>
</div>
</template>
<style module lang="scss">
.wrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-m);
width: calc(100% - var(--spacing-m) * 2);
align-items: center;
}
.grid-container {
position: relative;
display: flex;
width: 100%;
// AG Grid style overrides
--ag-foreground-color: var(--color-text-base);
--ag-accent-color: var(--color-primary);
--ag-background-color: var(--color-background-xlight);
--ag-border-color: var(--border-color-base);
--ag-border-radius: var(--border-radius-base);
--ag-wrapper-border-radius: 0;
--ag-font-family: var(--font-family);
--ag-font-size: var(--font-size-xs);
--ag-row-height: calc(var(--ag-grid-size) * 0.8 + 32px);
--ag-header-background-color: var(--color-background-base);
--ag-header-font-size: var(--font-size-xs);
--ag-header-font-weight: var(--font-weight-bold);
--ag-header-foreground-color: var(--color-text-dark);
--ag-cell-horizontal-padding: calc(var(--ag-grid-size) * 0.7);
--ag-header-column-resize-handle-color: var(--border-color-base);
--ag-header-column-resize-handle-height: 100%;
--ag-header-height: calc(var(--ag-grid-size) * 0.8 + 32px);
:global(.ag-header-cell-resize) {
width: var(--spacing-4xs);
}
}
.add-column-popover {
display: flex;
position: absolute;
right: -47px;
}
.footer {
display: flex;
width: 100%;
justify-content: space-between;
margin-bottom: var(--spacing-l);
padding-right: var(--spacing-xl);
:global(.el-pagination__sizes) {
height: 100%;
position: relative;
top: -1px;
input {
height: 100%;
min-height: 28px;
}
:global(.el-input__suffix) {
width: var(--spacing-m);
}
}
}
</style>

View File

@@ -0,0 +1,9 @@
import { iconSetAlpine, themeQuartz } from 'ag-grid-community';
export const n8nTheme = themeQuartz.withPart(iconSetAlpine).withParams({
columnBorder: true,
rowBorder: true,
rowVerticalPaddingScale: 0.8,
sidePanelBorder: true,
wrapperBorder: true,
});

View File

@@ -0,0 +1,34 @@
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
import type { AGGridCellType, DataStoreColumnType } from '@/features/dataStore/datastore.types';
/* eslint-disable id-denylist */
const COLUMN_TYPE_ICONS: Record<DataStoreColumnType, IconName> = {
string: 'type',
number: 'hash',
boolean: 'toggle-right',
date: 'calendar',
} as const;
/* eslint-enable id-denylist */
export const useDataStoreTypes = () => {
const getIconForType = (type: DataStoreColumnType) => COLUMN_TYPE_ICONS[type];
/**
* Maps a DataStoreColumnType to an AGGridCellType.
* For now the only mismatch is our 'string' type,
* which needs to be mapped manually.
* @param colType The DataStoreColumnType to map.
* @returns The corresponding AGGridCellType.
*/
const mapToAGCellType = (colType: DataStoreColumnType): AGGridCellType => {
if (colType === 'string') {
return 'text';
}
return colType;
};
return {
getIconForType,
mapToAGCellType,
};
};

View File

@@ -13,3 +13,9 @@ export const DATA_STORE_CARD_ACTIONS = {
};
export const ADD_DATA_STORE_MODAL_KEY = 'addDataStoreModal';
export const DEFAULT_ID_COLUMN_NAME = 'id';
export const MAX_COLUMN_NAME_LENGTH = 128;
export const COLUMN_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/;

View File

@@ -1,7 +1,11 @@
import { makeRestApiRequest } from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
import { type DataStore } from '@/features/dataStore/datastore.types';
import type {
DataStoreColumnCreatePayload,
DataStore,
DataStoreColumn,
} from '@/features/dataStore/datastore.types';
export const fetchDataStoresApi = async (
context: IRestApiContext,
@@ -32,6 +36,7 @@ export const createDataStoreApi = async (
context: IRestApiContext,
name: string,
projectId?: string,
columns?: DataStoreColumnCreatePayload[],
) => {
return await makeRestApiRequest<DataStore>(
context,
@@ -39,7 +44,7 @@ export const createDataStoreApi = async (
`/projects/${projectId}/data-stores`,
{
name,
columns: [],
columns: columns ?? [],
},
);
};
@@ -75,3 +80,19 @@ export const updateDataStoreApi = async (
},
);
};
export const addDataStoreColumnApi = async (
context: IRestApiContext,
dataStoreId: string,
projectId: string,
column: DataStoreColumnCreatePayload,
) => {
return await makeRestApiRequest<DataStoreColumn>(
context,
'POST',
`/projects/${projectId}/data-stores/${dataStoreId}/columns`,
{
...column,
},
);
};

View File

@@ -7,8 +7,9 @@ import {
createDataStoreApi,
deleteDataStoreApi,
updateDataStoreApi,
addDataStoreColumnApi,
} from '@/features/dataStore/dataStore.api';
import type { DataStore } from '@/features/dataStore/datastore.types';
import type { DataStore, DataStoreColumnCreatePayload } from '@/features/dataStore/datastore.types';
import { useProjectsStore } from '@/stores/projects.store';
export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
@@ -83,6 +84,26 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
return await fetchDataStoreDetails(datastoreId, projectId);
};
const addDataStoreColumn = async (
datastoreId: string,
projectId: string,
column: DataStoreColumnCreatePayload,
) => {
const newColumn = await addDataStoreColumnApi(
rootStore.restApiContext,
datastoreId,
projectId,
column,
);
if (newColumn) {
const index = dataStores.value.findIndex((store) => store.id === datastoreId);
if (index !== -1) {
dataStores.value[index].columns.push(newColumn);
}
}
return newColumn;
};
return {
dataStores,
totalCount,
@@ -92,5 +113,6 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
updateDataStore,
fetchDataStoreDetails,
fetchOrFindDataStore,
addDataStoreColumn,
};
});

View File

@@ -8,13 +8,23 @@ export type DataStore = {
columns: DataStoreColumn[];
createdAt: string;
updatedAt: string;
projectId?: string;
projectId: string;
project?: Project;
};
export type DataStoreColumnType = 'string' | 'number' | 'boolean' | 'date';
export type AGGridCellType = 'text' | 'number' | 'boolean' | 'date' | 'dateString' | 'object';
export type DataStoreColumn = {
id: string;
name: string;
type: string;
type: DataStoreColumnType;
index: number;
};
export type DataStoreColumnCreatePayload = Pick<DataStoreColumn, 'name' | 'type'>;
export type DataStoreValue = string | number | boolean | Date | null;
export type DataStoreRow = Record<string, DataStoreValue>;

37
pnpm-lock.yaml generated
View File

@@ -2523,6 +2523,9 @@ importers:
'@vueuse/core':
specifier: catalog:frontend
version: 10.11.0(vue@3.5.13(typescript@5.9.2))
ag-grid-vue3:
specifier: ^34.1.1
version: 34.1.1(vue@3.5.13(typescript@5.9.2))
array.prototype.tosorted:
specifier: 1.1.4
version: 1.1.4
@@ -8140,6 +8143,17 @@ packages:
resolution: {integrity: sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==}
engines: {node: '>=6.0'}
ag-charts-types@12.1.1:
resolution: {integrity: sha512-VbAOfp1E7+Z/TBufJOIjUYdx70kQRDg49WoluiVKVSB0r0el/uvxARp9xjzx4ByBq9+Xq/V23tJVsRj1MS2A/g==}
ag-grid-community@34.1.1:
resolution: {integrity: sha512-ODVvGoMTkyGvMT8b5lzvum5r93bG6CKdJdNrk6u/aYS7oqZ5rUEXJJHC8n8Zq+o76KhFiXMBQrU39xuhz8i+Tg==}
ag-grid-vue3@34.1.1:
resolution: {integrity: sha512-+O3nDlInu4RZJ82zkqVD/DCfps0OsfO1MUi453zEJ+CbJudhq+l4f9hy+l+OYWH10XOEdr6+HL9j6toONWyS8g==}
peerDependencies:
vue: ^3.5.0
agent-base@5.1.1:
resolution: {integrity: sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g==}
engines: {node: '>= 6.0.0'}
@@ -23079,6 +23093,17 @@ snapshots:
adm-zip@0.5.10: {}
ag-charts-types@12.1.1: {}
ag-grid-community@34.1.1:
dependencies:
ag-charts-types: 12.1.1
ag-grid-vue3@34.1.1(vue@3.5.13(typescript@5.9.2)):
dependencies:
ag-grid-community: 34.1.1
vue: 3.5.13(typescript@5.9.2)
agent-base@5.1.1: {}
agent-base@6.0.2:
@@ -25523,7 +25548,7 @@ snapshots:
eslint-import-resolver-node@0.3.9:
dependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
is-core-module: 2.16.1
resolve: 1.22.10
transitivePeerDependencies:
@@ -25547,7 +25572,7 @@ snapshots:
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.35.0(eslint@9.29.0(jiti@1.21.7))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.3)(eslint@9.29.0(jiti@1.21.7)):
dependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
optionalDependencies:
'@typescript-eslint/parser': 8.35.0(eslint@9.29.0(jiti@1.21.7))(typescript@5.9.2)
eslint: 9.29.0(jiti@1.21.7)
@@ -25586,7 +25611,7 @@ snapshots:
array.prototype.findlastindex: 1.2.6
array.prototype.flat: 1.3.3
array.prototype.flatmap: 1.3.3
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
doctrine: 2.1.0
eslint: 9.29.0(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
@@ -26545,7 +26570,7 @@ snapshots:
array-parallel: 0.1.3
array-series: 0.1.5
cross-spawn: 7.0.6
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@@ -29950,7 +29975,7 @@ snapshots:
pdf-parse@1.1.1:
dependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
node-ensure: 0.0.0
transitivePeerDependencies:
- supports-color
@@ -30938,7 +30963,7 @@ snapshots:
rhea@1.0.24:
dependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color