fix(editor): Styling/UX improvements on the new logs view (#14789)

This commit is contained in:
Suguru Inoue
2025-04-23 15:31:12 +02:00
committed by GitHub
parent 6c9c720ae9
commit 454e5c77ad
28 changed files with 472 additions and 228 deletions

View File

@@ -207,6 +207,10 @@ export function useChatState(isReadOnly: boolean, onWindowResize?: () => void):
nodeHelpers.updateNodesExecutionIssues(); nodeHelpers.updateNodesExecutionIssues();
messages.value = []; messages.value = [];
currentSessionId.value = uuid().replace(/-/g, ''); currentSessionId.value = uuid().replace(/-/g, '');
if (logsPanelState.value !== LOGS_PANEL_STATE.CLOSED) {
chatEventBus.emit('focusInput');
}
} }
function displayExecution(executionId: string) { function displayExecution(executionId: string) {

View File

@@ -141,15 +141,17 @@ describe('LogsPanel', () => {
await fireEvent.click(await rendered.findByTestId('logs-overview-header')); await fireEvent.click(await rendered.findByTestId('logs-overview-header'));
await fireEvent.click(await rendered.findByText('AI Agent')); await fireEvent.click(await rendered.findByText('AI Agent'));
const detailsPanel = rendered.getByTestId('log-details');
// Click the toggle button to close the panel // Click the toggle button to close the panel
await fireEvent.click(within(detailsPanel).getByLabelText('Collapse panel')); await fireEvent.click(
within(rendered.getByTestId('log-details')).getByLabelText('Collapse panel'),
);
expect(rendered.queryByTestId('chat-messages-empty')).not.toBeInTheDocument(); expect(rendered.queryByTestId('chat-messages-empty')).not.toBeInTheDocument();
expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument(); expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument();
// Click again to open the panel // Click again to open the panel
await fireEvent.click(within(detailsPanel).getByLabelText('Open panel')); await fireEvent.click(
within(rendered.getByTestId('logs-overview')).getByLabelText('Open panel'),
);
expect(await rendered.findByTestId('chat-messages-empty')).toBeInTheDocument(); expect(await rendered.findByTestId('chat-messages-empty')).toBeInTheDocument();
expect(await rendered.findByTestId('logs-overview-body')).toBeInTheDocument(); expect(await rendered.findByTestId('logs-overview-body')).toBeInTheDocument();
}); });

View File

@@ -77,10 +77,10 @@ const selectedLogEntry = computed(() =>
? undefined ? undefined
: manualLogEntrySelection.value.data, : manualLogEntrySelection.value.data,
); );
const isLogDetailsOpen = computed( const isLogDetailsOpen = computed(() => isOpen.value && selectedLogEntry.value !== undefined);
() => selectedLogEntry.value !== undefined && !isCollapsingDetailsPanel.value, const isLogDetailsVisuallyOpen = computed(
() => isLogDetailsOpen.value && !isCollapsingDetailsPanel.value,
); );
const isLogDetailsOpenOrCollapsing = computed(() => selectedLogEntry.value !== undefined);
const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$props']>(() => ({ const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$props']>(() => ({
isOpen: isOpen.value, isOpen: isOpen.value,
showToggleButton: !isPoppedOut.value, showToggleButton: !isPoppedOut.value,
@@ -149,9 +149,9 @@ function handleResizeOverviewPanelEnd() {
<N8nResizeWrapper <N8nResizeWrapper
:class="$style.overviewResizer" :class="$style.overviewResizer"
:width="overviewPanelWidth" :width="overviewPanelWidth"
:style="{ width: isLogDetailsOpen ? `${overviewPanelWidth}px` : '' }" :style="{ width: isLogDetailsVisuallyOpen ? `${overviewPanelWidth}px` : '' }"
:supported-directions="['right']" :supported-directions="['right']"
:is-resizing-enabled="isLogDetailsOpenOrCollapsing" :is-resizing-enabled="isLogDetailsOpen"
:window="pipWindow" :window="pipWindow"
@resize="onOverviewPanelResize" @resize="onOverviewPanelResize"
@resizeend="handleResizeOverviewPanelEnd" @resizeend="handleResizeOverviewPanelEnd"
@@ -160,19 +160,22 @@ function handleResizeOverviewPanelEnd() {
:class="$style.logsOverview" :class="$style.logsOverview"
:is-open="isOpen" :is-open="isOpen"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:is-compact="isLogDetailsOpen" :is-compact="isLogDetailsVisuallyOpen"
:selected="selectedLogEntry" :selected="selectedLogEntry"
:execution-tree="executionTree" :execution-tree="executionTree"
@click-header="onToggleOpen(true)" @click-header="onToggleOpen(true)"
@select="handleSelectLogEntry" @select="handleSelectLogEntry"
> >
<template #actions> <template #actions>
<LogsPanelActions v-if="!isLogDetailsOpen" v-bind="logsPanelActionsProps" /> <LogsPanelActions
v-if="!isLogDetailsVisuallyOpen"
v-bind="logsPanelActionsProps"
/>
</template> </template>
</LogsOverviewPanel> </LogsOverviewPanel>
</N8nResizeWrapper> </N8nResizeWrapper>
<LogsDetailsPanel <LogsDetailsPanel
v-if="isLogDetailsOpenOrCollapsing && selectedLogEntry" v-if="isLogDetailsVisuallyOpen && selectedLogEntry"
:class="$style.logDetails" :class="$style.logDetails"
:is-open="isOpen" :is-open="isOpen"
:log-entry="selectedLogEntry" :log-entry="selectedLogEntry"
@@ -180,7 +183,7 @@ function handleResizeOverviewPanelEnd() {
@click-header="onToggleOpen(true)" @click-header="onToggleOpen(true)"
> >
<template #actions> <template #actions>
<LogsPanelActions v-if="isLogDetailsOpen" v-bind="logsPanelActionsProps" /> <LogsPanelActions v-if="isLogDetailsVisuallyOpen" v-bind="logsPanelActionsProps" />
</template> </template>
</LogsDetailsPanel> </LogsDetailsPanel>
</div> </div>

View File

@@ -92,9 +92,13 @@ describe('LogDetailsPanel', () => {
createdAt: '2025-04-16T00:00:00.000Z', createdAt: '2025-04-16T00:00:00.000Z',
startedAt: '2025-04-16T00:00:01.000Z', startedAt: '2025-04-16T00:00:01.000Z',
}); });
localStorage.clear();
}); });
it('should show name, run status, input, and output of the node', async () => { it('should show name, run status, input, and output of the node', async () => {
localStorage.setItem('N8N_LOGS_DETAIL_PANEL_CONTENT', 'both');
const rendered = render({ const rendered = render({
isOpen: true, isOpen: true,
logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }), logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),
@@ -118,12 +122,12 @@ describe('LogDetailsPanel', () => {
const header = within(rendered.getByTestId('log-details-header')); const header = within(rendered.getByTestId('log-details-header'));
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument(); expect(rendered.queryByTestId('log-details-input')).not.toBeInTheDocument();
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument(); expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
await fireEvent.click(header.getByText('Input')); await fireEvent.click(header.getByText('Input'));
expect(rendered.queryByTestId('log-details-input')).not.toBeInTheDocument(); expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument(); expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
await fireEvent.click(header.getByText('Output')); await fireEvent.click(header.getByText('Output'));
@@ -133,6 +137,8 @@ describe('LogDetailsPanel', () => {
}); });
it('should close input panel by dragging the divider to the left end', async () => { it('should close input panel by dragging the divider to the left end', async () => {
localStorage.setItem('N8N_LOGS_DETAIL_PANEL_CONTENT', 'both');
const rendered = render({ const rendered = render({
isOpen: true, isOpen: true,
logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }), logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),
@@ -150,6 +156,8 @@ describe('LogDetailsPanel', () => {
}); });
it('should close output panel by dragging the divider to the right end', async () => { it('should close output panel by dragging the divider to the right end', async () => {
localStorage.setItem('N8N_LOGS_DETAIL_PANEL_CONTENT', 'both');
const rendered = render({ const rendered = render({
isOpen: true, isOpen: true,
logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }), logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),

View File

@@ -12,8 +12,9 @@ import { type INodeUi } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { N8nButton, N8nResizeWrapper, N8nText } from '@n8n/design-system'; import { N8nButton, N8nResizeWrapper, N8nText } from '@n8n/design-system';
import { useLocalStorage } from '@vueuse/core';
import { type ITaskData } from 'n8n-workflow'; import { type ITaskData } from 'n8n-workflow';
import { computed, ref, useTemplateRef } from 'vue'; import { computed, useTemplateRef } from 'vue';
const MIN_IO_PANEL_WIDTH = 200; const MIN_IO_PANEL_WIDTH = 200;
@@ -32,7 +33,11 @@ const telemetry = useTelemetry();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const nodeTypeStore = useNodeTypesStore(); const nodeTypeStore = useNodeTypesStore();
const content = ref<LogDetailsContent>(LOG_DETAILS_CONTENT.BOTH); const content = useLocalStorage<LogDetailsContent>(
'N8N_LOGS_DETAIL_PANEL_CONTENT',
LOG_DETAILS_CONTENT.OUTPUT,
{ writeDefaults: false },
);
const node = computed<INodeUi | undefined>(() => workflowsStore.nodesByName[logEntry.node]); const node = computed<INodeUi | undefined>(() => workflowsStore.nodesByName[logEntry.node]);
const type = computed(() => (node.value ? nodeTypeStore.getNodeType(node.value.type) : undefined)); const type = computed(() => (node.value ? nodeTypeStore.getNodeType(node.value.type) : undefined));
@@ -100,7 +105,11 @@ function handleResizeEnd() {
<template> <template>
<div ref="container" :class="$style.container" data-test-id="log-details"> <div ref="container" :class="$style.container" data-test-id="log-details">
<PanelHeader data-test-id="log-details-header" @click="emit('clickHeader')"> <PanelHeader
data-test-id="log-details-header"
:class="$style.header"
@click="emit('clickHeader')"
>
<template #title> <template #title>
<div :class="$style.title"> <div :class="$style.title">
<NodeIcon :node-type="type" :size="16" :class="$style.icon" /> <NodeIcon :node-type="type" :size="16" :class="$style.icon" />
@@ -183,6 +192,10 @@ function handleResizeEnd() {
overflow: hidden; overflow: hidden;
} }
.header {
padding: var(--spacing-2xs);
}
.actions { .actions {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -42,8 +42,8 @@ const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
const workflow = computed(() => workflowsStore.getCurrentWorkflow()); const workflow = computed(() => workflowsStore.getCurrentWorkflow());
const isEmpty = computed(() => workflowsStore.workflowExecutionData === null); const isEmpty = computed(() => workflowsStore.workflowExecutionData === null);
const switchViewOptions = computed(() => [ const switchViewOptions = computed(() => [
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' as const },
{ label: locale.baseText('logs.overview.header.switch.overview'), value: 'overview' as const }, { label: locale.baseText('logs.overview.header.switch.overview'), value: 'overview' as const },
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' as const },
]); ]);
const execution = computed(() => workflowsStore.workflowExecutionData); const execution = computed(() => workflowsStore.workflowExecutionData);
const consumedTokens = computed(() => const consumedTokens = computed(() =>
@@ -166,7 +166,7 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
</template> </template>
</ElTree> </ElTree>
<N8nRadioButtons <N8nRadioButtons
size="medium" size="small"
:class="$style.switchViewButtons" :class="$style.switchViewButtons"
:model-value="selected ? 'details' : 'overview'" :model-value="selected ? 'details' : 'overview'"
:options="switchViewOptions" :options="switchViewOptions"
@@ -193,6 +193,7 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
.clearButton { .clearButton {
border: none; border: none;
color: var(--color-text-light); color: var(--color-text-light);
gap: var(--spacing-5xs);
} }
.content { .content {
@@ -222,9 +223,7 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
} }
.summary { .summary {
margin-bottom: var(--spacing-4xs); padding: var(--spacing-2xs);
padding: var(--spacing-4xs) var(--spacing-2xs) 0 var(--spacing-2xs);
min-height: calc(30px + var(--spacing-s));
} }
.tree { .tree {

View File

@@ -122,7 +122,7 @@ watch(
:color="isError ? 'danger' : undefined" :color="isError ? 'danger' : undefined"
>{{ node.name }} >{{ node.name }}
</N8nText> </N8nText>
<N8nText tag="div" color="text-light" size="small" :class="$style.timeTook"> <N8nText v-if="!isCompact" tag="div" color="text-light" size="small" :class="$style.timeTook">
<I18nT v-if="isSettled && runData" keypath="logs.overview.body.summaryText"> <I18nT v-if="isSettled && runData" keypath="logs.overview.body.summaryText">
<template #status> <template #status>
<N8nText v-if="isError" color="danger" :bold="true" size="small"> <N8nText v-if="isError" color="danger" :bold="true" size="small">
@@ -140,7 +140,7 @@ watch(
startedAtText startedAtText
}}</N8nText> }}</N8nText>
<N8nText <N8nText
v-if="subtreeConsumedTokens !== undefined" v-if="!isCompact && subtreeConsumedTokens !== undefined"
tag="div" tag="div"
color="text-light" color="text-light"
size="small" size="small"
@@ -207,31 +207,31 @@ watch(
position: relative; position: relative;
z-index: 1; z-index: 1;
--row-gap-thickness: 1px;
& > * { & > * {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
padding: var(--spacing-2xs); padding: var(--spacing-2xs);
margin-bottom: var(--row-gap-thickness);
} }
} }
.background { .background {
position: absolute; position: absolute;
left: calc(var(--row-gap-thickness) + var(--indent-depth) * 32px); left: calc(var(--indent-depth) * 32px);
top: 0; top: 0;
width: calc(100% - var(--indent-depth) * 32px - var(--row-gap-thickness)); width: calc(100% - var(--indent-depth) * 32px);
height: calc(100% - var(--row-gap-thickness)); height: 100%;
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
z-index: -1; z-index: -1;
.selected &, .selected & {
.container:hover & {
background-color: var(--color-foreground-base); background-color: var(--color-foreground-base);
} }
.container:hover:not(.selected) & {
background-color: var(--color-background-light-base);
}
.selected:not(:hover).error & { .selected:not(:hover).error & {
background-color: var(--color-danger-tint-2); background-color: var(--color-danger-tint-2);
} }
@@ -288,24 +288,12 @@ watch(
margin-right: var(--spacing-4xs); margin-right: var(--spacing-4xs);
vertical-align: text-bottom; vertical-align: text-bottom;
} }
.compact & {
flex-shrink: 1;
}
.compact:hover & {
width: auto;
}
.compact:not(:hover) & {
display: none;
}
} }
.startedAt { .startedAt {
flex-grow: 0; flex-grow: 0;
flex-shrink: 0; flex-shrink: 0;
width: 30%; width: 25%;
.compact & { .compact & {
display: none; display: none;
@@ -315,21 +303,8 @@ watch(
.consumedTokens { .consumedTokens {
flex-grow: 0; flex-grow: 0;
flex-shrink: 0; flex-shrink: 0;
width: 10%; width: 15%;
text-align: right; text-align: right;
.compact & {
flex-shrink: 1;
}
.compact:hover & {
width: auto;
}
.compact &:empty,
.compact:not(:hover) & {
display: none;
}
} }
.compactErrorIcon { .compactErrorIcon {

View File

@@ -24,7 +24,7 @@ const emit = defineEmits<{ click: [] }>();
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
text-align: left; text-align: left;
padding-inline-start: var(--spacing-s); padding-inline-start: var(--spacing-s);
padding-inline-end: var(--spacing-xs); padding-inline-end: var(--spacing-2xs);
padding-block: var(--spacing-2xs); padding-block: var(--spacing-2xs);
background-color: var(--color-foreground-xlight); background-color: var(--color-foreground-xlight);
display: flex; display: flex;

View File

@@ -61,6 +61,7 @@ function handleClickOpenNdv() {
:compact="true" :compact="true"
:disable-pin="true" :disable-pin="true"
:disable-edit="true" :disable-edit="true"
:disable-hover-highlight="true"
table-header-bg-color="light" table-header-bg-color="light"
> >
<template #header> <template #header>

View File

@@ -35,14 +35,14 @@ export function useLayout(
const chatPanelResizer = useResizablePanel(LOCAL_STORAGE_PANEL_WIDTH, { const chatPanelResizer = useResizablePanel(LOCAL_STORAGE_PANEL_WIDTH, {
container, container,
defaultSize: (size) => size * 0.3, defaultSize: (size) => Math.min(800, size * 0.3),
minSize: 300, minSize: 240,
maxSize: (size) => size * 0.8, maxSize: (size) => size * 0.8,
}); });
const overviewPanelResizer = useResizablePanel(LOCAL_STORAGE_OVERVIEW_PANEL_WIDTH, { const overviewPanelResizer = useResizablePanel(LOCAL_STORAGE_OVERVIEW_PANEL_WIDTH, {
container: logsContainer, container: logsContainer,
defaultSize: (size) => size * 0.3, defaultSize: (size) => Math.min(240, size * 0.2),
minSize: 80, minSize: 80,
maxSize: 500, maxSize: 500,
allowFullSize: true, allowFullSize: true,
@@ -103,15 +103,19 @@ export function useLayout(
resizer.onResizeEnd(); resizer.onResizeEnd();
} }
watch([panelState, resizer.size], ([state, height]) => { watch(
canvasStore.setPanelHeight( [panelState, resizer.size],
state === LOGS_PANEL_STATE.FLOATING ([state, height]) => {
? 0 canvasStore.setPanelHeight(
: state === LOGS_PANEL_STATE.ATTACHED state === LOGS_PANEL_STATE.FLOATING
? height ? 0
: 32 /* collapsed panel height */, : state === LOGS_PANEL_STATE.ATTACHED
); ? height
}); : 32 /* collapsed panel height */,
);
},
{ immediate: true },
);
return { return {
height: resizer.size, height: resizer.size,

View File

@@ -56,13 +56,21 @@ describe(useResizablePanel, () => {
}); });
it('should restore value from local storage if valid number is stored', () => { it('should restore value from local storage if valid number is stored', () => {
window.localStorage.setItem(localStorageKey, '333'); window.localStorage.setItem(localStorageKey, '0.333');
const { size } = useResizablePanel(localStorageKey, { container, defaultSize: 444 }); const { size } = useResizablePanel(localStorageKey, { container, defaultSize: 444 });
expect(size.value).toBe(333); expect(size.value).toBe(333);
}); });
it('should return defaultSize if invalid value is stored in local storage', () => {
window.localStorage.setItem(localStorageKey, '333');
const { size } = useResizablePanel(localStorageKey, { container, defaultSize: 444 });
expect(size.value).toBe(444);
});
it('should update size when onResize is called', () => { it('should update size when onResize is called', () => {
const { size, onResize } = useResizablePanel(localStorageKey, { container, defaultSize: 444 }); const { size, onResize } = useResizablePanel(localStorageKey, { container, defaultSize: 444 });

View File

@@ -9,8 +9,17 @@ interface UseResizerV2Options {
* Container element, to which relative size is calculated (doesn't necessarily have to be DOM parent node) * Container element, to which relative size is calculated (doesn't necessarily have to be DOM parent node)
*/ */
container: MaybeRef<HTMLElement | null>; container: MaybeRef<HTMLElement | null>;
/**
* Default size in pixels
*/
defaultSize: GetSize; defaultSize: GetSize;
/**
* Minimum size in pixels
*/
minSize?: GetSize; minSize?: GetSize;
/**
* Maximum size in pixels
*/
maxSize?: GetSize; maxSize?: GetSize;
/** /**
* Which end of the container the resizable element itself is located * Which end of the container the resizable element itself is located
@@ -46,31 +55,36 @@ export function useResizablePanel(
}: UseResizerV2Options, }: UseResizerV2Options,
) { ) {
const containerSize = ref(0); const containerSize = ref(0);
const size = useLocalStorage(localStorageKey, -1, { writeDefaults: false }); const persistedSize = useLocalStorage(localStorageKey, -1, { writeDefaults: false });
const isResizing = ref(false); const isResizing = ref(false);
const sizeOnResizeStart = ref<number>();
const minSizeValue = computed(() => resolveSize(minSize, containerSize.value));
const maxSizeValue = computed(() => resolveSize(maxSize, containerSize.value));
const constrainedSize = computed(() => { const constrainedSize = computed(() => {
if (isResizing.value && allowCollapse && size.value < 30) { const sizeInPixels =
persistedSize.value >= 0 && persistedSize.value <= 1
? containerSize.value * persistedSize.value
: -1;
if (isResizing.value && allowCollapse && sizeInPixels < 30) {
return 0; return 0;
} }
if (isResizing.value && allowFullSize && size.value > containerSize.value - 30) { if (isResizing.value && allowFullSize && sizeInPixels > containerSize.value - 30) {
return containerSize.value; return containerSize.value;
} }
const defaultSizeValue = resolveSize(defaultSize, containerSize.value); const defaultSizeValue = resolveSize(defaultSize, containerSize.value);
if (Number.isNaN(size.value) || size.value < 0) { if (Number.isNaN(sizeInPixels) || !Number.isFinite(sizeInPixels) || sizeInPixels < 0) {
return defaultSizeValue; return defaultSizeValue;
} }
const minSizeValue = resolveSize(minSize, containerSize.value);
const maxSizeValue = resolveSize(maxSize, containerSize.value);
return Math.max( return Math.max(
minSizeValue, minSizeValue.value,
Math.min( Math.min(
snap && Math.abs(defaultSizeValue - size.value) < 30 ? defaultSizeValue : size.value, snap && Math.abs(defaultSizeValue - sizeInPixels) < 30 ? defaultSizeValue : sizeInPixels,
maxSizeValue, maxSizeValue.value,
), ),
); );
}); });
@@ -93,17 +107,31 @@ export function useResizablePanel(
function onResize(data: ResizeData) { function onResize(data: ResizeData) {
const containerRect = unref(container)?.getBoundingClientRect(); const containerRect = unref(container)?.getBoundingClientRect();
const newSizeInPixels = Math.max(
isResizing.value = true;
size.value = Math.max(
0, 0,
position === 'bottom' position === 'bottom'
? (containerRect ? getSize(containerRect) : 0) - getValue(data) ? (containerRect ? getSize(containerRect) : 0) - getValue(data)
: getValue(data) - (containerRect ? getValue(containerRect) : 0), : getValue(data) - (containerRect ? getValue(containerRect) : 0),
); );
isResizing.value = true;
persistedSize.value = newSizeInPixels / containerSize.value;
if (sizeOnResizeStart.value === undefined) {
sizeOnResizeStart.value = persistedSize.value;
}
} }
function onResizeEnd() { function onResizeEnd() {
// If resizing ends with either collapsing or maximizing the panel, restore size at the start of dragging
if (
(minSizeValue.value > 0 && constrainedSize.value <= 0) ||
(maxSizeValue.value < containerSize.value && constrainedSize.value >= containerSize.value)
) {
persistedSize.value = sizeOnResizeStart.value;
}
sizeOnResizeStart.value = undefined;
isResizing.value = false; isResizing.value = false;
} }
@@ -127,15 +155,6 @@ export function useResizablePanel(
{ immediate: true }, { immediate: true },
); );
watch(containerSize, (newValue, oldValue) => {
if (size.value > 0 && oldValue > 0) {
// Update size to maintain proportion
const ratio = size.value / oldValue;
size.value = Math.round(newValue * ratio);
}
});
return { return {
isResizing: computed(() => isResizing.value), isResizing: computed(() => isResizing.value),
isCollapsed: computed(() => isResizing.value && constrainedSize.value <= 0), isCollapsed: computed(() => isResizing.value && constrainedSize.value <= 0),

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { type ContextMenuAction, useContextMenu } from '@/composables/useContextMenu'; import { type ContextMenuAction, useContextMenu } from '@/composables/useContextMenu';
import { useStyles } from '@/composables/useStyles';
import { N8nActionDropdown } from '@n8n/design-system'; import { N8nActionDropdown } from '@n8n/design-system';
import { watch, ref } from 'vue'; import { watch, ref } from 'vue';
@@ -7,6 +8,7 @@ const contextMenu = useContextMenu();
const { position, isOpen, actions, target } = contextMenu; const { position, isOpen, actions, target } = contextMenu;
const dropdown = ref<InstanceType<typeof N8nActionDropdown>>(); const dropdown = ref<InstanceType<typeof N8nActionDropdown>>();
const emit = defineEmits<{ action: [action: ContextMenuAction, nodeIds: string[]] }>(); const emit = defineEmits<{ action: [action: ContextMenuAction, nodeIds: string[]] }>();
const { APP_Z_INDEXES } = useStyles();
watch( watch(
isOpen, isOpen,
@@ -40,6 +42,7 @@ function onVisibleChange(open: boolean) {
:style="{ :style="{
left: `${position[0]}px`, left: `${position[0]}px`,
top: `${position[1]}px`, top: `${position[1]}px`,
zIndex: APP_Z_INDEXES.CONTEXT_MENU,
}" }"
> >
<N8nActionDropdown <N8nActionDropdown

View File

@@ -93,6 +93,7 @@ import { usePostHog } from '@/stores/posthog.store';
import ViewSubExecution from './ViewSubExecution.vue'; import ViewSubExecution from './ViewSubExecution.vue';
import RunDataItemCount from '@/components/RunDataItemCount.vue'; import RunDataItemCount from '@/components/RunDataItemCount.vue';
import RunDataDisplayModeSelect from '@/components/RunDataDisplayModeSelect.vue'; import RunDataDisplayModeSelect from '@/components/RunDataDisplayModeSelect.vue';
import RunDataPaginationBar from '@/components/RunDataPaginationBar.vue';
const LazyRunDataTable = defineAsyncComponent( const LazyRunDataTable = defineAsyncComponent(
async () => await import('@/components/RunDataTable.vue'), async () => await import('@/components/RunDataTable.vue'),
@@ -141,6 +142,7 @@ type Props = {
disablePin?: boolean; disablePin?: boolean;
compact?: boolean; compact?: boolean;
tableHeaderBgColor?: 'base' | 'light'; tableHeaderBgColor?: 'base' | 'light';
disableHoverHighlight?: boolean;
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -158,6 +160,7 @@ const props = withDefaults(defineProps<Props>(), {
disableRunIndexSelection: false, disableRunIndexSelection: false,
disableEdit: false, disableEdit: false,
disablePin: false, disablePin: false,
disableHoverHighlight: false,
compact: false, compact: false,
tableHeaderBgColor: 'base', tableHeaderBgColor: 'base',
}); });
@@ -203,7 +206,6 @@ const binaryDataDisplayVisible = ref(false);
const binaryDataDisplayData = ref<IBinaryData | null>(null); const binaryDataDisplayData = ref<IBinaryData | null>(null);
const currentPage = ref(1); const currentPage = ref(1);
const pageSize = ref(10); const pageSize = ref(10);
const pageSizes = [1, 10, 25, 50, 100];
const pinDataDiscoveryTooltipVisible = ref(false); const pinDataDiscoveryTooltipVisible = ref(false);
const isControlledPinDataTooltip = ref(false); const isControlledPinDataTooltip = ref(false);
@@ -605,7 +607,6 @@ const itemsCountProps = computed<InstanceType<typeof RunDataItemCount>['$props']
dataCount: dataCount.value, dataCount: dataCount.value,
unfilteredDataCount: unfilteredDataCount.value, unfilteredDataCount: unfilteredDataCount.value,
subExecutionsCount: activeTaskMetadata.value?.subExecutionsCount, subExecutionsCount: activeTaskMetadata.value?.subExecutionsCount,
muted: props.compact || (props.paneType === 'input' && maxRunIndex.value === 0),
})); }));
watch(node, (newNode, prevNode) => { watch(node, (newNode, prevNode) => {
@@ -1369,7 +1370,9 @@ defineExpose({ enterEditMode });
/> />
<div :class="$style.header"> <div :class="$style.header">
<slot name="header"></slot> <div :class="$style.title">
<slot name="header"></slot>
</div>
<div <div
v-show="!hasRunError && !isTrimmedManualExecutionDataItem" v-show="!hasRunError && !isTrimmedManualExecutionDataItem"
@@ -1776,6 +1779,8 @@ defineExpose({ enterEditMode });
:has-default-hover-state="paneType === 'input' && !search" :has-default-hover-state="paneType === 'input' && !search"
:search="search" :search="search"
:header-bg-color="tableHeaderBgColor" :header-bg-color="tableHeaderBgColor"
:compact="props.compact"
:disable-hover-highlight="props.disableHoverHighlight"
@mounted="emit('tableMounted', $event)" @mounted="emit('tableMounted', $event)"
@active-row-changed="onItemHover" @active-row-changed="onItemHover"
@display-mode-change="onDisplayModeChange" @display-mode-change="onDisplayModeChange"
@@ -1795,6 +1800,7 @@ defineExpose({ enterEditMode });
:output-index="currentOutputIndex" :output-index="currentOutputIndex"
:total-runs="maxRunIndex" :total-runs="maxRunIndex"
:search="search" :search="search"
:compact="props.compact"
/> />
</Suspense> </Suspense>
@@ -1815,6 +1821,7 @@ defineExpose({ enterEditMode });
:total-runs="maxRunIndex" :total-runs="maxRunIndex"
:search="search" :search="search"
:class="$style.schema" :class="$style.schema"
:compact="props.compact"
@clear:search="onSearchClear" @clear:search="onSearchClear"
/> />
</Suspense> </Suspense>
@@ -1908,7 +1915,7 @@ defineExpose({ enterEditMode });
<slot name="node-not-run"></slot> <slot name="node-not-run"></slot>
</div> </div>
</div> </div>
<div <RunDataPaginationBar
v-if=" v-if="
hidePagination === false && hidePagination === false &&
hasNodeRun && hasNodeRun &&
@@ -1919,41 +1926,17 @@ defineExpose({ enterEditMode });
!isArtificialRecoveredEventItem !isArtificialRecoveredEventItem
" "
v-show="!editMode.enabled" v-show="!editMode.enabled"
:class="$style.pagination" :current-page="currentPage"
data-test-id="ndv-data-pagination" :page-size="pageSize"
> :total="dataCount"
<el-pagination @update:current-page="onCurrentPageChange"
background @update:page-size="onPageSizeChange"
:hide-on-single-page="true" />
:current-page="currentPage"
:pager-count="5"
:page-size="pageSize"
layout="prev, pager, next"
:total="dataCount"
@update:current-page="onCurrentPageChange"
>
</el-pagination>
<div :class="$style.pageSizeSelector">
<N8nSelect
size="mini"
:model-value="pageSize"
teleported
@update:model-value="onPageSizeChange"
>
<template #prepend>{{ i18n.baseText('ndv.output.pageSize') }}</template>
<N8nOption v-for="size in pageSizes" :key="size" :label="size" :value="size"> </N8nOption>
<N8nOption :label="i18n.baseText('ndv.output.all')" :value="dataCount"> </N8nOption>
</N8nSelect>
</div>
</div>
<N8nBlockUi :show="blockUI" :class="$style.uiBlocker" /> <N8nBlockUi :show="blockUI" :class="$style.uiBlocker" />
</div> </div>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
@import '@/styles/variables';
.infoIcon { .infoIcon {
color: var(--color-foreground-dark); color: var(--color-foreground-dark);
} }
@@ -1999,10 +1982,15 @@ defineExpose({ enterEditMode });
overflow-y: hidden; overflow-y: hidden;
min-height: calc(30px + var(--spacing-s)); min-height: calc(30px + var(--spacing-s));
scrollbar-width: thin; scrollbar-width: thin;
container-type: inline-size;
.compact & { .compact & {
margin-bottom: var(--spacing-4xs); margin-bottom: var(--spacing-4xs);
padding: var(--spacing-4xs) var(--spacing-s) 0 var(--spacing-s); padding: var(--spacing-2xs);
margin-bottom: 0;
flex-shrink: 0;
flex-grow: 0;
min-height: auto;
} }
> *:first-child { > *:first-child {
@@ -2014,12 +2002,6 @@ defineExpose({ enterEditMode });
position: relative; position: relative;
overflow-y: auto; overflow-y: auto;
height: 100%; height: 100%;
&:hover {
.actions-group {
opacity: 1;
}
}
} }
.dataDisplay { .dataDisplay {
@@ -2032,6 +2014,10 @@ defineExpose({ enterEditMode });
line-height: var(--font-line-height-xloose); line-height: var(--font-line-height-xloose);
word-break: normal; word-break: normal;
height: 100%; height: 100%;
.compact & {
padding: 0 var(--spacing-2xs);
}
} }
.inlineError { .inlineError {
@@ -2102,22 +2088,6 @@ defineExpose({ enterEditMode });
margin-left: auto; margin-left: auto;
} }
.pagination {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
bottom: 0;
padding: 5px;
overflow-y: hidden;
}
.pageSizeSelector {
text-transform: capitalize;
max-width: 150px;
flex: 0 1 auto;
}
.binaryIndex { .binaryIndex {
display: block; display: block;
padding: var(--spacing-2xs); padding: var(--spacing-2xs);
@@ -2185,6 +2155,11 @@ defineExpose({ enterEditMode });
align-items: center; align-items: center;
flex-grow: 1; flex-grow: 1;
gap: var(--spacing-2xs); gap: var(--spacing-2xs);
.compact & {
/* let title text alone decide the height */
height: 0;
}
} }
.tooltipContain { .tooltipContain {
@@ -2255,6 +2230,10 @@ defineExpose({ enterEditMode });
margin-bottom: var(--spacing-xs); margin-bottom: var(--spacing-xs);
margin-left: var(--spacing-s); margin-left: var(--spacing-s);
margin-right: var(--spacing-s); margin-right: var(--spacing-s);
.compact & {
margin: 0 var(--spacing-2xs) var(--spacing-2xs) var(--spacing-2xs);
}
} }
.schema { .schema {
@@ -2263,15 +2242,21 @@ defineExpose({ enterEditMode });
.search, .search,
.displayModeSelect { .displayModeSelect {
.compact & { .compact:not(:hover) & {
opacity: 0; opacity: 0;
visibility: hidden; display: none;
transition: opacity 0.3s $ease-out-expo;
} }
.compact:hover & { .compact:hover & {
opacity: 1; opacity: 1;
visibility: visible; }
}
@container (max-width: 240px) {
/* Hide title when the panel is too narrow */
.compact:hover .title {
visibility: hidden;
width: 0;
} }
} }
</style> </style>

View File

@@ -39,6 +39,7 @@ const options = computed(() => {
:model-value="value" :model-value="value"
:options="options" :options="options"
data-test-id="ndv-run-data-display-mode" data-test-id="ndv-run-data-display-mode"
:size="compact ? 'small' : 'medium'"
@update:model-value="(selected) => emit('change', selected)" @update:model-value="(selected) => emit('change', selected)"
> >
<template v-if="compact" #option="option"> <template v-if="compact" #option="option">
@@ -64,6 +65,6 @@ const options = computed(() => {
<style lang="scss" module> <style lang="scss" module>
.icon { .icon {
padding-inline: var(--spacing-2xs); padding-inline: var(--spacing-4xs);
} }
</style> </style>

View File

@@ -7,20 +7,18 @@ const {
unfilteredDataCount, unfilteredDataCount,
subExecutionsCount = 0, subExecutionsCount = 0,
search, search,
muted,
} = defineProps<{ } = defineProps<{
dataCount: number; dataCount: number;
unfilteredDataCount: number; unfilteredDataCount: number;
subExecutionsCount?: number; subExecutionsCount?: number;
search: string; search: string;
muted: boolean;
}>(); }>();
const i18n = useI18n(); const i18n = useI18n();
</script> </script>
<template> <template>
<N8nText v-if="search" :class="[$style.itemsText, muted ? $style.muted : '']"> <N8nText v-if="search" :class="$style.itemsText">
{{ {{
i18n.baseText('ndv.search.items', { i18n.baseText('ndv.search.items', {
adjustToNumber: unfilteredDataCount, adjustToNumber: unfilteredDataCount,
@@ -28,7 +26,7 @@ const i18n = useI18n();
}) })
}} }}
</N8nText> </N8nText>
<N8nText v-else :class="[$style.itemsText, muted ? $style.muted : '']"> <N8nText v-else :class="$style.itemsText">
<span> <span>
{{ {{
i18n.baseText('ndv.output.items', { i18n.baseText('ndv.output.items', {
@@ -54,9 +52,6 @@ const i18n = useI18n();
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
}
.muted {
color: var(--color-text-light); color: var(--color-text-light);
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
} }

View File

@@ -33,6 +33,7 @@ const props = withDefaults(
runIndex: number | undefined; runIndex: number | undefined;
totalRuns: number | undefined; totalRuns: number | undefined;
search: string | undefined; search: string | undefined;
compact?: boolean;
}>(), }>(),
{ {
editMode: () => ({}), editMode: () => ({}),
@@ -123,7 +124,13 @@ const getListItemName = (path: string) => {
</script> </script>
<template> <template>
<div ref="jsonDataContainer" :class="[$style.jsonDisplay, { [$style.highlight]: highlight }]"> <div
ref="jsonDataContainer"
:class="[
$style.jsonDisplay,
{ [$style.highlight]: highlight, [$style.compact]: props.compact },
]"
>
<Suspense> <Suspense>
<LazyRunDataJsonActions <LazyRunDataJsonActions
v-if="!editMode.enabled" v-if="!editMode.enabled"
@@ -238,6 +245,10 @@ const getListItemName = (path: string) => {
color: var(--color-primary); color: var(--color-primary);
} }
} }
&.compact {
padding-left: var(--spacing-2xs);
}
} }
</style> </style>
@@ -245,6 +256,7 @@ const getListItemName = (path: string) => {
.vjs-tree { .vjs-tree {
color: var(--color-json-default); color: var(--color-json-default);
--color-line-break: var(--color-code-line-break); --color-line-break: var(--color-code-line-break);
font-size: var(--font-size-2xs);
} }
.vjs-tree-node { .vjs-tree-node {

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
const { pageSize, total, currentPage } = defineProps<{
pageSize: number;
total: number;
currentPage: number;
}>();
const emit = defineEmits<{ 'update:current-page': [number]; 'update:page-size': [number] }>();
const i18n = useI18n();
const pageSizes = [1, 10, 25, 50, 100];
</script>
<template>
<div :class="$style.pagination" data-test-id="ndv-data-pagination">
<el-pagination
background
:hide-on-single-page="true"
:current-page="currentPage"
:pager-count="5"
:page-size="pageSize"
layout="prev, pager, next"
:total="total"
@update:current-page="(value: number) => emit('update:current-page', value)"
>
</el-pagination>
<div :class="$style.pageSizeSelector">
<N8nSelect
size="mini"
:model-value="pageSize"
teleported
@update:model-value="(value: number) => emit('update:page-size', value)"
>
<template #prepend>{{ i18n.baseText('ndv.output.pageSize') }}</template>
<N8nOption v-for="size in pageSizes" :key="size" :label="size" :value="size"> </N8nOption>
<N8nOption :label="i18n.baseText('ndv.output.all')" :value="total"> </N8nOption>
</N8nSelect>
</div>
</div>
</template>
<style lang="scss" module>
.pagination {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px;
overflow-y: hidden;
flex-shrink: 0;
flex-grow: 0;
}
.pageSizeSelector {
text-transform: capitalize;
max-width: 150px;
flex: 0 1 auto;
}
</style>

View File

@@ -33,6 +33,8 @@ type Props = {
hasDefaultHoverState?: boolean; hasDefaultHoverState?: boolean;
search?: string; search?: string;
headerBgColor?: 'base' | 'light'; headerBgColor?: 'base' | 'light';
compact?: boolean;
disableHoverHighlight?: boolean;
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -43,6 +45,8 @@ const props = withDefaults(defineProps<Props>(), {
hasDefaultHoverState: false, hasDefaultHoverState: false,
search: '', search: '',
headerBgColor: 'base', headerBgColor: 'base',
disableHoverHighlight: false,
compact: false,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
activeRowChanged: [row: number | null]; activeRowChanged: [row: number | null];
@@ -91,6 +95,10 @@ onMounted(() => {
}); });
function isHoveringRow(row: number): boolean { function isHoveringRow(row: number): boolean {
if (props.disableHoverHighlight) {
return false;
}
if (row === activeRow.value) { if (row === activeRow.value) {
return true; return true;
} }
@@ -427,7 +435,11 @@ watch(focusedMappableInput, (curr) => {
<div <div
:class="[ :class="[
$style.dataDisplay, $style.dataDisplay,
{ [$style.highlight]: highlight, [$style.lightHeader]: headerBgColor === 'light' }, {
[$style.highlight]: highlight,
[$style.lightHeader]: headerBgColor === 'light',
[$style.compact]: props.compact,
},
]" ]"
> >
<table v-if="tableData.columns && tableData.columns.length === 0" :class="$style.table"> <table v-if="tableData.columns && tableData.columns.length === 0" :class="$style.table">
@@ -681,6 +693,10 @@ watch(focusedMappableInput, (curr) => {
word-break: normal; word-break: normal;
height: 100%; height: 100%;
padding-bottom: var(--spacing-3xl); padding-bottom: var(--spacing-3xl);
&.compact {
padding-left: var(--spacing-2xs);
}
} }
.table { .table {
@@ -848,6 +864,12 @@ watch(focusedMappableInput, (curr) => {
border-right: none !important; border-right: none !important;
border-top: none !important; border-top: none !important;
border-bottom: none !important; border-bottom: none !important;
.compact & {
padding: 0;
min-width: var(--spacing-2xs);
max-width: var(--spacing-2xs);
}
} }
.hoveringRow { .hoveringRow {

View File

@@ -62,6 +62,7 @@ type Props = {
paneType: 'input' | 'output'; paneType: 'input' | 'output';
connectionType?: NodeConnectionType; connectionType?: NodeConnectionType;
search?: string; search?: string;
compact?: boolean;
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -75,6 +76,7 @@ const props = withDefaults(defineProps<Props>(), {
connectionType: NodeConnectionTypes.Main, connectionType: NodeConnectionTypes.Main,
search: '', search: '',
mappingEnabled: false, mappingEnabled: false,
compact: false,
}); });
const telemetry = useTelemetry(); const telemetry = useTelemetry();
@@ -377,7 +379,7 @@ const onDragEnd = (el: HTMLElement) => {
</script> </script>
<template> <template>
<div class="run-data-schema full-height"> <div :class="['run-data-schema', 'full-height', props.compact ? 'compact' : '']">
<div v-if="noSearchResults" class="no-results"> <div v-if="noSearchResults" class="no-results">
<N8nText tag="h3" size="large">{{ i18n.baseText('ndv.search.noNodeMatch.title') }}</N8nText> <N8nText tag="h3" size="large">{{ i18n.baseText('ndv.search.noNodeMatch.title') }}</N8nText>
<N8nText> <N8nText>
@@ -491,6 +493,10 @@ const onDragEnd = (el: HTMLElement) => {
.scroller { .scroller {
padding: 0 var(--spacing-s); padding: 0 var(--spacing-s);
padding-bottom: var(--spacing-2xl); padding-bottom: var(--spacing-2xl);
.compact & {
padding: 0 var(--spacing-2xs);
}
} }
.no-results { .no-results {

View File

@@ -13,55 +13,60 @@ exports[`InputPanel > should render 1`] = `
class="header" class="header"
data-v-2e5cd75c="" data-v-2e5cd75c=""
> >
<div <div
class="titleSection" class="title"
data-v-2e5cd75c=""
> >
<span
class="title"
>
Input
</span>
<div <div
class="n8n-radio-buttons radioGroup" class="titleSection"
data-test-id="input-panel-mode"
role="radiogroup"
> >
<span
<label class="title"
aria-checked="true"
class="n8n-radio-button container hoverable"
role="radio"
tabindex="-1"
> >
<div Input
class="button active medium" </span>
data-test-id="radio-button-mapping" <div
> class="n8n-radio-buttons radioGroup"
data-test-id="input-panel-mode"
Mapping role="radiogroup"
</div>
</label>
<label
aria-checked="false"
class="n8n-radio-button container hoverable"
role="radio"
tabindex="-1"
> >
<div
class="button medium" <label
data-test-id="radio-button-debugging" aria-checked="true"
class="n8n-radio-button container hoverable"
role="radio"
tabindex="-1"
> >
<div
Debugging class="button active medium"
data-test-id="radio-button-mapping"
</div> >
</label>
Mapping
</div>
</label>
<label
aria-checked="false"
class="n8n-radio-button container hoverable"
role="radio"
tabindex="-1"
>
<div
class="button medium"
data-test-id="radio-button-debugging"
>
Debugging
</div>
</label>
</div>
</div> </div>
</div> </div>
<div <div
class="displayModes" class="displayModes"
data-test-id="run-data-pane-header" data-test-id="run-data-pane-header"

View File

@@ -50,6 +50,7 @@ import CanvasBackground from './elements/background/CanvasBackground.vue';
import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue'; import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue';
import Edge from './elements/edges/CanvasEdge.vue'; import Edge from './elements/edges/CanvasEdge.vue';
import Node from './elements/nodes/CanvasNode.vue'; import Node from './elements/nodes/CanvasNode.vue';
import { useViewportAutoAdjust } from '@/components/canvas/composables/useViewportAutoAdjust';
const $style = useCssModule(); const $style = useCssModule();
@@ -141,6 +142,7 @@ const {
findNode, findNode,
viewport, viewport,
nodesSelectionActive, nodesSelectionActive,
setViewport,
onEdgeMouseLeave, onEdgeMouseLeave,
onEdgeMouseEnter, onEdgeMouseEnter,
onEdgeMouseMove, onEdgeMouseMove,
@@ -536,6 +538,8 @@ function emitWithLastSelectedNode(emitFn: (id: string) => void) {
const defaultZoom = 1; const defaultZoom = 1;
const isPaneMoving = ref(false); const isPaneMoving = ref(false);
useViewportAutoAdjust(viewportRef, viewport, setViewport);
function getProjectedPosition(event?: Pick<MouseEvent, 'clientX' | 'clientY'>) { function getProjectedPosition(event?: Pick<MouseEvent, 'clientX' | 'clientY'>) {
const bounds = viewportRef.value?.getBoundingClientRect() ?? { left: 0, top: 0 }; const bounds = viewportRef.value?.getBoundingClientRect() ?? { left: 0, top: 0 };
const offsetX = event?.clientX ?? 0; const offsetX = event?.clientX ?? 0;

View File

@@ -0,0 +1,55 @@
import { ref } from 'vue';
import { useViewportAutoAdjust } from './useViewportAutoAdjust';
import { waitFor } from '@testing-library/vue';
vi.mock('@/stores/settings.store', () => ({
useSettingsStore: vi.fn(() => ({ isNewLogsEnabled: true })),
}));
describe(useViewportAutoAdjust, () => {
afterAll(() => {
vi.clearAllMocks();
});
it('should set viewport when canvas is resized', async () => {
let resizeHandler: ResizeObserverCallback = () => {};
vi.spyOn(window, 'ResizeObserver').mockImplementation((handler) => {
resizeHandler = handler;
return { observe() {}, disconnect() {}, unobserve() {} } as ResizeObserver;
});
const container = document.createElement('div');
Object.defineProperty(container, 'offsetWidth', {
configurable: true,
get() {
return 1000;
},
});
Object.defineProperty(container, 'offsetHeight', {
configurable: true,
get() {
return 800;
},
});
const viewportRef = ref(container);
const viewport = ref({ x: 30, y: 40, zoom: 0.5 });
const setViewport = vi.fn();
useViewportAutoAdjust(viewportRef, viewport, setViewport);
resizeHandler(
[{ contentRect: { x: 0, y: 0, width: 900, height: 1000 } } as ResizeObserverEntry],
{} as ResizeObserver,
);
await waitFor(() =>
expect(setViewport).toHaveBeenLastCalledWith({
x: -20, // 30 + (900 - 1000) / 2
y: 140, // 40 + (1000 - 800) / 2
zoom: 0.5, // unchanged
}),
);
});
});

View File

@@ -0,0 +1,57 @@
import { useSettingsStore } from '@/stores/settings.store';
import type { Rect, SetViewport, ViewportTransform } from '@vue-flow/core';
import { type Ref, ref, watch } from 'vue';
/**
* When canvas is resized (via window resize or toggling logs panel), adjust viewport to maintain center
*/
export function useViewportAutoAdjust(
viewportRef: Ref<HTMLElement | null>,
viewport: Ref<ViewportTransform>,
setViewport: SetViewport,
) {
const settingsStore = useSettingsStore();
if (settingsStore.isNewLogsEnabled) {
const canvasRect = ref<Rect>();
watch(
viewportRef,
(vp, _, onCleanUp) => {
if (!vp) {
return;
}
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
canvasRect.value = entry.contentRect;
}
});
canvasRect.value = {
x: vp.offsetLeft,
y: vp.offsetTop,
width: vp.offsetWidth,
height: vp.offsetHeight,
};
resizeObserver.observe(vp);
onCleanUp(() => resizeObserver.disconnect());
},
{ immediate: true },
);
watch(canvasRect, async (newRect, oldRect) => {
if (!newRect || !oldRect) {
return;
}
await setViewport({
x: viewport.value.x + (newRect.width - oldRect.width) / 2,
y: viewport.value.y + (newRect.height - oldRect.height) / 2,
zoom: viewport.value.zoom,
});
});
}
}

View File

@@ -9,7 +9,7 @@ describe('useStyles', () => {
setAppZIndexes(); setAppZIndexes();
expect(global.document.documentElement.style.setProperty).toHaveBeenNthCalledWith( expect(global.document.documentElement.style.setProperty).toHaveBeenNthCalledWith(
1, 2,
'--z-index-app-header', '--z-index-app-header',
'99', '99',
); );

View File

@@ -1,4 +1,5 @@
const APP_Z_INDEXES = { const APP_Z_INDEXES = {
CONTEXT_MENU: 10, // should be still in front of the logs panel
APP_HEADER: 99, APP_HEADER: 99,
SELECT_BOX: 100, SELECT_BOX: 100,
CANVAS_ADD_BUTTON: 101, CANVAS_ADD_BUTTON: 101,

View File

@@ -12,7 +12,7 @@ import type { ApplicationError } from 'n8n-workflow';
import { useStyles } from './useStyles'; import { useStyles } from './useStyles';
import { useCanvasStore } from '@/stores/canvas.store'; import { useCanvasStore } from '@/stores/canvas.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs'; import { useNDVStore } from '@/stores/ndv.store';
export interface NotificationErrorWithNodeAndDescription extends ApplicationError { export interface NotificationErrorWithNodeAndDescription extends ApplicationError {
node: { node: {
@@ -32,26 +32,22 @@ export function useToast() {
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const { APP_Z_INDEXES } = useStyles(); const { APP_Z_INDEXES } = useStyles();
const canvasStore = useCanvasStore(); const canvasStore = useCanvasStore();
const ndvStore = useNDVStore();
const messageDefaults: Partial<Omit<NotificationOptions, 'message'>> = {
dangerouslyUseHTMLString: true,
position: 'bottom-right',
zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays
offset:
settingsStore.isAiAssistantEnabled ||
workflowsStore.logsPanelState === LOGS_PANEL_STATE.ATTACHED
? 64
: 0,
appendTo: '#app-grid',
customClass: 'content-toast',
};
function showMessage(messageData: Partial<NotificationOptions>, track = true) { function showMessage(messageData: Partial<NotificationOptions>, track = true) {
const messageDefaults: Partial<Omit<NotificationOptions, 'message'>> = {
dangerouslyUseHTMLString: true,
position: 'bottom-right',
zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays
offset:
(settingsStore.isAiAssistantEnabled ? 64 : 0) +
(ndvStore.activeNode === null ? canvasStore.panelHeight : 0),
appendTo: '#app-grid',
customClass: 'content-toast',
};
const { message, title } = messageData; const { message, title } = messageData;
const params = { ...messageDefaults, ...messageData }; const params = { ...messageDefaults, ...messageData };
params.offset = +canvasStore.panelHeight;
if (typeof message === 'string') { if (typeof message === 'string') {
params.message = sanitizeHtml(message); params.message = sanitizeHtml(message);
} }

View File

@@ -1279,8 +1279,12 @@ const chatTriggerNodePinnedData = computed(() => {
return workflowsStore.pinDataByNodeName(chatTriggerNode.value.name); return workflowsStore.pinDataByNodeName(chatTriggerNode.value.name);
}); });
async function onOpenChat(isOpen?: boolean) { async function onToggleChat() {
await toggleChatOpen('main', isOpen); await toggleChatOpen('main');
}
async function onOpenChat() {
await toggleChatOpen('main', true);
} }
/** /**
@@ -1787,7 +1791,7 @@ onBeforeUnmount(() => {
v-if="containsChatTriggerNodes" v-if="containsChatTriggerNodes"
:type="isLogsPanelOpen ? 'tertiary' : 'primary'" :type="isLogsPanelOpen ? 'tertiary' : 'primary'"
:label="isLogsPanelOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open')" :label="isLogsPanelOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open')"
@click="onOpenChat(!isLogsPanelOpen)" @click="onToggleChat"
/> />
<CanvasStopCurrentExecutionButton <CanvasStopCurrentExecutionButton
v-if="isStopExecutionButtonVisible" v-if="isStopExecutionButtonVisible"