mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
fix(editor): Styling/UX improvements on the new logs view (#14789)
This commit is contained in:
@@ -207,6 +207,10 @@ export function useChatState(isReadOnly: boolean, onWindowResize?: () => void):
|
||||
nodeHelpers.updateNodesExecutionIssues();
|
||||
messages.value = [];
|
||||
currentSessionId.value = uuid().replace(/-/g, '');
|
||||
|
||||
if (logsPanelState.value !== LOGS_PANEL_STATE.CLOSED) {
|
||||
chatEventBus.emit('focusInput');
|
||||
}
|
||||
}
|
||||
|
||||
function displayExecution(executionId: string) {
|
||||
|
||||
@@ -141,15 +141,17 @@ describe('LogsPanel', () => {
|
||||
await fireEvent.click(await rendered.findByTestId('logs-overview-header'));
|
||||
await fireEvent.click(await rendered.findByText('AI Agent'));
|
||||
|
||||
const detailsPanel = rendered.getByTestId('log-details');
|
||||
|
||||
// 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('logs-overview-body')).not.toBeInTheDocument();
|
||||
|
||||
// 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('logs-overview-body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -77,10 +77,10 @@ const selectedLogEntry = computed(() =>
|
||||
? undefined
|
||||
: manualLogEntrySelection.value.data,
|
||||
);
|
||||
const isLogDetailsOpen = computed(
|
||||
() => selectedLogEntry.value !== undefined && !isCollapsingDetailsPanel.value,
|
||||
const isLogDetailsOpen = computed(() => isOpen.value && selectedLogEntry.value !== undefined);
|
||||
const isLogDetailsVisuallyOpen = computed(
|
||||
() => isLogDetailsOpen.value && !isCollapsingDetailsPanel.value,
|
||||
);
|
||||
const isLogDetailsOpenOrCollapsing = computed(() => selectedLogEntry.value !== undefined);
|
||||
const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$props']>(() => ({
|
||||
isOpen: isOpen.value,
|
||||
showToggleButton: !isPoppedOut.value,
|
||||
@@ -149,9 +149,9 @@ function handleResizeOverviewPanelEnd() {
|
||||
<N8nResizeWrapper
|
||||
:class="$style.overviewResizer"
|
||||
:width="overviewPanelWidth"
|
||||
:style="{ width: isLogDetailsOpen ? `${overviewPanelWidth}px` : '' }"
|
||||
:style="{ width: isLogDetailsVisuallyOpen ? `${overviewPanelWidth}px` : '' }"
|
||||
:supported-directions="['right']"
|
||||
:is-resizing-enabled="isLogDetailsOpenOrCollapsing"
|
||||
:is-resizing-enabled="isLogDetailsOpen"
|
||||
:window="pipWindow"
|
||||
@resize="onOverviewPanelResize"
|
||||
@resizeend="handleResizeOverviewPanelEnd"
|
||||
@@ -160,19 +160,22 @@ function handleResizeOverviewPanelEnd() {
|
||||
:class="$style.logsOverview"
|
||||
:is-open="isOpen"
|
||||
:is-read-only="isReadOnly"
|
||||
:is-compact="isLogDetailsOpen"
|
||||
:is-compact="isLogDetailsVisuallyOpen"
|
||||
:selected="selectedLogEntry"
|
||||
:execution-tree="executionTree"
|
||||
@click-header="onToggleOpen(true)"
|
||||
@select="handleSelectLogEntry"
|
||||
>
|
||||
<template #actions>
|
||||
<LogsPanelActions v-if="!isLogDetailsOpen" v-bind="logsPanelActionsProps" />
|
||||
<LogsPanelActions
|
||||
v-if="!isLogDetailsVisuallyOpen"
|
||||
v-bind="logsPanelActionsProps"
|
||||
/>
|
||||
</template>
|
||||
</LogsOverviewPanel>
|
||||
</N8nResizeWrapper>
|
||||
<LogsDetailsPanel
|
||||
v-if="isLogDetailsOpenOrCollapsing && selectedLogEntry"
|
||||
v-if="isLogDetailsVisuallyOpen && selectedLogEntry"
|
||||
:class="$style.logDetails"
|
||||
:is-open="isOpen"
|
||||
:log-entry="selectedLogEntry"
|
||||
@@ -180,7 +183,7 @@ function handleResizeOverviewPanelEnd() {
|
||||
@click-header="onToggleOpen(true)"
|
||||
>
|
||||
<template #actions>
|
||||
<LogsPanelActions v-if="isLogDetailsOpen" v-bind="logsPanelActionsProps" />
|
||||
<LogsPanelActions v-if="isLogDetailsVisuallyOpen" v-bind="logsPanelActionsProps" />
|
||||
</template>
|
||||
</LogsDetailsPanel>
|
||||
</div>
|
||||
|
||||
@@ -92,9 +92,13 @@ describe('LogDetailsPanel', () => {
|
||||
createdAt: '2025-04-16T00:00:00.000Z',
|
||||
startedAt: '2025-04-16T00:00:01.000Z',
|
||||
});
|
||||
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('should show name, run status, input, and output of the node', async () => {
|
||||
localStorage.setItem('N8N_LOGS_DETAIL_PANEL_CONTENT', 'both');
|
||||
|
||||
const rendered = render({
|
||||
isOpen: true,
|
||||
logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),
|
||||
@@ -118,12 +122,12 @@ describe('LogDetailsPanel', () => {
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
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 () => {
|
||||
localStorage.setItem('N8N_LOGS_DETAIL_PANEL_CONTENT', 'both');
|
||||
|
||||
const rendered = render({
|
||||
isOpen: true,
|
||||
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 () => {
|
||||
localStorage.setItem('N8N_LOGS_DETAIL_PANEL_CONTENT', 'both');
|
||||
|
||||
const rendered = render({
|
||||
isOpen: true,
|
||||
logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),
|
||||
|
||||
@@ -12,8 +12,9 @@ import { type INodeUi } from '@/Interface';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { N8nButton, N8nResizeWrapper, N8nText } from '@n8n/design-system';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { type ITaskData } from 'n8n-workflow';
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
|
||||
const MIN_IO_PANEL_WIDTH = 200;
|
||||
|
||||
@@ -32,7 +33,11 @@ const telemetry = useTelemetry();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
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 type = computed(() => (node.value ? nodeTypeStore.getNodeType(node.value.type) : undefined));
|
||||
@@ -100,7 +105,11 @@ function handleResizeEnd() {
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<div :class="$style.title">
|
||||
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
|
||||
@@ -183,6 +192,10 @@ function handleResizeEnd() {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -42,8 +42,8 @@ const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
|
||||
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||
const isEmpty = computed(() => workflowsStore.workflowExecutionData === null);
|
||||
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.details'), value: 'details' as const },
|
||||
]);
|
||||
const execution = computed(() => workflowsStore.workflowExecutionData);
|
||||
const consumedTokens = computed(() =>
|
||||
@@ -166,7 +166,7 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
|
||||
</template>
|
||||
</ElTree>
|
||||
<N8nRadioButtons
|
||||
size="medium"
|
||||
size="small"
|
||||
:class="$style.switchViewButtons"
|
||||
:model-value="selected ? 'details' : 'overview'"
|
||||
:options="switchViewOptions"
|
||||
@@ -193,6 +193,7 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
|
||||
.clearButton {
|
||||
border: none;
|
||||
color: var(--color-text-light);
|
||||
gap: var(--spacing-5xs);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -222,9 +223,7 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin-bottom: var(--spacing-4xs);
|
||||
padding: var(--spacing-4xs) var(--spacing-2xs) 0 var(--spacing-2xs);
|
||||
min-height: calc(30px + var(--spacing-s));
|
||||
padding: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.tree {
|
||||
|
||||
@@ -122,7 +122,7 @@ watch(
|
||||
:color="isError ? 'danger' : undefined"
|
||||
>{{ node.name }}
|
||||
</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">
|
||||
<template #status>
|
||||
<N8nText v-if="isError" color="danger" :bold="true" size="small">
|
||||
@@ -140,7 +140,7 @@ watch(
|
||||
startedAtText
|
||||
}}</N8nText>
|
||||
<N8nText
|
||||
v-if="subtreeConsumedTokens !== undefined"
|
||||
v-if="!isCompact && subtreeConsumedTokens !== undefined"
|
||||
tag="div"
|
||||
color="text-light"
|
||||
size="small"
|
||||
@@ -207,31 +207,31 @@ watch(
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
--row-gap-thickness: 1px;
|
||||
|
||||
& > * {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: var(--spacing-2xs);
|
||||
margin-bottom: var(--row-gap-thickness);
|
||||
}
|
||||
}
|
||||
|
||||
.background {
|
||||
position: absolute;
|
||||
left: calc(var(--row-gap-thickness) + var(--indent-depth) * 32px);
|
||||
left: calc(var(--indent-depth) * 32px);
|
||||
top: 0;
|
||||
width: calc(100% - var(--indent-depth) * 32px - var(--row-gap-thickness));
|
||||
height: calc(100% - var(--row-gap-thickness));
|
||||
width: calc(100% - var(--indent-depth) * 32px);
|
||||
height: 100%;
|
||||
border-radius: var(--border-radius-base);
|
||||
z-index: -1;
|
||||
|
||||
.selected &,
|
||||
.container:hover & {
|
||||
.selected & {
|
||||
background-color: var(--color-foreground-base);
|
||||
}
|
||||
|
||||
.container:hover:not(.selected) & {
|
||||
background-color: var(--color-background-light-base);
|
||||
}
|
||||
|
||||
.selected:not(:hover).error & {
|
||||
background-color: var(--color-danger-tint-2);
|
||||
}
|
||||
@@ -288,24 +288,12 @@ watch(
|
||||
margin-right: var(--spacing-4xs);
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.compact & {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.compact:hover & {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.compact:not(:hover) & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.startedAt {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
width: 30%;
|
||||
width: 25%;
|
||||
|
||||
.compact & {
|
||||
display: none;
|
||||
@@ -315,21 +303,8 @@ watch(
|
||||
.consumedTokens {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
width: 10%;
|
||||
width: 15%;
|
||||
text-align: right;
|
||||
|
||||
.compact & {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.compact:hover & {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.compact &:empty,
|
||||
.compact:not(:hover) & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.compactErrorIcon {
|
||||
|
||||
@@ -24,7 +24,7 @@ const emit = defineEmits<{ click: [] }>();
|
||||
font-size: var(--font-size-2xs);
|
||||
text-align: left;
|
||||
padding-inline-start: var(--spacing-s);
|
||||
padding-inline-end: var(--spacing-xs);
|
||||
padding-inline-end: var(--spacing-2xs);
|
||||
padding-block: var(--spacing-2xs);
|
||||
background-color: var(--color-foreground-xlight);
|
||||
display: flex;
|
||||
|
||||
@@ -61,6 +61,7 @@ function handleClickOpenNdv() {
|
||||
:compact="true"
|
||||
:disable-pin="true"
|
||||
:disable-edit="true"
|
||||
:disable-hover-highlight="true"
|
||||
table-header-bg-color="light"
|
||||
>
|
||||
<template #header>
|
||||
|
||||
@@ -35,14 +35,14 @@ export function useLayout(
|
||||
|
||||
const chatPanelResizer = useResizablePanel(LOCAL_STORAGE_PANEL_WIDTH, {
|
||||
container,
|
||||
defaultSize: (size) => size * 0.3,
|
||||
minSize: 300,
|
||||
defaultSize: (size) => Math.min(800, size * 0.3),
|
||||
minSize: 240,
|
||||
maxSize: (size) => size * 0.8,
|
||||
});
|
||||
|
||||
const overviewPanelResizer = useResizablePanel(LOCAL_STORAGE_OVERVIEW_PANEL_WIDTH, {
|
||||
container: logsContainer,
|
||||
defaultSize: (size) => size * 0.3,
|
||||
defaultSize: (size) => Math.min(240, size * 0.2),
|
||||
minSize: 80,
|
||||
maxSize: 500,
|
||||
allowFullSize: true,
|
||||
@@ -103,15 +103,19 @@ export function useLayout(
|
||||
resizer.onResizeEnd();
|
||||
}
|
||||
|
||||
watch([panelState, resizer.size], ([state, height]) => {
|
||||
canvasStore.setPanelHeight(
|
||||
state === LOGS_PANEL_STATE.FLOATING
|
||||
? 0
|
||||
: state === LOGS_PANEL_STATE.ATTACHED
|
||||
? height
|
||||
: 32 /* collapsed panel height */,
|
||||
);
|
||||
});
|
||||
watch(
|
||||
[panelState, resizer.size],
|
||||
([state, height]) => {
|
||||
canvasStore.setPanelHeight(
|
||||
state === LOGS_PANEL_STATE.FLOATING
|
||||
? 0
|
||||
: state === LOGS_PANEL_STATE.ATTACHED
|
||||
? height
|
||||
: 32 /* collapsed panel height */,
|
||||
);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return {
|
||||
height: resizer.size,
|
||||
|
||||
@@ -56,13 +56,21 @@ describe(useResizablePanel, () => {
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
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', () => {
|
||||
const { size, onResize } = useResizablePanel(localStorageKey, { container, defaultSize: 444 });
|
||||
|
||||
|
||||
@@ -9,8 +9,17 @@ interface UseResizerV2Options {
|
||||
* Container element, to which relative size is calculated (doesn't necessarily have to be DOM parent node)
|
||||
*/
|
||||
container: MaybeRef<HTMLElement | null>;
|
||||
/**
|
||||
* Default size in pixels
|
||||
*/
|
||||
defaultSize: GetSize;
|
||||
/**
|
||||
* Minimum size in pixels
|
||||
*/
|
||||
minSize?: GetSize;
|
||||
/**
|
||||
* Maximum size in pixels
|
||||
*/
|
||||
maxSize?: GetSize;
|
||||
/**
|
||||
* Which end of the container the resizable element itself is located
|
||||
@@ -46,31 +55,36 @@ export function useResizablePanel(
|
||||
}: UseResizerV2Options,
|
||||
) {
|
||||
const containerSize = ref(0);
|
||||
const size = useLocalStorage(localStorageKey, -1, { writeDefaults: false });
|
||||
const persistedSize = useLocalStorage(localStorageKey, -1, { writeDefaults: 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(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
if (isResizing.value && allowFullSize && size.value > containerSize.value - 30) {
|
||||
if (isResizing.value && allowFullSize && sizeInPixels > containerSize.value - 30) {
|
||||
return 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;
|
||||
}
|
||||
|
||||
const minSizeValue = resolveSize(minSize, containerSize.value);
|
||||
const maxSizeValue = resolveSize(maxSize, containerSize.value);
|
||||
|
||||
return Math.max(
|
||||
minSizeValue,
|
||||
minSizeValue.value,
|
||||
Math.min(
|
||||
snap && Math.abs(defaultSizeValue - size.value) < 30 ? defaultSizeValue : size.value,
|
||||
maxSizeValue,
|
||||
snap && Math.abs(defaultSizeValue - sizeInPixels) < 30 ? defaultSizeValue : sizeInPixels,
|
||||
maxSizeValue.value,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -93,17 +107,31 @@ export function useResizablePanel(
|
||||
|
||||
function onResize(data: ResizeData) {
|
||||
const containerRect = unref(container)?.getBoundingClientRect();
|
||||
|
||||
isResizing.value = true;
|
||||
size.value = Math.max(
|
||||
const newSizeInPixels = Math.max(
|
||||
0,
|
||||
position === 'bottom'
|
||||
? (containerRect ? getSize(containerRect) : 0) - getValue(data)
|
||||
: getValue(data) - (containerRect ? getValue(containerRect) : 0),
|
||||
);
|
||||
|
||||
isResizing.value = true;
|
||||
persistedSize.value = newSizeInPixels / containerSize.value;
|
||||
|
||||
if (sizeOnResizeStart.value === undefined) {
|
||||
sizeOnResizeStart.value = persistedSize.value;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -127,15 +155,6 @@ export function useResizablePanel(
|
||||
{ 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 {
|
||||
isResizing: computed(() => isResizing.value),
|
||||
isCollapsed: computed(() => isResizing.value && constrainedSize.value <= 0),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { type ContextMenuAction, useContextMenu } from '@/composables/useContextMenu';
|
||||
import { useStyles } from '@/composables/useStyles';
|
||||
import { N8nActionDropdown } from '@n8n/design-system';
|
||||
import { watch, ref } from 'vue';
|
||||
|
||||
@@ -7,6 +8,7 @@ const contextMenu = useContextMenu();
|
||||
const { position, isOpen, actions, target } = contextMenu;
|
||||
const dropdown = ref<InstanceType<typeof N8nActionDropdown>>();
|
||||
const emit = defineEmits<{ action: [action: ContextMenuAction, nodeIds: string[]] }>();
|
||||
const { APP_Z_INDEXES } = useStyles();
|
||||
|
||||
watch(
|
||||
isOpen,
|
||||
@@ -40,6 +42,7 @@ function onVisibleChange(open: boolean) {
|
||||
:style="{
|
||||
left: `${position[0]}px`,
|
||||
top: `${position[1]}px`,
|
||||
zIndex: APP_Z_INDEXES.CONTEXT_MENU,
|
||||
}"
|
||||
>
|
||||
<N8nActionDropdown
|
||||
|
||||
@@ -93,6 +93,7 @@ import { usePostHog } from '@/stores/posthog.store';
|
||||
import ViewSubExecution from './ViewSubExecution.vue';
|
||||
import RunDataItemCount from '@/components/RunDataItemCount.vue';
|
||||
import RunDataDisplayModeSelect from '@/components/RunDataDisplayModeSelect.vue';
|
||||
import RunDataPaginationBar from '@/components/RunDataPaginationBar.vue';
|
||||
|
||||
const LazyRunDataTable = defineAsyncComponent(
|
||||
async () => await import('@/components/RunDataTable.vue'),
|
||||
@@ -141,6 +142,7 @@ type Props = {
|
||||
disablePin?: boolean;
|
||||
compact?: boolean;
|
||||
tableHeaderBgColor?: 'base' | 'light';
|
||||
disableHoverHighlight?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -158,6 +160,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
disableRunIndexSelection: false,
|
||||
disableEdit: false,
|
||||
disablePin: false,
|
||||
disableHoverHighlight: false,
|
||||
compact: false,
|
||||
tableHeaderBgColor: 'base',
|
||||
});
|
||||
@@ -203,7 +206,6 @@ const binaryDataDisplayVisible = ref(false);
|
||||
const binaryDataDisplayData = ref<IBinaryData | null>(null);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const pageSizes = [1, 10, 25, 50, 100];
|
||||
|
||||
const pinDataDiscoveryTooltipVisible = ref(false);
|
||||
const isControlledPinDataTooltip = ref(false);
|
||||
@@ -605,7 +607,6 @@ const itemsCountProps = computed<InstanceType<typeof RunDataItemCount>['$props']
|
||||
dataCount: dataCount.value,
|
||||
unfilteredDataCount: unfilteredDataCount.value,
|
||||
subExecutionsCount: activeTaskMetadata.value?.subExecutionsCount,
|
||||
muted: props.compact || (props.paneType === 'input' && maxRunIndex.value === 0),
|
||||
}));
|
||||
|
||||
watch(node, (newNode, prevNode) => {
|
||||
@@ -1369,7 +1370,9 @@ defineExpose({ enterEditMode });
|
||||
/>
|
||||
|
||||
<div :class="$style.header">
|
||||
<slot name="header"></slot>
|
||||
<div :class="$style.title">
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="!hasRunError && !isTrimmedManualExecutionDataItem"
|
||||
@@ -1776,6 +1779,8 @@ defineExpose({ enterEditMode });
|
||||
:has-default-hover-state="paneType === 'input' && !search"
|
||||
:search="search"
|
||||
:header-bg-color="tableHeaderBgColor"
|
||||
:compact="props.compact"
|
||||
:disable-hover-highlight="props.disableHoverHighlight"
|
||||
@mounted="emit('tableMounted', $event)"
|
||||
@active-row-changed="onItemHover"
|
||||
@display-mode-change="onDisplayModeChange"
|
||||
@@ -1795,6 +1800,7 @@ defineExpose({ enterEditMode });
|
||||
:output-index="currentOutputIndex"
|
||||
:total-runs="maxRunIndex"
|
||||
:search="search"
|
||||
:compact="props.compact"
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -1815,6 +1821,7 @@ defineExpose({ enterEditMode });
|
||||
:total-runs="maxRunIndex"
|
||||
:search="search"
|
||||
:class="$style.schema"
|
||||
:compact="props.compact"
|
||||
@clear:search="onSearchClear"
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -1908,7 +1915,7 @@ defineExpose({ enterEditMode });
|
||||
<slot name="node-not-run"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
<RunDataPaginationBar
|
||||
v-if="
|
||||
hidePagination === false &&
|
||||
hasNodeRun &&
|
||||
@@ -1919,41 +1926,17 @@ defineExpose({ enterEditMode });
|
||||
!isArtificialRecoveredEventItem
|
||||
"
|
||||
v-show="!editMode.enabled"
|
||||
: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="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>
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="dataCount"
|
||||
@update:current-page="onCurrentPageChange"
|
||||
@update:page-size="onPageSizeChange"
|
||||
/>
|
||||
<N8nBlockUi :show="blockUI" :class="$style.uiBlocker" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@import '@/styles/variables';
|
||||
|
||||
.infoIcon {
|
||||
color: var(--color-foreground-dark);
|
||||
}
|
||||
@@ -1999,10 +1982,15 @@ defineExpose({ enterEditMode });
|
||||
overflow-y: hidden;
|
||||
min-height: calc(30px + var(--spacing-s));
|
||||
scrollbar-width: thin;
|
||||
container-type: inline-size;
|
||||
|
||||
.compact & {
|
||||
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 {
|
||||
@@ -2014,12 +2002,6 @@ defineExpose({ enterEditMode });
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
|
||||
&:hover {
|
||||
.actions-group {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dataDisplay {
|
||||
@@ -2032,6 +2014,10 @@ defineExpose({ enterEditMode });
|
||||
line-height: var(--font-line-height-xloose);
|
||||
word-break: normal;
|
||||
height: 100%;
|
||||
|
||||
.compact & {
|
||||
padding: 0 var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.inlineError {
|
||||
@@ -2102,22 +2088,6 @@ defineExpose({ enterEditMode });
|
||||
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 {
|
||||
display: block;
|
||||
padding: var(--spacing-2xs);
|
||||
@@ -2185,6 +2155,11 @@ defineExpose({ enterEditMode });
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
gap: var(--spacing-2xs);
|
||||
|
||||
.compact & {
|
||||
/* let title text alone decide the height */
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltipContain {
|
||||
@@ -2255,6 +2230,10 @@ defineExpose({ enterEditMode });
|
||||
margin-bottom: var(--spacing-xs);
|
||||
margin-left: var(--spacing-s);
|
||||
margin-right: var(--spacing-s);
|
||||
|
||||
.compact & {
|
||||
margin: 0 var(--spacing-2xs) var(--spacing-2xs) var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.schema {
|
||||
@@ -2263,15 +2242,21 @@ defineExpose({ enterEditMode });
|
||||
|
||||
.search,
|
||||
.displayModeSelect {
|
||||
.compact & {
|
||||
.compact:not(:hover) & {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s $ease-out-expo;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.compact:hover & {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 240px) {
|
||||
/* Hide title when the panel is too narrow */
|
||||
.compact:hover .title {
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -39,6 +39,7 @@ const options = computed(() => {
|
||||
:model-value="value"
|
||||
:options="options"
|
||||
data-test-id="ndv-run-data-display-mode"
|
||||
:size="compact ? 'small' : 'medium'"
|
||||
@update:model-value="(selected) => emit('change', selected)"
|
||||
>
|
||||
<template v-if="compact" #option="option">
|
||||
@@ -64,6 +65,6 @@ const options = computed(() => {
|
||||
|
||||
<style lang="scss" module>
|
||||
.icon {
|
||||
padding-inline: var(--spacing-2xs);
|
||||
padding-inline: var(--spacing-4xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,20 +7,18 @@ const {
|
||||
unfilteredDataCount,
|
||||
subExecutionsCount = 0,
|
||||
search,
|
||||
muted,
|
||||
} = defineProps<{
|
||||
dataCount: number;
|
||||
unfilteredDataCount: number;
|
||||
subExecutionsCount?: number;
|
||||
search: string;
|
||||
muted: boolean;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nText v-if="search" :class="[$style.itemsText, muted ? $style.muted : '']">
|
||||
<N8nText v-if="search" :class="$style.itemsText">
|
||||
{{
|
||||
i18n.baseText('ndv.search.items', {
|
||||
adjustToNumber: unfilteredDataCount,
|
||||
@@ -28,7 +26,7 @@ const i18n = useI18n();
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
<N8nText v-else :class="[$style.itemsText, muted ? $style.muted : '']">
|
||||
<N8nText v-else :class="$style.itemsText">
|
||||
<span>
|
||||
{{
|
||||
i18n.baseText('ndv.output.items', {
|
||||
@@ -54,9 +52,6 @@ const i18n = useI18n();
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-light);
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ const props = withDefaults(
|
||||
runIndex: number | undefined;
|
||||
totalRuns: number | undefined;
|
||||
search: string | undefined;
|
||||
compact?: boolean;
|
||||
}>(),
|
||||
{
|
||||
editMode: () => ({}),
|
||||
@@ -123,7 +124,13 @@ const getListItemName = (path: string) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="jsonDataContainer" :class="[$style.jsonDisplay, { [$style.highlight]: highlight }]">
|
||||
<div
|
||||
ref="jsonDataContainer"
|
||||
:class="[
|
||||
$style.jsonDisplay,
|
||||
{ [$style.highlight]: highlight, [$style.compact]: props.compact },
|
||||
]"
|
||||
>
|
||||
<Suspense>
|
||||
<LazyRunDataJsonActions
|
||||
v-if="!editMode.enabled"
|
||||
@@ -238,6 +245,10 @@ const getListItemName = (path: string) => {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.compact {
|
||||
padding-left: var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -245,6 +256,7 @@ const getListItemName = (path: string) => {
|
||||
.vjs-tree {
|
||||
color: var(--color-json-default);
|
||||
--color-line-break: var(--color-code-line-break);
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
|
||||
.vjs-tree-node {
|
||||
|
||||
@@ -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>
|
||||
@@ -33,6 +33,8 @@ type Props = {
|
||||
hasDefaultHoverState?: boolean;
|
||||
search?: string;
|
||||
headerBgColor?: 'base' | 'light';
|
||||
compact?: boolean;
|
||||
disableHoverHighlight?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -43,6 +45,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
hasDefaultHoverState: false,
|
||||
search: '',
|
||||
headerBgColor: 'base',
|
||||
disableHoverHighlight: false,
|
||||
compact: false,
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
activeRowChanged: [row: number | null];
|
||||
@@ -91,6 +95,10 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
function isHoveringRow(row: number): boolean {
|
||||
if (props.disableHoverHighlight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (row === activeRow.value) {
|
||||
return true;
|
||||
}
|
||||
@@ -427,7 +435,11 @@ watch(focusedMappableInput, (curr) => {
|
||||
<div
|
||||
:class="[
|
||||
$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">
|
||||
@@ -681,6 +693,10 @@ watch(focusedMappableInput, (curr) => {
|
||||
word-break: normal;
|
||||
height: 100%;
|
||||
padding-bottom: var(--spacing-3xl);
|
||||
|
||||
&.compact {
|
||||
padding-left: var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
@@ -848,6 +864,12 @@ watch(focusedMappableInput, (curr) => {
|
||||
border-right: none !important;
|
||||
border-top: none !important;
|
||||
border-bottom: none !important;
|
||||
|
||||
.compact & {
|
||||
padding: 0;
|
||||
min-width: var(--spacing-2xs);
|
||||
max-width: var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.hoveringRow {
|
||||
|
||||
@@ -62,6 +62,7 @@ type Props = {
|
||||
paneType: 'input' | 'output';
|
||||
connectionType?: NodeConnectionType;
|
||||
search?: string;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -75,6 +76,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
connectionType: NodeConnectionTypes.Main,
|
||||
search: '',
|
||||
mappingEnabled: false,
|
||||
compact: false,
|
||||
});
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
@@ -377,7 +379,7 @@ const onDragEnd = (el: HTMLElement) => {
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<N8nText tag="h3" size="large">{{ i18n.baseText('ndv.search.noNodeMatch.title') }}</N8nText>
|
||||
<N8nText>
|
||||
@@ -491,6 +493,10 @@ const onDragEnd = (el: HTMLElement) => {
|
||||
.scroller {
|
||||
padding: 0 var(--spacing-s);
|
||||
padding-bottom: var(--spacing-2xl);
|
||||
|
||||
.compact & {
|
||||
padding: 0 var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
|
||||
@@ -13,55 +13,60 @@ exports[`InputPanel > should render 1`] = `
|
||||
class="header"
|
||||
data-v-2e5cd75c=""
|
||||
>
|
||||
|
||||
<div
|
||||
class="titleSection"
|
||||
class="title"
|
||||
data-v-2e5cd75c=""
|
||||
>
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Input
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="n8n-radio-buttons radioGroup"
|
||||
data-test-id="input-panel-mode"
|
||||
role="radiogroup"
|
||||
class="titleSection"
|
||||
>
|
||||
|
||||
<label
|
||||
aria-checked="true"
|
||||
class="n8n-radio-button container hoverable"
|
||||
role="radio"
|
||||
tabindex="-1"
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
<div
|
||||
class="button active medium"
|
||||
data-test-id="radio-button-mapping"
|
||||
>
|
||||
|
||||
Mapping
|
||||
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
aria-checked="false"
|
||||
class="n8n-radio-button container hoverable"
|
||||
role="radio"
|
||||
tabindex="-1"
|
||||
Input
|
||||
</span>
|
||||
<div
|
||||
class="n8n-radio-buttons radioGroup"
|
||||
data-test-id="input-panel-mode"
|
||||
role="radiogroup"
|
||||
>
|
||||
<div
|
||||
class="button medium"
|
||||
data-test-id="radio-button-debugging"
|
||||
|
||||
<label
|
||||
aria-checked="true"
|
||||
class="n8n-radio-button container hoverable"
|
||||
role="radio"
|
||||
tabindex="-1"
|
||||
>
|
||||
|
||||
Debugging
|
||||
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="button active medium"
|
||||
data-test-id="radio-button-mapping"
|
||||
>
|
||||
|
||||
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
|
||||
class="displayModes"
|
||||
data-test-id="run-data-pane-header"
|
||||
|
||||
@@ -50,6 +50,7 @@ import CanvasBackground from './elements/background/CanvasBackground.vue';
|
||||
import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue';
|
||||
import Edge from './elements/edges/CanvasEdge.vue';
|
||||
import Node from './elements/nodes/CanvasNode.vue';
|
||||
import { useViewportAutoAdjust } from '@/components/canvas/composables/useViewportAutoAdjust';
|
||||
|
||||
const $style = useCssModule();
|
||||
|
||||
@@ -141,6 +142,7 @@ const {
|
||||
findNode,
|
||||
viewport,
|
||||
nodesSelectionActive,
|
||||
setViewport,
|
||||
onEdgeMouseLeave,
|
||||
onEdgeMouseEnter,
|
||||
onEdgeMouseMove,
|
||||
@@ -536,6 +538,8 @@ function emitWithLastSelectedNode(emitFn: (id: string) => void) {
|
||||
const defaultZoom = 1;
|
||||
const isPaneMoving = ref(false);
|
||||
|
||||
useViewportAutoAdjust(viewportRef, viewport, setViewport);
|
||||
|
||||
function getProjectedPosition(event?: Pick<MouseEvent, 'clientX' | 'clientY'>) {
|
||||
const bounds = viewportRef.value?.getBoundingClientRect() ?? { left: 0, top: 0 };
|
||||
const offsetX = event?.clientX ?? 0;
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ describe('useStyles', () => {
|
||||
setAppZIndexes();
|
||||
|
||||
expect(global.document.documentElement.style.setProperty).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
2,
|
||||
'--z-index-app-header',
|
||||
'99',
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const APP_Z_INDEXES = {
|
||||
CONTEXT_MENU: 10, // should be still in front of the logs panel
|
||||
APP_HEADER: 99,
|
||||
SELECT_BOX: 100,
|
||||
CANVAS_ADD_BUTTON: 101,
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { ApplicationError } from 'n8n-workflow';
|
||||
import { useStyles } from './useStyles';
|
||||
import { useCanvasStore } from '@/stores/canvas.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 {
|
||||
node: {
|
||||
@@ -32,26 +32,22 @@ export function useToast() {
|
||||
const settingsStore = useSettingsStore();
|
||||
const { APP_Z_INDEXES } = useStyles();
|
||||
const canvasStore = useCanvasStore();
|
||||
|
||||
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',
|
||||
};
|
||||
const ndvStore = useNDVStore();
|
||||
|
||||
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 params = { ...messageDefaults, ...messageData };
|
||||
|
||||
params.offset = +canvasStore.panelHeight;
|
||||
|
||||
if (typeof message === 'string') {
|
||||
params.message = sanitizeHtml(message);
|
||||
}
|
||||
|
||||
@@ -1279,8 +1279,12 @@ const chatTriggerNodePinnedData = computed(() => {
|
||||
return workflowsStore.pinDataByNodeName(chatTriggerNode.value.name);
|
||||
});
|
||||
|
||||
async function onOpenChat(isOpen?: boolean) {
|
||||
await toggleChatOpen('main', isOpen);
|
||||
async function onToggleChat() {
|
||||
await toggleChatOpen('main');
|
||||
}
|
||||
|
||||
async function onOpenChat() {
|
||||
await toggleChatOpen('main', true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1787,7 +1791,7 @@ onBeforeUnmount(() => {
|
||||
v-if="containsChatTriggerNodes"
|
||||
:type="isLogsPanelOpen ? 'tertiary' : 'primary'"
|
||||
:label="isLogsPanelOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open')"
|
||||
@click="onOpenChat(!isLogsPanelOpen)"
|
||||
@click="onToggleChat"
|
||||
/>
|
||||
<CanvasStopCurrentExecutionButton
|
||||
v-if="isStopExecutionButtonVisible"
|
||||
|
||||
Reference in New Issue
Block a user