mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Improve Data Table UX based on feedback (no-changelog) (#19312)
This commit is contained in:
committed by
GitHub
parent
52d44c26db
commit
6e6a8f8be8
@@ -495,14 +495,14 @@ function confirmFolderDelete(folderName: string) {
|
||||
function deleteFolderAndMoveContents(folderName: string, destinationName: string) {
|
||||
cy.intercept('DELETE', '/rest/projects/**').as('deleteFolder');
|
||||
getFolderDeleteModal().should('be.visible');
|
||||
getFolderDeleteModal().find('h1').first().contains(`Delete "${folderName}"`);
|
||||
getFolderDeleteModal().find('h1').first().contains(`Delete '${folderName}'`);
|
||||
getTransferContentRadioButton().should('be.visible').click();
|
||||
getMoveToFolderDropdown().click();
|
||||
getMoveToFolderInput().type(destinationName);
|
||||
getMoveToFolderOption(destinationName).click();
|
||||
getDeleteFolderModalConfirmButton().should('be.enabled').click();
|
||||
cy.wait('@deleteFolder');
|
||||
successToast().should('contain.text', `Data transferred to "${destinationName}"`);
|
||||
successToast().should('contain.text', `Data transferred to '${destinationName}'`);
|
||||
}
|
||||
|
||||
function moveFolder(folderName: string, destinationName: string) {
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
"generic.upgradeToEnterprise": "Upgrade to Enterprise",
|
||||
"generic.never": "Never",
|
||||
"generic.list.clearSelection": "Clear selection",
|
||||
"generic.list.selected": "{count} row selected: | {count} rows selected:",
|
||||
"generic.list.selected": "{count} row selected | {count} rows selected",
|
||||
"about.aboutN8n": "About n8n",
|
||||
"about.close": "Close",
|
||||
"about.license": "License",
|
||||
@@ -971,17 +971,17 @@
|
||||
"folders.actions.moveToFolder": "Move",
|
||||
"folders.add": "Add folder",
|
||||
"folders.add.here.message": "Create a new folder here",
|
||||
"folders.add.to.parent.message": "Create folder in \"{parent}\"",
|
||||
"folders.add.to.parent.message": "Create folder in '{parent}'",
|
||||
"folders.add.overview.community.message": "Folders available in your personal space",
|
||||
"folders.add.overview.withProjects.message": "Folders available in projects or your personal space",
|
||||
"folders.add.success.title": "Folder created",
|
||||
"folders.add.success.message": "Created new folder \"{folderName}\"<br><a href=\"{link}\">Open folder</a>",
|
||||
"folders.add.success.message": "Created new folder '{folderName}'<br><a href=\"{link}\">Open folder</a>",
|
||||
"folders.invalidName.empty.message": "Folder name cannot be empty",
|
||||
"folders.invalidName.tooLong.message": "Folder name cannot be longer than {maxLength} characters",
|
||||
"folders.invalidName.invalidCharacters.message": "Folder name cannot contain the following characters: {illegalChars}",
|
||||
"folders.invalidName.starts.with.dot..message": "Folder name cannot start with a dot",
|
||||
"folders.invalidName.only.dots.message": "Folder name cannot contain only dots",
|
||||
"folders.delete.confirm.title": "Delete \"{folderName}\"",
|
||||
"folders.delete.confirm.title": "Delete '{folderName}'",
|
||||
"folders.delete.typeToConfirm": "delete {folderName}",
|
||||
"folders.delete.confirm.message": "Are to sure you want to delete this folder?",
|
||||
"folders.delete.success.message": "Folder deleted",
|
||||
@@ -991,13 +991,13 @@
|
||||
"folder.and.workflow.separator": "and",
|
||||
"folders.delete.action": "Archive all workflows and delete subfolders",
|
||||
"folders.delete.error.message": "Problem while deleting folder",
|
||||
"folders.delete.confirmation.message": "Type \"delete {folderName}\" to confirm",
|
||||
"folders.transfer.confirm.message": "Data transferred to \"{folderName}\"",
|
||||
"folders.transfer.action": "Transfer workflows and subfolders to another folder inside \"{projectName}\"",
|
||||
"folders.delete.confirmation.message": "Type 'delete {folderName}' to confirm",
|
||||
"folders.transfer.confirm.message": "Data transferred to '{folderName}'",
|
||||
"folders.transfer.action": "Transfer workflows and subfolders to another folder inside '{projectName}'",
|
||||
"folders.transfer.action.noProject": "Transfer workflows and subfolders to another folder",
|
||||
"folders.transfer.selectFolder": "Folder to transfer to",
|
||||
"folders.transfer.select.placeholder": "Select folder",
|
||||
"folders.rename.message": "Rename \"{folderName}\"",
|
||||
"folders.rename.message": "Rename '{folderName}'",
|
||||
"folders.rename.error.title": "Problem renaming folder",
|
||||
"folders.rename.success.message": "Folder renamed",
|
||||
"folders.rename.placeholder": "Enter new folder name",
|
||||
@@ -2844,8 +2844,7 @@
|
||||
"contextual.feature.unavailable.title.cloud": "Available on the Pro Plan",
|
||||
"dataStore.dataStores": "Data tables",
|
||||
"dataStore.empty.label": "You don't have any data tables yet",
|
||||
"dataStore.empty.description": "Once you create data tables for your projects, they will appear here",
|
||||
"dataStore.empty.button.label": "Create Data table in \"{projectName}\"",
|
||||
"dataStore.empty.description": "Use data tables to persist execution results, share data between workflows, and track metrics for evaluation.",
|
||||
"dataStore.card.size": "{size}MB",
|
||||
"dataStore.card.column.count": "{count} column | {count} columns",
|
||||
"dataStore.card.row.count": "{count} record | {count} records",
|
||||
@@ -2856,13 +2855,12 @@
|
||||
"dataStore.search.placeholder": "Search",
|
||||
"dataStore.error.fetching": "Error loading data tables",
|
||||
"dataStore.add.title": "Create new Data table",
|
||||
"dataStore.add.description": "Set up a new data table to organize and manage your data.",
|
||||
"dataStore.add.button.label": "Create Data table",
|
||||
"dataStore.add.input.name.label": "Data table name",
|
||||
"dataStore.add.input.name.placeholder": "Enter data table name",
|
||||
"dataStore.add.error": "Error creating data table",
|
||||
"dataStore.delete.confirm.title": "Delete Data table",
|
||||
"dataStore.delete.confirm.message": "Are you sure you want to delete the data table \"{name}\"? This action cannot be undone.",
|
||||
"dataStore.delete.confirm.message": "Are you sure you want to delete the data table '{name}'? This action cannot be undone.",
|
||||
"dataStore.delete.error": "Error deleting data table",
|
||||
"dataStore.rename.error": "Error renaming data table",
|
||||
"dataStore.getDetails.error": "Error fetching data table details",
|
||||
@@ -2875,10 +2873,12 @@
|
||||
"dataStore.addColumn.nameInput.placeholder": "Enter column name",
|
||||
"dataStore.addColumn.typeInput.label": "@:_reusableBaseText.type",
|
||||
"dataStore.addColumn.error": "Error adding column",
|
||||
"dataStore.addColumn.alreadyExistsError": "This column already exists",
|
||||
"dataStore.addColumn.alreadyExistsDescription": "Column name already exisits, choose a different name",
|
||||
"dataStore.moveColumn.error": "Error moving column",
|
||||
"dataStore.deleteColumn.error": "Error deleting column",
|
||||
"dataStore.deleteColumn.confirm.title": "Delete column",
|
||||
"dataStore.deleteColumn.confirm.message": "Are you sure you want to delete the column \"{name}\"? This action cannot be undone.",
|
||||
"dataStore.deleteColumn.confirm.message": "Are you sure you want to delete the column '{name}'? This action cannot be undone.",
|
||||
"dataStore.addColumn.invalidName.error": "Invalid column name",
|
||||
"dataStore.addColumn.invalidName.description": "Column names must begin with a letter and can only include letters, numbers, or underscores",
|
||||
"dataStore.fetchContent.error": "Error fetching data store content",
|
||||
@@ -2887,8 +2887,9 @@
|
||||
"dataStore.updateRow.error": "Error updating row",
|
||||
"dataStore.deleteRows.title": "Delete Rows",
|
||||
"dataStore.deleteRows.confirmation": "Are you sure you want to delete {count} row? | Are you sure you want to delete {count} rows?",
|
||||
"dataStore.deleteRows.success": "Rows deleted successfully",
|
||||
"dataStore.deleteRows.error": "Error deleting rows",
|
||||
"dataStore.error.tableNotInitialized": "Table not initialized",
|
||||
"dataStore.noRows": "No rows",
|
||||
"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>",
|
||||
|
||||
@@ -45,15 +45,17 @@ const handleClearSelection = () => {
|
||||
{{ getSelectedText() }}
|
||||
</span>
|
||||
<N8nButton
|
||||
:label="i18n.baseText('generic.delete')"
|
||||
type="tertiary"
|
||||
data-test-id="delete-selected-button"
|
||||
:label="i18n.baseText('generic.delete')"
|
||||
:class="$style.button"
|
||||
@click="handleDeleteSelected"
|
||||
/>
|
||||
<N8nButton
|
||||
:label="getClearSelectionText()"
|
||||
type="tertiary"
|
||||
data-test-id="clear-selection-button"
|
||||
:label="getClearSelectionText()"
|
||||
:class="$style.button"
|
||||
@click="handleClearSelection"
|
||||
/>
|
||||
</div>
|
||||
@@ -73,9 +75,11 @@ const handleClearSelection = () => {
|
||||
border-radius: var(--border-radius-base);
|
||||
color: var(--execution-selector-text);
|
||||
font-size: var(--font-size-2xs);
|
||||
|
||||
button {
|
||||
margin-left: var(--spacing-2xs);
|
||||
gap: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import type { DataStore, DataStoreColumnCreatePayload } from '@/features/dataStore/datastore.types';
|
||||
import type {
|
||||
AddColumnResponse,
|
||||
DataStore,
|
||||
DataStoreColumnCreatePayload,
|
||||
} from '@/features/dataStore/datastore.types';
|
||||
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
@@ -82,9 +86,12 @@ const onToggleSave = (value: boolean) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onAddColumn = async (column: DataStoreColumnCreatePayload) => {
|
||||
const onAddColumn = async (column: DataStoreColumnCreatePayload): Promise<AddColumnResponse> => {
|
||||
if (!dataStoreTableRef.value) {
|
||||
return false;
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: i18n.baseText('dataStore.error.tableNotInitialized'),
|
||||
};
|
||||
}
|
||||
return await dataStoreTableRef.value.addColumn(column);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useInsightsStore } from '@/features/insights/insights.store';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import type { SortingAndPaginationUpdates } from '@/Interface';
|
||||
import type { DataStoreResource } from '@/features/dataStore/types';
|
||||
@@ -61,23 +60,6 @@ const currentProject = computed(() => {
|
||||
return projectsStore.currentProject;
|
||||
});
|
||||
|
||||
const projectName = computed(() => {
|
||||
if (currentProject.value?.type === ProjectTypes.Personal) {
|
||||
return i18n.baseText('projects.menu.personal');
|
||||
}
|
||||
return currentProject.value?.name ?? '';
|
||||
});
|
||||
|
||||
const emptyCalloutDescription = computed(() => {
|
||||
return projectPages.isOverviewSubPage ? i18n.baseText('dataStore.empty.description') : '';
|
||||
});
|
||||
|
||||
const emptyCalloutButtonText = computed(() => {
|
||||
return i18n.baseText('dataStore.empty.button.label', {
|
||||
interpolate: { projectName: projectName.value },
|
||||
});
|
||||
});
|
||||
|
||||
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
|
||||
|
||||
const initialize = async () => {
|
||||
@@ -160,8 +142,8 @@ watch(
|
||||
<n8n-action-box
|
||||
data-test-id="empty-shared-action-box"
|
||||
:heading="i18n.baseText('dataStore.empty.label')"
|
||||
:description="emptyCalloutDescription"
|
||||
:button-text="emptyCalloutButtonText"
|
||||
:description="i18n.baseText('dataStore.empty.description')"
|
||||
:button-text="i18n.baseText('dataStore.add.button.label')"
|
||||
button-type="secondary"
|
||||
@click:button="onAddModalClick"
|
||||
/>
|
||||
|
||||
@@ -69,11 +69,12 @@ const redirectToDataStores = () => {
|
||||
<template>
|
||||
<Modal :name="props.modalName" :center="true" width="540px" :before-close="redirectToDataStores">
|
||||
<template #header>
|
||||
<div :class="$style.header">
|
||||
<h2>{{ i18n.baseText('dataStore.add.title') }}</h2>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.content">
|
||||
<p>{{ i18n.baseText('dataStore.add.description') }}</p>
|
||||
<n8n-input-label
|
||||
:label="i18n.baseText('dataStore.add.input.name.label')"
|
||||
:required="true"
|
||||
@@ -95,7 +96,7 @@ const redirectToDataStores = () => {
|
||||
<div :class="$style.footer">
|
||||
<n8n-button
|
||||
:disabled="!dataStoreName"
|
||||
:label="i18n.baseText('dataStore.add.button.label')"
|
||||
:label="i18n.baseText('generic.create')"
|
||||
data-test-id="confirm-add-data-store-button"
|
||||
@click="onSubmit"
|
||||
/>
|
||||
@@ -111,10 +112,13 @@ const redirectToDataStores = () => {
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.header {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-l);
|
||||
}
|
||||
|
||||
.footer {
|
||||
|
||||
@@ -137,11 +137,12 @@ watch(
|
||||
<style lang="scss" module>
|
||||
.data-store-breadcrumbs {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.data-store-actions {
|
||||
position: relative;
|
||||
top: var(--spacing-5xs);
|
||||
}
|
||||
|
||||
.separator {
|
||||
|
||||
@@ -43,7 +43,7 @@ const dataStoreRoute = computed(() => {
|
||||
/>
|
||||
</template>
|
||||
<template #header>
|
||||
<div :class="$style['card-header']" @click.prevent>
|
||||
<div :class="$style['card-header']">
|
||||
<n8n-text tag="h2" bold data-test-id="data-store-card-name">
|
||||
{{ props.dataStore.name }}
|
||||
</n8n-text>
|
||||
|
||||
@@ -43,7 +43,7 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
describe('AddColumnButton', () => {
|
||||
const addColumnHandler = vi.fn().mockResolvedValue(true);
|
||||
const addColumnHandler = vi.fn().mockResolvedValue({ success: true });
|
||||
const renderComponent = createComponentRenderer(AddColumnButton, {
|
||||
props: {
|
||||
params: {
|
||||
@@ -228,7 +228,7 @@ describe('AddColumnButton', () => {
|
||||
});
|
||||
|
||||
it('should close popover after successful submission', async () => {
|
||||
const { getByPlaceholderText, getByTestId, queryByText } = renderComponent();
|
||||
const { getByPlaceholderText, getByTestId, queryByTestId } = renderComponent();
|
||||
const addButton = getByTestId('data-store-add-column-trigger-button');
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
@@ -240,13 +240,16 @@ describe('AddColumnButton', () => {
|
||||
await fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('Column name')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('add-column-popover-content')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not close popover if submission fails', async () => {
|
||||
const { getByPlaceholderText, getByTestId } = renderComponent();
|
||||
addColumnHandler.mockResolvedValueOnce(false);
|
||||
addColumnHandler.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Column name already exists',
|
||||
});
|
||||
const addButton = getByTestId('data-store-add-column-trigger-button');
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
@@ -258,7 +261,7 @@ describe('AddColumnButton', () => {
|
||||
await fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('data-store-add-column-submit-button')).toBeInTheDocument();
|
||||
expect(getByTestId('add-column-popover-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import type {
|
||||
AddColumnResponse,
|
||||
DataStoreColumnCreatePayload,
|
||||
DataStoreColumnType,
|
||||
} from '@/features/dataStore/datastore.types';
|
||||
@@ -10,10 +11,15 @@ import { COLUMN_NAME_REGEX, MAX_COLUMN_NAME_LENGTH } from '@/features/dataStore/
|
||||
import Tooltip from '@n8n/design-system/components/N8nTooltip/Tooltip.vue';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
|
||||
type FormError = {
|
||||
message?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
// the params key is needed so that we can pass this directly to ag-grid as column
|
||||
params: {
|
||||
onAddColumn: (column: DataStoreColumnCreatePayload) => Promise<boolean>;
|
||||
onAddColumn: (column: DataStoreColumnCreatePayload) => Promise<AddColumnResponse>;
|
||||
};
|
||||
popoverId?: string;
|
||||
useTextTrigger?: boolean;
|
||||
@@ -30,7 +36,7 @@ const columnType = ref<DataStoreColumnType>('string');
|
||||
|
||||
const columnTypes: DataStoreColumnType[] = ['string', 'number', 'boolean', 'date'];
|
||||
|
||||
const error = ref<string | null>(null);
|
||||
const error = ref<FormError | null>(null);
|
||||
|
||||
// Handling popover state manually to prevent it closing when interacting with dropdown
|
||||
const popoverOpen = ref(false);
|
||||
@@ -43,11 +49,26 @@ const onAddButtonClicked = async () => {
|
||||
if (!columnName.value || !columnType.value || error.value) {
|
||||
return;
|
||||
}
|
||||
const success = await props.params.onAddColumn({
|
||||
const response = await props.params.onAddColumn({
|
||||
name: columnName.value,
|
||||
type: columnType.value,
|
||||
});
|
||||
if (!success) {
|
||||
|
||||
if (!response.success) {
|
||||
let errorMessage = i18n.baseText('dataStore.addColumn.error');
|
||||
let errorDescription = response.errorMessage;
|
||||
// Provide custom error message for conflict (column already exists)
|
||||
if (response.httpStatus === 409) {
|
||||
errorMessage = i18n.baseText('dataStore.addColumn.alreadyExistsError', {
|
||||
interpolate: { name: columnName.value },
|
||||
});
|
||||
errorDescription = i18n.baseText('dataStore.addColumn.alreadyExistsDescription');
|
||||
}
|
||||
error.value = {
|
||||
message: errorMessage,
|
||||
description: errorDescription,
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
columnName.value = '';
|
||||
@@ -74,7 +95,10 @@ const validateName = () => {
|
||||
error.value = null;
|
||||
}
|
||||
if (columnName.value && !COLUMN_NAME_REGEX.test(columnName.value)) {
|
||||
error.value = i18n.baseText('dataStore.addColumn.invalidName.error');
|
||||
error.value = {
|
||||
message: i18n.baseText('dataStore.addColumn.invalidName.error'),
|
||||
description: i18n.baseText('dataStore.addColumn.invalidName.description'),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -107,7 +131,10 @@ const onInput = debounce(validateName, { debounceTime: 100 });
|
||||
</template>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="add-ds-column-header-popover-content">
|
||||
<div
|
||||
class="add-ds-column-header-popover-content"
|
||||
data-test-id="add-column-popover-content"
|
||||
>
|
||||
<div class="popover-body">
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('dataStore.addColumn.nameInput.label')"
|
||||
@@ -123,10 +150,14 @@ const onInput = debounce(validateName, { debounceTime: 100 });
|
||||
@input="onInput"
|
||||
/>
|
||||
<div v-if="error" class="error-message">
|
||||
<n8n-text size="small" color="danger" tag="span">
|
||||
{{ error }}
|
||||
<n8n-text v-if="error.message" size="small" color="danger" tag="span">
|
||||
{{ error.message }}
|
||||
</n8n-text>
|
||||
<Tooltip :content="i18n.baseText('dataStore.addColumn.invalidName.description')">
|
||||
<Tooltip
|
||||
:content="error.description"
|
||||
placement="top"
|
||||
:disabled="!error.description"
|
||||
>
|
||||
<N8nIcon
|
||||
icon="circle-help"
|
||||
size="small"
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useDataStorePagination } from '@/features/dataStore/composables/useData
|
||||
import { useDataStoreGridBase } from '@/features/dataStore/composables/useDataStoreGridBase';
|
||||
import { useDataStoreSelection } from '@/features/dataStore/composables/useDataStoreSelection';
|
||||
import { useDataStoreOperations } from '@/features/dataStore/composables/useDataStoreOperations';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
// Register only the modules we actually use
|
||||
ModuleRegistry.registerModules([
|
||||
@@ -66,6 +67,8 @@ const emit = defineEmits<{
|
||||
|
||||
const gridContainerRef = useTemplateRef<HTMLDivElement>('gridContainerRef');
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const dataStoreGridBase = useDataStoreGridBase({
|
||||
gridContainerRef,
|
||||
onDeleteColumn: onDeleteColumnFunction,
|
||||
@@ -75,7 +78,18 @@ const dataStoreGridBase = useDataStoreGridBase({
|
||||
const rowData = ref<DataStoreRow[]>([]);
|
||||
const hasRecords = computed(() => rowData.value.length > 0);
|
||||
|
||||
const pagination = useDataStorePagination({ onChange: fetchDataStoreRowsFunction });
|
||||
const {
|
||||
currentPage,
|
||||
pageSize,
|
||||
totalItems,
|
||||
pageSizeOptions,
|
||||
ensureItemOnPage,
|
||||
setTotalItems,
|
||||
setCurrentPage,
|
||||
setPageSize,
|
||||
} = useDataStorePagination({
|
||||
onChange: fetchDataStoreRowsFunction,
|
||||
});
|
||||
|
||||
const selection = useDataStoreSelection({
|
||||
gridApi: dataStoreGridBase.gridApi,
|
||||
@@ -92,13 +106,13 @@ const dataStoreOperations = useDataStoreOperations({
|
||||
addGridColumn: dataStoreGridBase.addColumn,
|
||||
moveGridColumn: dataStoreGridBase.moveColumn,
|
||||
gridApi: dataStoreGridBase.gridApi,
|
||||
totalItems: pagination.totalItems,
|
||||
setTotalItems: pagination.setTotalItems,
|
||||
ensureItemOnPage: pagination.ensureItemOnPage,
|
||||
totalItems,
|
||||
setTotalItems,
|
||||
ensureItemOnPage,
|
||||
focusFirstEditableCell: dataStoreGridBase.focusFirstEditableCell,
|
||||
toggleSave: emit.bind(null, 'toggleSave'),
|
||||
currentPage: pagination.currentPage,
|
||||
pageSize: pagination.pageSize,
|
||||
currentPage,
|
||||
pageSize,
|
||||
currentSortBy: dataStoreGridBase.currentSortBy,
|
||||
currentSortOrder: dataStoreGridBase.currentSortOrder,
|
||||
handleClearSelection: selection.handleClearSelection,
|
||||
@@ -128,8 +142,10 @@ const initialize = async (params: GridReadyEvent) => {
|
||||
await dataStoreOperations.fetchDataStoreRows();
|
||||
};
|
||||
|
||||
const customNoRowsOverlay = `<div class="no-rows-overlay ag-overlay-no-rows-center" data-test-id="data-store-no-rows-overlay">${i18n.baseText('dataStore.noRows')}</div>`;
|
||||
|
||||
watch([dataStoreGridBase.currentSortBy, dataStoreGridBase.currentSortOrder], async () => {
|
||||
await pagination.setCurrentPage(1);
|
||||
await setCurrentPage(1);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
@@ -159,6 +175,7 @@ defineExpose({
|
||||
:stop-editing-when-cells-lose-focus="true"
|
||||
:undo-redo-cell-editing="true"
|
||||
:suppress-multi-sort="true"
|
||||
:overlay-no-rows-template="customNoRowsOverlay"
|
||||
@grid-ready="initialize"
|
||||
@cell-value-changed="dataStoreOperations.onCellValueChanged"
|
||||
@column-moved="dataStoreOperations.onColumnMoved"
|
||||
@@ -173,15 +190,15 @@ defineExpose({
|
||||
</div>
|
||||
<div :class="$style.footer">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.currentPage"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
data-test-id="data-store-content-pagination"
|
||||
background
|
||||
:total="pagination.totalItems"
|
||||
:page-sizes="pagination.pageSizeOptions"
|
||||
:total="totalItems"
|
||||
:page-sizes="pageSizeOptions"
|
||||
layout="total, prev, pager, next, sizes"
|
||||
@update:current-page="pagination.setCurrentPage"
|
||||
@size-change="pagination.setPageSize"
|
||||
@update:current-page="setCurrentPage"
|
||||
@size-change="setPageSize"
|
||||
/>
|
||||
</div>
|
||||
<SelectedItemsInfo
|
||||
@@ -263,7 +280,11 @@ defineExpose({
|
||||
|
||||
:global(.id-column) {
|
||||
color: var(--color-text-light);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:global(.system-column),
|
||||
:global(.system-cell) {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
:global(.ag-header-cell[col-id='id']) {
|
||||
@@ -273,6 +294,12 @@ defineExpose({
|
||||
:global(.add-row-cell) {
|
||||
border: none !important;
|
||||
background-color: transparent !important;
|
||||
padding: 0;
|
||||
|
||||
button {
|
||||
position: relative;
|
||||
left: calc(var(--spacing-4xs) * -1);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ag-header-cell[col-id='add-column']) {
|
||||
@@ -298,7 +325,7 @@ defineExpose({
|
||||
padding: 0;
|
||||
|
||||
textarea {
|
||||
padding-top: var(--spacing-xs);
|
||||
padding-top: var(--spacing-2xs);
|
||||
|
||||
&:where(:focus-within, :active) {
|
||||
border: var(--grid-cell-editing-border);
|
||||
@@ -341,6 +368,10 @@ defineExpose({
|
||||
:global(.ag-row[row-id='__n8n_add_row__']) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:global(.ag-row-last) {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
|
||||
@@ -75,6 +75,7 @@ defineExpose({
|
||||
:editable="false"
|
||||
:teleported="false"
|
||||
:placeholder="''"
|
||||
size="small"
|
||||
@change="onChange"
|
||||
@clear="onClear"
|
||||
@keydown="onKeydown"
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
SortDirection,
|
||||
} from 'ag-grid-community';
|
||||
import type {
|
||||
AddColumnResponse,
|
||||
DataStoreColumn,
|
||||
DataStoreColumnCreatePayload,
|
||||
DataStoreRow,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
createStringValueSetter,
|
||||
stringCellEditorParams,
|
||||
dateValueFormatter,
|
||||
numberValueFormatter,
|
||||
} from '@/features/dataStore/utils/columnUtils';
|
||||
|
||||
export const useDataStoreGridBase = ({
|
||||
@@ -48,7 +50,7 @@ export const useDataStoreGridBase = ({
|
||||
gridContainerRef: Ref<HTMLElement | null>;
|
||||
onDeleteColumn: (columnId: string) => void;
|
||||
onAddRowClick: () => void;
|
||||
onAddColumn: (column: DataStoreColumnCreatePayload) => Promise<boolean>;
|
||||
onAddColumn: (column: DataStoreColumnCreatePayload) => Promise<AddColumnResponse>;
|
||||
}) => {
|
||||
const gridApi = ref<GridApi | null>(null);
|
||||
const colDefs = ref<ColDef[]>([]);
|
||||
@@ -98,15 +100,21 @@ export const useDataStoreGridBase = ({
|
||||
const focusFirstEditableCell = (rowId: number) => {
|
||||
const rowNode = initializedGridApi.value.getRowNode(String(rowId));
|
||||
if (rowNode?.rowIndex === null) return;
|
||||
const rowIndex = rowNode!.rowIndex;
|
||||
|
||||
const firstEditableCol = colDefs.value[1];
|
||||
if (!firstEditableCol?.colId) return;
|
||||
const columnId = firstEditableCol.colId;
|
||||
|
||||
initializedGridApi.value.ensureIndexVisible(rowNode!.rowIndex);
|
||||
initializedGridApi.value.setFocusedCell(rowNode!.rowIndex, firstEditableCol.colId);
|
||||
requestAnimationFrame(() => {
|
||||
initializedGridApi.value.ensureIndexVisible(rowIndex);
|
||||
requestAnimationFrame(() => {
|
||||
initializedGridApi.value.setFocusedCell(rowIndex, columnId);
|
||||
initializedGridApi.value.startEditingCell({
|
||||
rowIndex: rowNode!.rowIndex,
|
||||
colKey: firstEditableCol.colId,
|
||||
rowIndex,
|
||||
colKey: columnId,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -140,6 +148,8 @@ export const useDataStoreGridBase = ({
|
||||
component: ElDatePickerCellEditor,
|
||||
});
|
||||
columnDef.valueFormatter = dateValueFormatter;
|
||||
} else if (col.type === 'number') {
|
||||
columnDef.valueFormatter = numberValueFormatter;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -171,6 +181,8 @@ export const useDataStoreGridBase = ({
|
||||
headerComponentParams: {
|
||||
allowMenuActions: false,
|
||||
},
|
||||
cellClass: (params) => (params.data?.id === ADD_ROW_ROW_ID ? 'add-row-cell' : 'system-cell'),
|
||||
headerClass: 'system-column',
|
||||
};
|
||||
return [
|
||||
// Always add the ID column, it's not returned by the back-end but all data stores have it
|
||||
@@ -184,13 +196,14 @@ export const useDataStoreGridBase = ({
|
||||
},
|
||||
{
|
||||
editable: false,
|
||||
sortable: false,
|
||||
sortable: true,
|
||||
suppressMovable: true,
|
||||
headerComponent: null,
|
||||
lockPosition: true,
|
||||
minWidth: DATA_STORE_ID_COLUMN_WIDTH,
|
||||
maxWidth: DATA_STORE_ID_COLUMN_WIDTH,
|
||||
resizable: false,
|
||||
headerClass: 'system-column',
|
||||
cellClass: (params) =>
|
||||
params.data?.id === ADD_ROW_ROW_ID ? 'add-row-cell' : 'id-column',
|
||||
cellRendererSelector: (params: ICellRendererParams) => {
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('useDataStoreOperations', () => {
|
||||
});
|
||||
const { onAddColumn } = useDataStoreOperations(params);
|
||||
const result = await onAddColumn({ name: 'test', type: 'string' });
|
||||
expect(result).toBe(false);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should add column when column is added', async () => {
|
||||
@@ -78,7 +78,7 @@ describe('useDataStoreOperations', () => {
|
||||
const rowData = ref([{ id: 1 }]);
|
||||
const { onAddColumn } = useDataStoreOperations({ ...params, rowData });
|
||||
const result = await onAddColumn({ name: returnedColumn.name, type: returnedColumn.type });
|
||||
expect(result).toBe(true);
|
||||
expect(result.success).toBe(true);
|
||||
expect(params.setGridData).toHaveBeenCalledWith({ rowData: [{ id: 1, test: null }] });
|
||||
expect(params.addGridColumn).toHaveBeenCalledWith(returnedColumn);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMessage } from '@/composables/useMessage';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import type {
|
||||
AddColumnResponse,
|
||||
DataStoreColumn,
|
||||
DataStoreColumnCreatePayload,
|
||||
DataStoreRow,
|
||||
@@ -18,6 +19,7 @@ import type {
|
||||
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
||||
import { MODAL_CONFIRM } from '@/constants';
|
||||
import { isDataStoreValue } from '@/features/dataStore/typeGuards';
|
||||
import { useDataStoreTypes } from './useDataStoreTypes';
|
||||
|
||||
export type UseDataStoreOperationsParams = {
|
||||
colDefs: Ref<ColDef[]>;
|
||||
@@ -74,6 +76,7 @@ export const useDataStoreOperations = ({
|
||||
const dataStoreStore = useDataStoreStore();
|
||||
const contentLoading = ref(false);
|
||||
const telemetry = useTelemetry();
|
||||
const dataStoreTypes = useDataStoreTypes();
|
||||
|
||||
async function onDeleteColumn(columnId: string) {
|
||||
const columnToDelete = colDefs.value.find((col) => col.colId === columnId);
|
||||
@@ -117,7 +120,7 @@ export const useDataStoreOperations = ({
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddColumn(column: DataStoreColumnCreatePayload) {
|
||||
async function onAddColumn(column: DataStoreColumnCreatePayload): Promise<AddColumnResponse> {
|
||||
try {
|
||||
const newColumn = await dataStoreStore.addDataStoreColumn(dataStoreId, projectId, column);
|
||||
addGridColumn(newColumn);
|
||||
@@ -130,10 +133,14 @@ export const useDataStoreOperations = ({
|
||||
column_type: newColumn.type,
|
||||
data_table_id: dataStoreId,
|
||||
});
|
||||
return true;
|
||||
return { success: true, httpStatus: 200 };
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('dataStore.addColumn.error'));
|
||||
return false;
|
||||
const addColumnError = dataStoreTypes.getAddColumnError(error);
|
||||
return {
|
||||
success: false,
|
||||
httpStatus: addColumnError.httpStatus,
|
||||
errorMessage: addColumnError.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,11 +277,6 @@ export const useDataStoreOperations = ({
|
||||
await dataStoreStore.deleteRows(dataStoreId, projectId, idsToDelete);
|
||||
await fetchDataStoreRows();
|
||||
|
||||
toast.showToast({
|
||||
title: i18n.baseText('dataStore.deleteRows.success'),
|
||||
message: '',
|
||||
type: 'success',
|
||||
});
|
||||
telemetry.track('User deleted rows in data table', {
|
||||
data_table_id: dataStoreId,
|
||||
deleted_row_count: idsToDelete.length,
|
||||
@@ -287,17 +289,28 @@ export const useDataStoreOperations = ({
|
||||
};
|
||||
|
||||
const onCellKeyDown = async (params: CellKeyDownEvent<DataStoreRow>) => {
|
||||
if (params.api.getEditingCells().length > 0) {
|
||||
const event = params.event as KeyboardEvent;
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
const isSelectionColumn = params.column.getColId() === 'ag-Grid-SelectionColumn';
|
||||
const isEditing =
|
||||
params.api.getEditingCells().length > 0 ||
|
||||
(target instanceof HTMLInputElement && !isSelectionColumn);
|
||||
if (isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = params.event as KeyboardEvent;
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c') {
|
||||
event.preventDefault();
|
||||
await handleCopyFocusedCell(params);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
handleClearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.key !== 'Delete' && event.key !== 'Backspace') || selectedRowIds.value.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
DataStoreValue,
|
||||
} from '@/features/dataStore/datastore.types';
|
||||
import { isAGGridCellType } from '@/features/dataStore/typeGuards';
|
||||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
/* eslint-disable id-denylist */
|
||||
const COLUMN_TYPE_ICONS: Record<DataStoreColumnType, IconName> = {
|
||||
@@ -17,6 +19,7 @@ const COLUMN_TYPE_ICONS: Record<DataStoreColumnType, IconName> = {
|
||||
|
||||
export const useDataStoreTypes = () => {
|
||||
const getIconForType = (type: DataStoreColumnType) => COLUMN_TYPE_ICONS[type];
|
||||
const i18n = useI18n();
|
||||
|
||||
/**
|
||||
* Maps a DataStoreColumnType to an AGGridCellType.
|
||||
@@ -57,10 +60,33 @@ export const useDataStoreTypes = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getAddColumnError = (error: unknown): { httpStatus: number; message: string } => {
|
||||
const DEFAULT_HTTP_STATUS = 500;
|
||||
const DEFAULT_MESSAGE = i18n.baseText('generic.unknownError');
|
||||
|
||||
if (error instanceof ResponseError) {
|
||||
return {
|
||||
httpStatus: error.httpStatusCode ?? 500,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
httpStatus: DEFAULT_HTTP_STATUS,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
httpStatus: DEFAULT_HTTP_STATUS,
|
||||
message: DEFAULT_MESSAGE,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
getIconForType,
|
||||
mapToAGCellType,
|
||||
mapToDataStoreColumnType,
|
||||
getDefaultValueForType,
|
||||
getAddColumnError,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,10 +8,10 @@ export const DATA_STORE_STORE = 'dataStoreStore';
|
||||
|
||||
export const DEFAULT_DATA_STORE_PAGE_SIZE = 10;
|
||||
|
||||
export const DATA_STORE_ID_COLUMN_WIDTH = 46;
|
||||
export const DATA_STORE_ID_COLUMN_WIDTH = 60;
|
||||
|
||||
export const DATA_STORE_HEADER_HEIGHT = 36;
|
||||
export const DATA_STORE_ROW_HEIGHT = 43;
|
||||
export const DATA_STORE_ROW_HEIGHT = 33;
|
||||
|
||||
export const ADD_ROW_ROW_ID = '__n8n_add_row__';
|
||||
|
||||
@@ -35,3 +35,7 @@ export const NULL_VALUE = 'Null';
|
||||
export const EMPTY_VALUE = 'Empty';
|
||||
|
||||
export const DATA_STORE_MODULE_NAME = 'data-table';
|
||||
|
||||
export const NUMBER_WITH_SPACES_REGEX = /\B(?=(\d{3})+(?!\d))/g;
|
||||
export const NUMBER_THOUSAND_SEPARATOR = ' ';
|
||||
export const NUMBER_DECIMAL_SEPARATOR = '.';
|
||||
|
||||
@@ -27,3 +27,9 @@ export type DataStoreColumnCreatePayload = Pick<DataStoreColumn, 'name' | 'type'
|
||||
export type DataStoreValue = string | number | boolean | Date | null;
|
||||
|
||||
export type DataStoreRow = Record<string, DataStoreValue>;
|
||||
|
||||
export type AddColumnResponse = {
|
||||
success: boolean;
|
||||
httpStatus?: number;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
@@ -8,7 +8,14 @@ import type {
|
||||
} from 'ag-grid-community';
|
||||
import type { Ref } from 'vue';
|
||||
import type { DataStoreColumn, DataStoreRow } from '@/features/dataStore/datastore.types';
|
||||
import { ADD_ROW_ROW_ID, EMPTY_VALUE, NULL_VALUE } from '@/features/dataStore/constants';
|
||||
import {
|
||||
ADD_ROW_ROW_ID,
|
||||
EMPTY_VALUE,
|
||||
NULL_VALUE,
|
||||
NUMBER_DECIMAL_SEPARATOR,
|
||||
NUMBER_THOUSAND_SEPARATOR,
|
||||
NUMBER_WITH_SPACES_REGEX,
|
||||
} from '@/features/dataStore/constants';
|
||||
import NullEmptyCellRenderer from '@/features/dataStore/components/dataGrid/NullEmptyCellRenderer.vue';
|
||||
import { isDataStoreValue } from '@/features/dataStore/typeGuards';
|
||||
|
||||
@@ -93,3 +100,17 @@ export const dateValueFormatter = (
|
||||
if (value === null || value === undefined) return '';
|
||||
return value.toISOString();
|
||||
};
|
||||
|
||||
const numberWithSpaces = (num: number) => {
|
||||
const parts = num.toString().split('.');
|
||||
parts[0] = parts[0].replace(NUMBER_WITH_SPACES_REGEX, NUMBER_THOUSAND_SEPARATOR);
|
||||
return parts.join(NUMBER_DECIMAL_SEPARATOR);
|
||||
};
|
||||
|
||||
export const numberValueFormatter = (
|
||||
params: ValueFormatterParams<DataStoreRow, number | null | undefined>,
|
||||
): string => {
|
||||
const value = params.value;
|
||||
if (value === null || value === undefined) return '';
|
||||
return numberWithSpaces(value);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user