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();
messages.value = [];
currentSessionId.value = uuid().replace(/-/g, '');
if (logsPanelState.value !== LOGS_PANEL_STATE.CLOSED) {
chatEventBus.emit('focusInput');
}
}
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.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();
});

View File

@@ -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>

View File

@@ -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 }),

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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>

View File

@@ -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,

View File

@@ -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 });

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: 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),

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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 {

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;
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 {

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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;

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();
expect(global.document.documentElement.style.setProperty).toHaveBeenNthCalledWith(
1,
2,
'--z-index-app-header',
'99',
);

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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"