feat(editor): Collapse button on table view (#16993)

This commit is contained in:
Suguru Inoue
2025-07-07 10:54:59 +02:00
committed by GitHub
parent bd8b7b468c
commit d3330b6bcc
13 changed files with 276 additions and 10 deletions

View File

@@ -44,6 +44,7 @@ import IconLucideChevronDown from '~icons/lucide/chevron-down';
import IconLucideChevronLeft from '~icons/lucide/chevron-left';
import IconLucideChevronRight from '~icons/lucide/chevron-right';
import IconLucideChevronUp from '~icons/lucide/chevron-up';
import IconLucideChevronsDownUp from '~icons/lucide/chevrons-down-up';
import IconLucideChevronsLeft from '~icons/lucide/chevrons-left';
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down';
import IconLucideCircle from '~icons/lucide/circle';
@@ -438,6 +439,8 @@ export const updatedIconSet = {
'chevron-right': IconLucideChevronRight,
'chevron-up': IconLucideChevronUp,
'chevrons-left': IconLucideChevronsLeft,
'chevrons-down-up': IconLucideChevronsDownUp,
'chevrons-up-down': IconLucideChevronsUpDown,
circle: IconLucideCircle,
'circle-alert': IconLucideCircleAlert,
'circle-check': IconLucideCircleCheck,

View File

@@ -683,6 +683,8 @@
"dataMapping.tableView.tableColumnsExceeded": "Some columns are hidden",
"dataMapping.tableView.tableColumnsExceeded.tooltip": "Your data has more than {columnLimit} columns so some are hidden. Switch to {link} to see all data.",
"dataMapping.tableView.tableColumnsExceeded.tooltip.link": "JSON view",
"dataMapping.tableView.columnCollapsing": "Collapse rows",
"dataMapping.tableView.columnCollapsing.tooltip": "Collapse rows (to compare values in this column)",
"dataMapping.schemaView.emptyData": "No fields - node executed, but no items were sent on this branch",
"dataMapping.schemaView.emptySchema": "No fields - item(s) exist, but they're empty",
"dataMapping.schemaView.emptySchemaWithBinary": "Only binary data exists. View it using the 'Binary' tab",

View File

@@ -84,6 +84,7 @@ const showDraggableHintWithDelay = ref(false);
const draggableHintShown = ref(false);
const mappedNode = ref<string | null>(null);
const collapsingColumnName = ref<string | null>(null);
const inputModes = [
{ value: 'mapping', label: i18n.baseText('ndv.input.mapping') },
{ value: 'debugging', label: i18n.baseText('ndv.input.fromAI') },
@@ -365,6 +366,10 @@ function onConnectionHelpClick() {
function activatePane() {
emit('activatePane');
}
function handleChangeCollapsingColumn(columnName: string | null) {
collapsingColumnName.value = columnName;
}
</script>
<template>
@@ -390,6 +395,7 @@ function activatePane() {
pane-type="input"
data-test-id="ndv-input-panel"
:disable-ai-content="true"
:collapsing-table-column-name="collapsingColumnName"
@activate-pane="activatePane"
@item-hover="onItemHover"
@link-run="onLinkRun"
@@ -398,6 +404,7 @@ function activatePane() {
@table-mounted="onTableMounted"
@search="onSearch"
@display-mode-change="emit('displayModeChange', $event)"
@collapsing-table-column-changed="handleChangeCollapsingColumn"
>
<template #header>
<div :class="[$style.titleSection, { [$style.titleSectionV2]: isNDVV2 }]">

View File

@@ -103,6 +103,7 @@ const outputTypes = ref([
{ label: i18n.baseText('ndv.output.outType.logs'), value: OUTPUT_TYPE.LOGS },
]);
const runDataRef = ref<RunDataRef>();
const collapsingColumnName = ref<string | null>(null);
// Computed
@@ -321,6 +322,10 @@ watch(defaultOutputMode, (newValue: OutputType, oldValue: OutputType) => {
const activatePane = () => {
emit('activatePane');
};
function handleChangeCollapsingColumn(columnName: string | null) {
collapsingColumnName.value = columnName;
}
</script>
<template>
@@ -346,6 +351,7 @@ const activatePane = () => {
:callout-message="allToolsWereUnusedNotice"
:display-mode="displayMode"
:disable-ai-content="true"
:collapsing-table-column-name="collapsingColumnName"
data-test-id="ndv-output-panel"
@activate-pane="activatePane"
@run-change="onRunIndexChange"
@@ -355,6 +361,7 @@ const activatePane = () => {
@item-hover="emit('itemHover', $event)"
@search="emit('search', $event)"
@display-mode-change="emit('displayModeChange', $event)"
@collapsing-table-column-changed="handleChangeCollapsingColumn"
>
<template #header>
<div :class="[$style.titleSection, { [$style.titleSectionV2]: isNDVV2 }]">

View File

@@ -152,6 +152,7 @@ type Props = {
tableHeaderBgColor?: 'base' | 'light';
disableHoverHighlight?: boolean;
disableAiContent?: boolean;
collapsingTableColumnName: string | null;
};
const props = withDefaults(defineProps<Props>(), {
@@ -207,6 +208,7 @@ const emit = defineEmits<{
},
];
displayModeChange: [IRunDataDisplayMode];
collapsingTableColumnChanged: [columnName: string | null];
}>();
const connectionType = ref<NodeConnectionType>(NodeConnectionTypes.Main);
@@ -1436,6 +1438,16 @@ defineExpose({ enterEditMode });
/>
</Suspense>
<N8nIconButton
v-if="displayMode === 'table' && collapsingTableColumnName !== null"
:class="$style.resetCollapseButton"
text
icon="chevrons-up-down"
size="xmini"
type="tertiary"
@click="emit('collapsingTableColumnChanged', null)"
/>
<RunDataDisplayModeSelect
v-show="
hasPreviewSchema ||
@@ -1844,9 +1856,11 @@ defineExpose({ enterEditMode });
:header-bg-color="tableHeaderBgColor"
:compact="props.compact"
:disable-hover-highlight="props.disableHoverHighlight"
:collapsing-column-name="collapsingTableColumnName"
@mounted="emit('tableMounted', $event)"
@active-row-changed="onItemHover"
@display-mode-change="onDisplayModeChange"
@collapsing-column-changed="emit('collapsingTableColumnChanged', $event)"
/>
</Suspense>
@@ -2335,6 +2349,10 @@ defineExpose({ enterEditMode });
}
}
.resetCollapseButton {
color: var(--color-foreground-xdark);
}
@container (max-width: 240px) {
/* Hide title when the panel is too narrow */
.compact:hover .title {

View File

@@ -130,6 +130,7 @@ watch(
.ioSearchIcon {
color: var(--color-foreground-xdark);
cursor: pointer;
vertical-align: middle;
}
:global(.el-input__prefix) {

View File

@@ -1,7 +1,8 @@
import { createComponentRenderer } from '@/__tests__/render';
import RunDataTable from '@/components/RunDataTable.vue';
import { createTestingPinia } from '@pinia/testing';
import { cleanup } from '@testing-library/vue';
import { cleanup, fireEvent, waitFor } from '@testing-library/vue';
import { nextTick } from 'vue';
vi.mock('vue-router', () => {
const push = vi.fn();
@@ -107,4 +108,48 @@ describe('RunDataTable.vue', () => {
expect(getAllByText(value)).not.toHaveLength(0);
});
});
it('inserts col elements in DOM to specify column widths when collapsing column name is specified', async () => {
const inputData = { json: { firstName: 'John', lastName: 'Doe' } };
const rendered = renderComponent({
props: {
inputData: [inputData],
collapsingColumnName: null,
},
});
await nextTick();
expect(rendered.container.querySelectorAll('col')).toHaveLength(0);
await rendered.rerender({
inputData: [inputData],
collapsingColumnName: 'firstName',
});
await waitFor(() => expect(rendered.container.querySelectorAll('col')).toHaveLength(3)); // two data columns + one right margin column
await rendered.rerender({
inputData: [inputData],
collapsingColumnName: null,
});
await waitFor(() => expect(rendered.container.querySelectorAll('col')).toHaveLength(0));
});
it('shows the button for column collapsing in the column header', async () => {
const inputData = { json: { firstName: 'John', lastName: 'Doe' } };
const rendered = renderComponent({
props: {
inputData: [inputData],
collapsingColumnName: 'firstName',
},
});
expect(rendered.getAllByLabelText('Collapse rows')).toHaveLength(2);
await fireEvent.click(rendered.getAllByLabelText('Collapse rows')[0]);
await fireEvent.click(rendered.getAllByLabelText('Collapse rows')[1]);
expect(rendered.emitted('collapsingColumnChanged')).toEqual([[null], ['lastName']]);
});
});

View File

@@ -7,7 +7,7 @@ import { getMappedExpression } from '@/utils/mappingUtils';
import { getPairedItemId } from '@/utils/pairedItemUtils';
import { shorten } from '@/utils/typesUtils';
import type { GenericValue, IDataObject, INodeExecutionData } from 'n8n-workflow';
import { computed, onMounted, ref, watch } from 'vue';
import { useTemplateRef, computed, onMounted, ref, watch } from 'vue';
import Draggable from '@/components/Draggable.vue';
import MappingPill from './MappingPill.vue';
import TextWithHighlights from './TextWithHighlights.vue';
@@ -35,6 +35,7 @@ type Props = {
headerBgColor?: 'base' | 'light';
compact?: boolean;
disableHoverHighlight?: boolean;
collapsingColumnName: string | null;
};
const props = withDefaults(defineProps<Props>(), {
@@ -52,9 +53,13 @@ const emit = defineEmits<{
activeRowChanged: [row: number | null];
displayModeChange: [mode: IRunDataDisplayMode];
mounted: [data: { avgRowHeight: number }];
collapsingColumnChanged: [columnName: string | null];
}>();
const externalHooks = useExternalHooks();
const tableRef = useTemplateRef('tableRef');
const activeColumn = ref(-1);
const forceShowGrip = ref(false);
const draggedColumn = ref(false);
@@ -64,6 +69,7 @@ const activeRow = ref<number | null>(null);
const columnLimit = ref(MAX_COLUMNS_LIMIT);
const columnLimitExceeded = ref(false);
const draggableRef = ref<DraggableRef>();
const fixedColumnWidths = ref<number[] | undefined>();
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
@@ -82,6 +88,13 @@ const canDraggableDrop = computed(() => ndvStore.canDraggableDrop);
const draggableStickyPosition = computed(() => ndvStore.draggableStickyPos);
const pairedItemMappings = computed(() => workflowsStore.workflowExecutionPairedItemMappings);
const tableData = computed(() => convertToTable(props.inputData));
const collapsingColumnIndex = computed(() => {
if (!props.collapsingColumnName) {
return -1;
}
return tableData.value.columns.indexOf(props.collapsingColumnName);
});
onMounted(() => {
if (tableData.value?.columns && draggableRef.value) {
@@ -419,6 +432,15 @@ function switchToJsonView() {
emit('displayModeChange', 'json');
}
function handleSetCollapsingColumn(columnIndex: number) {
emit(
'collapsingColumnChanged',
collapsingColumnIndex.value === columnIndex
? null
: (tableData.value.columns[columnIndex] ?? null),
);
}
watch(focusedMappableInput, (curr) => {
setTimeout(
() => {
@@ -427,6 +449,27 @@ watch(focusedMappableInput, (curr) => {
curr ? 300 : 150,
);
});
watch(
[collapsingColumnIndex, tableRef],
([index, table]) => {
if (index === -1) {
fixedColumnWidths.value = undefined;
return;
}
if (table === null) {
return;
}
fixedColumnWidths.value = [...table.querySelectorAll('thead tr th')].map((el) =>
el instanceof HTMLElement
? el.getBoundingClientRect().width // using getBoundingClientRect for decimal accuracy
: 0,
);
},
{ immediate: true, flush: 'post' },
);
</script>
<template>
@@ -437,6 +480,7 @@ watch(focusedMappableInput, (curr) => {
[$style.highlight]: highlight,
[$style.lightHeader]: headerBgColor === 'light',
[$style.compact]: props.compact,
[$style.hasCollapsingColumn]: fixedColumnWidths !== undefined,
},
]"
>
@@ -500,13 +544,20 @@ watch(focusedMappableInput, (curr) => {
</tr>
</tbody>
</table>
<table v-else :class="$style.table">
<table v-else ref="tableRef" :class="$style.table">
<colgroup v-if="fixedColumnWidths">
<col v-for="(width, i) in fixedColumnWidths" :key="i" :width="width" />
</colgroup>
<thead>
<tr>
<th v-if="tableData.metadata.hasExecutionIds" :class="$style.executionLinkRowHeader">
<!-- column for execution link -->
</th>
<th v-for="(column, i) in tableData.columns || []" :key="column">
<th
v-for="(column, i) in tableData.columns || []"
:key="column"
:class="collapsingColumnIndex === i ? $style.isCollapsingColumn : ''"
>
<N8nTooltip placement="bottom-start" :disabled="!mappingEnabled" :show-after="1000">
<template #content>
<div>
@@ -540,7 +591,23 @@ watch(focusedMappableInput, (curr) => {
:content="getValueToRender(column || '')"
:search="search"
/>
<div :class="$style.dragButton">
<N8nTooltip
:content="i18n.baseText('dataMapping.tableView.columnCollapsing.tooltip')"
:disabled="mappingEnabled || collapsingColumnIndex === i"
>
<N8nIconButton
:class="$style.collapseColumnButton"
type="tertiary"
size="xmini"
text
:icon="
collapsingColumnIndex === i ? 'chevrons-up-down' : 'chevrons-down-up'
"
:aria-label="i18n.baseText('dataMapping.tableView.columnCollapsing')"
@click="handleSetCollapsingColumn(i)"
/>
</N8nTooltip>
<div v-if="mappingEnabled" :class="$style.dragButton">
<n8n-icon icon="grip-vertical" />
</div>
</div>
@@ -629,7 +696,10 @@ watch(focusedMappableInput, (curr) => {
:key="index2"
:data-row="index1"
:data-col="index2"
:class="hasJsonInColumn(index2) ? $style.minColWidth : $style.limitColWidth"
:class="[
hasJsonInColumn(index2) ? $style.minColWidth : $style.limitColWidth,
collapsingColumnIndex === index2 ? $style.isCollapsingColumn : '',
]"
@mouseenter="onMouseEnterCell"
@mouseleave="onMouseLeaveCell"
>
@@ -757,6 +827,41 @@ watch(focusedMappableInput, (curr) => {
td:last-child {
border-right: var(--border-base);
}
.hasCollapsingColumn & {
table-layout: fixed;
td:not(.isCollapsingColumn) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
& :global(.n8n-tree) {
height: 1.5em;
overflow: hidden;
}
}
}
}
th.isCollapsingColumn {
border-top-color: var(--color-foreground-xdark);
border-left-color: var(--color-foreground-xdark);
border-right-color: var(--color-foreground-xdark);
}
td.isCollapsingColumn {
border-left-color: var(--color-foreground-xdark);
border-right-color: var(--color-foreground-xdark);
tr:last-child & {
border-bottom-color: var(--color-foreground-xdark);
}
}
td.isCollapsingColumn + td,
th.isCollapsingColumn + th {
border-left-color: var(--color-foreground-xdark);
}
.nodeClass {
@@ -808,7 +913,10 @@ watch(focusedMappableInput, (curr) => {
.dragButton {
opacity: 0;
margin-left: var(--spacing-2xs);
& > svg {
vertical-align: middle;
}
}
.dataKey {
@@ -885,4 +993,18 @@ watch(focusedMappableInput, (curr) => {
.executionLinkRowHeader {
width: var(--spacing-m);
}
.collapseColumnButton {
span {
flex-shrink: 0;
}
visibility: hidden;
margin-block: calc(-2 * var(--spacing-2xs));
.isCollapsingColumn &,
th:hover & {
visibility: visible;
}
}
</style>

View File

@@ -73,6 +73,7 @@ exports[`InputPanel > should render 1`] = `
data-v-2e5cd75c=""
>
<!---->
<!--v-if-->
<div
class="n8n-radio-buttons radioGroup displayModeSelect"
data-test-id="ndv-run-data-display-mode"

View File

@@ -88,6 +88,8 @@ describe('LogDetailsPanel', () => {
isOpen: true,
logEntry: createLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
panels: LOG_DETAILS_PANEL_STATE.BOTH,
collapsingInputTableColumnName: null,
collapsingOutputTableColumnName: null,
});
const header = within(rendered.getByTestId('log-details-header'));
@@ -109,6 +111,8 @@ describe('LogDetailsPanel', () => {
runData: { ...aiNodeRunData, executionStatus: 'running' },
}),
panels: LOG_DETAILS_PANEL_STATE.BOTH,
collapsingInputTableColumnName: null,
collapsingOutputTableColumnName: null,
});
const inputPanel = within(rendered.getByTestId('log-details-input'));
@@ -123,6 +127,8 @@ describe('LogDetailsPanel', () => {
isOpen: true,
logEntry: createLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
panels: LOG_DETAILS_PANEL_STATE.BOTH,
collapsingInputTableColumnName: null,
collapsingOutputTableColumnName: null,
});
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
@@ -138,6 +144,8 @@ describe('LogDetailsPanel', () => {
isOpen: true,
logEntry: createLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
panels: LOG_DETAILS_PANEL_STATE.BOTH,
collapsingInputTableColumnName: null,
collapsingOutputTableColumnName: null,
});
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
@@ -164,6 +172,8 @@ describe('LogDetailsPanel', () => {
execution: { resultData: { runData: { A: [runDataA], B: [runDataB] } } },
}),
panels: LOG_DETAILS_PANEL_STATE.BOTH,
collapsingInputTableColumnName: null,
collapsingOutputTableColumnName: null,
});
expect(

View File

@@ -20,18 +20,30 @@ import { LOG_DETAILS_PANEL_STATE } from '@/features/logs/logs.constants';
const MIN_IO_PANEL_WIDTH = 200;
const { isOpen, logEntry, window, latestInfo, panels } = defineProps<{
const {
isOpen,
logEntry,
window,
latestInfo,
panels,
collapsingInputTableColumnName,
collapsingOutputTableColumnName,
} = defineProps<{
isOpen: boolean;
logEntry: LogEntry;
window?: Window;
latestInfo?: LatestNodeInfo;
panels: LogDetailsPanelState;
collapsingInputTableColumnName: string | null;
collapsingOutputTableColumnName: string | null;
}>();
const emit = defineEmits<{
clickHeader: [];
toggleInputOpen: [] | [boolean];
toggleOutputOpen: [] | [boolean];
collapsingInputTableColumnChanged: [columnName: string | null];
collapsingOutputTableColumnChanged: [columnName: string | null];
}>();
defineSlots<{ actions: {} }>();
@@ -149,6 +161,8 @@ function handleResizeEnd() {
pane-type="input"
:title="locale.baseText('logs.details.header.actions.input')"
:log-entry="logEntry"
:collapsing-table-column-name="collapsingInputTableColumnName"
@collapsing-table-column-changed="emit('collapsingInputTableColumnChanged', $event)"
/>
</N8nResizeWrapper>
<LogsViewRunData
@@ -158,6 +172,8 @@ function handleResizeEnd() {
:class="$style.outputPanel"
:title="locale.baseText('logs.details.header.actions.output')"
:log-entry="logEntry"
:collapsing-table-column-name="collapsingOutputTableColumnName"
@collapsing-table-column-changed="emit('collapsingOutputTableColumnChanged', $event)"
/>
</template>
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { nextTick, computed, useTemplateRef } from 'vue';
import { nextTick, computed, useTemplateRef, ref } from 'vue';
import { N8nResizeWrapper } from '@n8n/design-system';
import { useChatState } from '@/features/logs/composables/useChatState';
import LogsOverviewPanel from '@/features/logs/components/LogsOverviewPanel.vue';
@@ -66,6 +66,9 @@ const { selected, select, selectNext, selectPrev } = useLogsSelection(
toggleExpanded,
);
const inputTableColumnCollapsing = ref<{ nodeName: string; columnName: string }>();
const outputTableColumnCollapsing = ref<{ nodeName: string; columnName: string }>();
const isLogDetailsOpen = computed(() => isOpen.value && selected.value !== undefined);
const isLogDetailsVisuallyOpen = computed(
() => isLogDetailsOpen.value && !isCollapsingDetailsPanel.value,
@@ -79,6 +82,16 @@ const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$p
onToggleOpen,
onToggleSyncSelection: logsStore.toggleLogSelectionSync,
}));
const inputCollapsingColumnName = computed(() =>
inputTableColumnCollapsing.value?.nodeName === selected.value?.node.name
? (inputTableColumnCollapsing.value?.columnName ?? null)
: null,
);
const outputCollapsingColumnName = computed(() =>
outputTableColumnCollapsing.value?.nodeName === selected.value?.node.name
? (outputTableColumnCollapsing.value?.columnName ?? null)
: null,
);
const keyMap = computed<KeyMap>(() => ({
j: selectNext,
@@ -110,6 +123,16 @@ function handleOpenNdv(treeNode: LogEntry) {
ndvStore.setOutputRunIndex(treeNode.runIndex);
});
}
function handleChangeInputTableColumnCollapsing(columnName: string | null) {
inputTableColumnCollapsing.value =
columnName && selected.value ? { nodeName: selected.value.node.name, columnName } : undefined;
}
function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
outputTableColumnCollapsing.value =
columnName && selected.value ? { nodeName: selected.value.node.name, columnName } : undefined;
}
</script>
<template>
@@ -202,9 +225,13 @@ function handleOpenNdv(treeNode: LogEntry) {
:window="pipWindow"
:latest-info="latestNodeNameById[selected.node.id]"
:panels="logsStore.detailsState"
:collapsing-input-table-column-name="inputCollapsingColumnName"
:collapsing-output-table-column-name="outputCollapsingColumnName"
@click-header="onToggleOpen(true)"
@toggle-input-open="logsStore.toggleInputOpen"
@toggle-output-open="logsStore.toggleOutputOpen"
@collapsing-input-table-column-changed="handleChangeInputTableColumnCollapsing"
@collapsing-output-table-column-changed="handleChangeOutputTableColumnCollapsing"
>
<template #actions>
<LogsPanelActions v-if="isLogDetailsVisuallyOpen" v-bind="logsPanelActionsProps" />

View File

@@ -11,10 +11,15 @@ import { I18nT } from 'vue-i18n';
import { PiPWindowSymbol } from '@/constants';
import { isSubNodeLog } from '../logs.utils';
const { title, logEntry, paneType } = defineProps<{
const { title, logEntry, paneType, collapsingTableColumnName } = defineProps<{
title: string;
paneType: NodePanelType;
logEntry: LogEntry;
collapsingTableColumnName: string | null;
}>();
const emit = defineEmits<{
collapsingTableColumnChanged: [columnName: string | null];
}>();
const locale = useI18n();
@@ -85,7 +90,9 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) {
:disable-ai-content="!isSubNodeLog(logEntry)"
:is-executing="isExecuting"
table-header-bg-color="light"
:collapsing-table-column-name="collapsingTableColumnName"
@display-mode-change="handleChangeDisplayMode"
@collapsing-table-column-changed="emit('collapsingTableColumnChanged', $event)"
>
<template #header>
<N8nText :class="$style.title" :bold="true" color="text-light" size="small">