mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Log details panel (#14409)
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
@@ -157,6 +157,7 @@ async function copySessionId() {
|
||||
data-test-id="chat-session-id"
|
||||
type="secondary"
|
||||
size="mini"
|
||||
:class="$style.newHeaderButton"
|
||||
@click.stop="copySessionId"
|
||||
>{{ sessionIdText }}</N8nButton
|
||||
>
|
||||
@@ -166,7 +167,7 @@ async function copySessionId() {
|
||||
:content="locale.baseText('chat.window.session.resetSession')"
|
||||
>
|
||||
<N8nIconButton
|
||||
:class="$style.headerButton"
|
||||
:class="$style.newHeaderButton"
|
||||
data-test-id="refresh-session-button"
|
||||
outline
|
||||
type="secondary"
|
||||
@@ -310,6 +311,7 @@ async function copySessionId() {
|
||||
overflow: hidden;
|
||||
background-color: var(--color-background-light);
|
||||
}
|
||||
|
||||
.chatHeader {
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: var(--font-weight-regular);
|
||||
@@ -322,9 +324,11 @@ async function copySessionId() {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chatTitle {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.session {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -332,6 +336,7 @@ async function copySessionId() {
|
||||
color: var(--color-text-base);
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.sessionId {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
@@ -342,10 +347,17 @@ async function copySessionId() {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.headerButton {
|
||||
max-height: 1.1rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.newHeaderButton {
|
||||
border: none;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.chatBody {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
@@ -30,7 +30,7 @@ interface ChatState {
|
||||
displayExecution: (executionId: string) => void;
|
||||
}
|
||||
|
||||
export function useChatState(isReadOnly: boolean, onWindowResize: () => void): ChatState {
|
||||
export function useChatState(isReadOnly: boolean, onWindowResize?: () => void): ChatState {
|
||||
const locale = useI18n();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
@@ -141,7 +141,7 @@ export function useChatState(isReadOnly: boolean, onWindowResize: () => void): C
|
||||
setConnectedNode();
|
||||
|
||||
setTimeout(() => {
|
||||
onWindowResize();
|
||||
onWindowResize?.();
|
||||
chatEventBus.emit('focusInput');
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
interface UsePiPWindowOptions {
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
container: Readonly<ShallowRef<HTMLDivElement | null>>;
|
||||
content: Readonly<ShallowRef<HTMLDivElement | null>>;
|
||||
container: Readonly<ShallowRef<HTMLElement | null>>;
|
||||
content: Readonly<ShallowRef<HTMLElement | null>>;
|
||||
shouldPopOut: ComputedRef<boolean>;
|
||||
onRequestClose: () => void;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import type { IChatResizeStyles } from '../types/chat';
|
||||
import { useStorage } from '@/composables/useStorage';
|
||||
import { type ResizeData } from '@n8n/design-system';
|
||||
|
||||
const LOCAL_STORAGE_PANEL_HEIGHT = 'N8N_CANVAS_CHAT_HEIGHT';
|
||||
const LOCAL_STORAGE_PANEL_WIDTH = 'N8N_CANVAS_CHAT_WIDTH';
|
||||
export const LOCAL_STORAGE_PANEL_HEIGHT = 'N8N_CANVAS_CHAT_HEIGHT';
|
||||
export const LOCAL_STORAGE_PANEL_WIDTH = 'N8N_CANVAS_CHAT_WIDTH';
|
||||
export const LOCAL_STORAGE_OVERVIEW_PANEL_WIDTH = 'N8N_LOGS_OVERVIEW_PANEL_WIDTH';
|
||||
|
||||
// Percentage of container width for chat panel constraints
|
||||
const MAX_WIDTH_PERCENTAGE = 0.8;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { renderComponent } from '@/__tests__/render';
|
||||
import { fireEvent, within } from '@testing-library/vue';
|
||||
import { fireEvent, waitFor, within } from '@testing-library/vue';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import LogsPanel from '@/components/CanvasChat/future/LogsPanel.vue';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
@@ -15,8 +15,11 @@ import {
|
||||
nodeTypes,
|
||||
} from '../__test__/data';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { LOGS_PANEL_STATE } from '../types/logs';
|
||||
|
||||
describe('LogsPanel', () => {
|
||||
const VIEWPORT_HEIGHT = 800;
|
||||
|
||||
let pinia: TestingPinia;
|
||||
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||
@@ -50,6 +53,17 @@ describe('LogsPanel', () => {
|
||||
|
||||
nodeTypeStore = mockedStore(useNodeTypesStore);
|
||||
nodeTypeStore.setNodeTypes(nodeTypes);
|
||||
|
||||
Object.defineProperty(document.body, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return VIEWPORT_HEIGHT;
|
||||
},
|
||||
});
|
||||
vi.spyOn(document.body, 'getBoundingClientRect').mockReturnValue({
|
||||
y: 0,
|
||||
height: VIEWPORT_HEIGHT,
|
||||
} as DOMRect);
|
||||
});
|
||||
|
||||
it('should render collapsed panel by default', async () => {
|
||||
@@ -112,7 +126,9 @@ describe('LogsPanel', () => {
|
||||
expect(rendered.getByTestId('log-details')).toBeInTheDocument();
|
||||
|
||||
// Click again to close the panel
|
||||
await fireEvent.click(await rendered.findByText('AI Agent'));
|
||||
await fireEvent.click(
|
||||
await within(rendered.getByTestId('logs-overview-body')).findByText('AI Agent'),
|
||||
);
|
||||
expect(rendered.queryByTestId('log-details')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -130,11 +146,53 @@ describe('LogsPanel', () => {
|
||||
// Click the toggle button to close the panel
|
||||
await fireEvent.click(within(detailsPanel).getByLabelText('Collapse panel'));
|
||||
expect(rendered.queryByTestId('chat-messages-empty')).not.toBeInTheDocument();
|
||||
expect(rendered.queryByText('AI Agent', { exact: false })).not.toBeInTheDocument();
|
||||
expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument();
|
||||
|
||||
// Click again to open the panel
|
||||
await fireEvent.click(within(detailsPanel).getByLabelText('Open panel'));
|
||||
expect(await rendered.findByTestId('chat-messages-empty')).toBeInTheDocument();
|
||||
expect(await rendered.findByText('AI Agent', { exact: false })).toBeInTheDocument();
|
||||
expect(await rendered.findByTestId('logs-overview-body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open itself by pulling up the resizer', async () => {
|
||||
workflowsStore.toggleLogsPanelOpen(false);
|
||||
|
||||
const rendered = render();
|
||||
|
||||
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.CLOSED);
|
||||
expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
|
||||
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 0, clientY: 0 }));
|
||||
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 0, clientY: 0 }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.ATTACHED);
|
||||
expect(rendered.queryByTestId('logs-overview-body')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should close itself by pulling down the resizer', async () => {
|
||||
workflowsStore.toggleLogsPanelOpen(true);
|
||||
|
||||
const rendered = render();
|
||||
|
||||
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.ATTACHED);
|
||||
expect(rendered.queryByTestId('logs-overview-body')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
|
||||
|
||||
window.dispatchEvent(
|
||||
new MouseEvent('mousemove', { bubbles: true, clientX: 0, clientY: VIEWPORT_HEIGHT }),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MouseEvent('mouseup', { bubbles: true, clientX: 0, clientY: VIEWPORT_HEIGHT }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.CLOSED);
|
||||
expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue';
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
import { N8nResizeWrapper } from '@n8n/design-system';
|
||||
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
|
||||
import { useResize } from '@/components/CanvasChat/composables/useResize';
|
||||
import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import LogsOverviewPanel from '@/components/CanvasChat/future/components/LogsOverviewPanel.vue';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import ChatMessagesPanel from '@/components/CanvasChat/components/ChatMessagesPanel.vue';
|
||||
import LogsDetailsPanel from '@/components/CanvasChat/future/components/LogDetailsPanel.vue';
|
||||
import { LOGS_PANEL_STATE, type LogEntrySelection } from '@/components/CanvasChat/types/logs';
|
||||
import { type LogEntrySelection } from '@/components/CanvasChat/types/logs';
|
||||
import LogsPanelActions from '@/components/CanvasChat/future/components/LogsPanelActions.vue';
|
||||
import {
|
||||
createLogEntries,
|
||||
@@ -18,24 +14,39 @@ import {
|
||||
type TreeNode,
|
||||
} from '@/components/RunDataAi/utils';
|
||||
import { isChatNode } from '@/components/CanvasChat/utils';
|
||||
import { useLayout } from '@/components/CanvasChat/future/composables/useLayout';
|
||||
|
||||
const props = withDefaults(defineProps<{ isReadOnly?: boolean }>(), { isReadOnly: false });
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const canvasStore = useCanvasStore();
|
||||
const panelState = computed(() => workflowsStore.logsPanelState);
|
||||
const container = ref<HTMLElement>();
|
||||
const container = useTemplateRef('container');
|
||||
const logsContainer = useTemplateRef('logsContainer');
|
||||
const pipContainer = useTemplateRef('pipContainer');
|
||||
const pipContent = useTemplateRef('pipContent');
|
||||
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const { rootStyles, height, chatWidth, onWindowResize, onResizeDebounced, onResizeChatDebounced } =
|
||||
useResize(container);
|
||||
const {
|
||||
height,
|
||||
chatPanelWidth,
|
||||
overviewPanelWidth,
|
||||
canPopOut,
|
||||
isOpen,
|
||||
isPoppedOut,
|
||||
isCollapsingDetailsPanel,
|
||||
isOverviewPanelFullWidth,
|
||||
pipWindow,
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
onToggleOpen,
|
||||
onPopOut,
|
||||
onChatPanelResize,
|
||||
onChatPanelResizeEnd,
|
||||
onOverviewPanelResize,
|
||||
onOverviewPanelResizeEnd,
|
||||
} = useLayout(pipContainer, pipContent, container, logsContainer);
|
||||
|
||||
const { currentSessionId, messages, sendMessage, refreshSession, displayExecution } = useChatState(
|
||||
props.isReadOnly,
|
||||
onWindowResize,
|
||||
);
|
||||
|
||||
const hasChat = computed(
|
||||
@@ -66,45 +77,18 @@ const selectedLogEntry = computed(() =>
|
||||
? undefined
|
||||
: manualLogEntrySelection.value.data,
|
||||
);
|
||||
const isLogDetailsOpen = computed(() => selectedLogEntry.value !== undefined);
|
||||
|
||||
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
|
||||
initialHeight: 400,
|
||||
initialWidth: window.document.body.offsetWidth * 0.8,
|
||||
container: pipContainer,
|
||||
content: pipContent,
|
||||
shouldPopOut: computed(() => panelState.value === LOGS_PANEL_STATE.FLOATING),
|
||||
onRequestClose: () => {
|
||||
if (panelState.value === LOGS_PANEL_STATE.CLOSED) {
|
||||
return;
|
||||
}
|
||||
|
||||
telemetry.track('User toggled log view', { new_state: 'attached' });
|
||||
workflowsStore.setPreferPoppedOutLogsView(false);
|
||||
},
|
||||
});
|
||||
const isLogDetailsOpen = computed(
|
||||
() => selectedLogEntry.value !== undefined && !isCollapsingDetailsPanel.value,
|
||||
);
|
||||
const isLogDetailsOpenOrCollapsing = computed(() => selectedLogEntry.value !== undefined);
|
||||
const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$props']>(() => ({
|
||||
panelState: panelState.value,
|
||||
isOpen: isOpen.value,
|
||||
showToggleButton: !isPoppedOut.value,
|
||||
showPopOutButton: canPopOut.value && !isPoppedOut.value,
|
||||
onPopOut,
|
||||
onToggleOpen,
|
||||
}));
|
||||
|
||||
function onToggleOpen() {
|
||||
workflowsStore.toggleLogsPanelOpen();
|
||||
|
||||
telemetry.track('User toggled log view', {
|
||||
new_state: panelState.value === LOGS_PANEL_STATE.CLOSED ? 'attached' : 'collapsed',
|
||||
});
|
||||
}
|
||||
|
||||
function handleClickHeader() {
|
||||
if (panelState.value === LOGS_PANEL_STATE.CLOSED) {
|
||||
telemetry.track('User toggled log view', { new_state: 'attached' });
|
||||
workflowsStore.toggleLogsPanelOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectLogEntry(selected: TreeNode | undefined) {
|
||||
manualLogEntrySelection.value =
|
||||
selected === undefined
|
||||
@@ -112,21 +96,13 @@ function handleSelectLogEntry(selected: TreeNode | undefined) {
|
||||
: { type: 'selected', workflowId: workflowsStore.workflow.id, data: selected };
|
||||
}
|
||||
|
||||
function onPopOut() {
|
||||
telemetry.track('User toggled log view', { new_state: 'floating' });
|
||||
workflowsStore.toggleLogsPanelOpen(true);
|
||||
workflowsStore.setPreferPoppedOutLogsView(true);
|
||||
}
|
||||
function handleResizeOverviewPanelEnd() {
|
||||
if (isOverviewPanelFullWidth.value) {
|
||||
handleSelectLogEntry(undefined);
|
||||
}
|
||||
|
||||
watch([panelState, height], ([state, h]) => {
|
||||
canvasStore.setPanelHeight(
|
||||
state === LOGS_PANEL_STATE.FLOATING
|
||||
? 0
|
||||
: state === LOGS_PANEL_STATE.ATTACHED
|
||||
? h
|
||||
: 32 /* collapsed panel height */,
|
||||
);
|
||||
});
|
||||
onOverviewPanelResizeEnd();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -135,24 +111,27 @@ watch([panelState, height], ([state, h]) => {
|
||||
<N8nResizeWrapper
|
||||
:height="height"
|
||||
:supported-directions="['top']"
|
||||
:is-resizing-enabled="panelState === LOGS_PANEL_STATE.ATTACHED"
|
||||
:style="rootStyles"
|
||||
:class="[$style.resizeWrapper, panelState === LOGS_PANEL_STATE.CLOSED ? '' : $style.isOpen]"
|
||||
@resize="onResizeDebounced"
|
||||
:is-resizing-enabled="!isPoppedOut"
|
||||
:class="$style.resizeWrapper"
|
||||
:style="{ height: isOpen ? `${height}px` : 'auto' }"
|
||||
@resize="onResize"
|
||||
@resizeend="onResizeEnd"
|
||||
>
|
||||
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
|
||||
<N8nResizeWrapper
|
||||
v-if="hasChat"
|
||||
:supported-directions="['right']"
|
||||
:is-resizing-enabled="panelState !== LOGS_PANEL_STATE.CLOSED"
|
||||
:width="chatWidth"
|
||||
:is-resizing-enabled="isOpen"
|
||||
:width="chatPanelWidth"
|
||||
:style="{ width: `${chatPanelWidth}px` }"
|
||||
:class="$style.chat"
|
||||
:window="pipWindow"
|
||||
@resize="onResizeChatDebounced"
|
||||
@resize="onChatPanelResize"
|
||||
@resizeend="onChatPanelResizeEnd"
|
||||
>
|
||||
<ChatMessagesPanel
|
||||
data-test-id="canvas-chat"
|
||||
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
|
||||
:is-open="isOpen"
|
||||
:is-read-only="isReadOnly"
|
||||
:messages="messages"
|
||||
:session-id="currentSessionId"
|
||||
@@ -163,32 +142,48 @@ watch([panelState, height], ([state, h]) => {
|
||||
@refresh-session="refreshSession"
|
||||
@display-execution="displayExecution"
|
||||
@send-message="sendMessage"
|
||||
@click-header="handleClickHeader"
|
||||
@click-header="onToggleOpen(true)"
|
||||
/>
|
||||
</N8nResizeWrapper>
|
||||
<LogsOverviewPanel
|
||||
:class="$style.logsOverview"
|
||||
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
|
||||
:is-read-only="isReadOnly"
|
||||
:selected="selectedLogEntry"
|
||||
:execution-tree="executionTree"
|
||||
@click-header="handleClickHeader"
|
||||
@select="handleSelectLogEntry"
|
||||
>
|
||||
<template #actions>
|
||||
<LogsPanelActions v-if="!isLogDetailsOpen" v-bind="logsPanelActionsProps" />
|
||||
</template>
|
||||
</LogsOverviewPanel>
|
||||
<LogsDetailsPanel
|
||||
v-if="selectedLogEntry !== undefined"
|
||||
:class="$style.logDetails"
|
||||
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
|
||||
@click-header="handleClickHeader"
|
||||
>
|
||||
<template #actions>
|
||||
<LogsPanelActions v-if="isLogDetailsOpen" v-bind="logsPanelActionsProps" />
|
||||
</template>
|
||||
</LogsDetailsPanel>
|
||||
<div ref="logsContainer" :class="$style.logsContainer">
|
||||
<N8nResizeWrapper
|
||||
:class="$style.overviewResizer"
|
||||
:width="overviewPanelWidth"
|
||||
:style="{ width: isLogDetailsOpen ? `${overviewPanelWidth}px` : '' }"
|
||||
:supported-directions="['right']"
|
||||
:is-resizing-enabled="isLogDetailsOpenOrCollapsing"
|
||||
:window="pipWindow"
|
||||
@resize="onOverviewPanelResize"
|
||||
@resizeend="handleResizeOverviewPanelEnd"
|
||||
>
|
||||
<LogsOverviewPanel
|
||||
:class="$style.logsOverview"
|
||||
:is-open="isOpen"
|
||||
:is-read-only="isReadOnly"
|
||||
:is-compact="isLogDetailsOpen"
|
||||
:selected="selectedLogEntry"
|
||||
:execution-tree="executionTree"
|
||||
@click-header="onToggleOpen(true)"
|
||||
@select="handleSelectLogEntry"
|
||||
>
|
||||
<template #actions>
|
||||
<LogsPanelActions v-if="!isLogDetailsOpen" v-bind="logsPanelActionsProps" />
|
||||
</template>
|
||||
</LogsOverviewPanel>
|
||||
</N8nResizeWrapper>
|
||||
<LogsDetailsPanel
|
||||
v-if="isLogDetailsOpenOrCollapsing && selectedLogEntry"
|
||||
:class="$style.logDetails"
|
||||
:is-open="isOpen"
|
||||
:log-entry="selectedLogEntry"
|
||||
:window="pipWindow"
|
||||
@click-header="onToggleOpen(true)"
|
||||
>
|
||||
<template #actions>
|
||||
<LogsPanelActions v-if="isLogDetailsOpen" v-bind="logsPanelActionsProps" />
|
||||
</template>
|
||||
</LogsDetailsPanel>
|
||||
</div>
|
||||
</div>
|
||||
</N8nResizeWrapper>
|
||||
</div>
|
||||
@@ -215,13 +210,6 @@ watch([panelState, height], ([state, h]) => {
|
||||
flex-basis: 0;
|
||||
border-top: var(--border-base);
|
||||
background-color: var(--color-background-light);
|
||||
|
||||
&.isOpen {
|
||||
height: var(--panel-height);
|
||||
min-height: 4rem;
|
||||
max-height: 90vh;
|
||||
flex-basis: content;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -235,21 +223,35 @@ watch([panelState, height], ([state, h]) => {
|
||||
}
|
||||
|
||||
.chat {
|
||||
width: var(--chat-width);
|
||||
flex-shrink: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.logsContainer {
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
& > *:not(:last-child) {
|
||||
border-right: var(--border-base);
|
||||
}
|
||||
}
|
||||
|
||||
.overviewResizer {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:last-child {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.logsOverview {
|
||||
flex-basis: 20%;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-width: 360px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.logDetails {
|
||||
flex-basis: 60%;
|
||||
.logsDetails {
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { type LlmTokenUsageData } from '@/Interface';
|
||||
import { N8nText } from '@n8n/design-system';
|
||||
import { upperFirst } from 'lodash-es';
|
||||
import { type ExecutionStatus } from 'n8n-workflow';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const { status, consumedTokens, timeTook } = defineProps<{
|
||||
status: ExecutionStatus;
|
||||
consumedTokens: LlmTokenUsageData;
|
||||
timeTook?: number;
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
const executionStatusText = computed(() =>
|
||||
timeTook === undefined
|
||||
? upperFirst(status)
|
||||
: locale.baseText('logs.overview.body.summaryText', {
|
||||
interpolate: {
|
||||
status: upperFirst(status),
|
||||
time: locale.displayTimer(timeTook, true),
|
||||
},
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nText tag="div" color="text-light" size="small" :class="$style.container">
|
||||
<span>{{ executionStatusText }}</span>
|
||||
<ConsumedTokenCountText
|
||||
v-if="consumedTokens.totalTokens > 0"
|
||||
:consumed-tokens="consumedTokens"
|
||||
/>
|
||||
</N8nText>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& > * {
|
||||
padding-inline: var(--spacing-2xs);
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
& > *:not(:last-child) {
|
||||
border-right: var(--border-base);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
import { fireEvent, waitFor, within } from '@testing-library/vue';
|
||||
import { renderComponent } from '@/__tests__/render';
|
||||
import LogDetailsPanel from './LogDetailsPanel.vue';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
|
||||
import { h } from 'vue';
|
||||
import {
|
||||
createTestLogEntry,
|
||||
createTestNode,
|
||||
createTestTaskData,
|
||||
createTestWorkflow,
|
||||
} from '@/__tests__/mocks';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { type FrontendSettings } from '@n8n/api-types';
|
||||
|
||||
describe('LogDetailsPanel', () => {
|
||||
let pinia: TestingPinia;
|
||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
||||
|
||||
function render(props: InstanceType<typeof LogDetailsPanel>['$props']) {
|
||||
const rendered = renderComponent(LogDetailsPanel, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [
|
||||
createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [{ path: '/', component: () => h('div') }],
|
||||
}),
|
||||
pinia,
|
||||
],
|
||||
},
|
||||
});
|
||||
const container = rendered.getByTestId('log-details');
|
||||
|
||||
Object.defineProperty(container, 'offsetWidth', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return 1000;
|
||||
},
|
||||
});
|
||||
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
width: 1000,
|
||||
} as DOMRect);
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createTestingPinia({ stubActions: false, fakeApp: true });
|
||||
|
||||
settingsStore = mockedStore(useSettingsStore);
|
||||
settingsStore.isEnterpriseFeatureEnabled = {} as FrontendSettings['enterprise'];
|
||||
|
||||
const workflowData = createTestWorkflow({
|
||||
nodes: [createTestNode({ name: 'Chat Trigger' }), createTestNode({ name: 'AI Agent' })],
|
||||
connections: { 'Chat Trigger': { main: [[{ node: 'AI Agent', type: 'main', index: 0 }]] } },
|
||||
});
|
||||
|
||||
workflowsStore = mockedStore(useWorkflowsStore);
|
||||
workflowsStore.setNodes(workflowData.nodes);
|
||||
workflowsStore.setConnections(workflowData.connections);
|
||||
workflowsStore.setWorkflowExecutionData({
|
||||
id: 'test-exec-id',
|
||||
finished: true,
|
||||
mode: 'manual',
|
||||
status: 'error',
|
||||
workflowData,
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {
|
||||
'Chat Trigger': [
|
||||
createTestTaskData({
|
||||
executionStatus: 'success',
|
||||
executionTime: 0,
|
||||
data: { main: [[{ json: { chatInput: 'hey' } }]] },
|
||||
}),
|
||||
],
|
||||
'AI Agent': [
|
||||
createTestTaskData({
|
||||
executionStatus: 'success',
|
||||
executionTime: 10,
|
||||
data: { main: [[{ json: { response: 'Hello!' } }]] },
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
createdAt: '2025-04-16T00:00:00.000Z',
|
||||
startedAt: '2025-04-16T00:00:01.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show name, run status, input, and output of the node', async () => {
|
||||
const rendered = render({
|
||||
isOpen: true,
|
||||
logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),
|
||||
});
|
||||
|
||||
const header = within(rendered.getByTestId('log-details-header'));
|
||||
const inputPanel = within(rendered.getByTestId('log-details-input'));
|
||||
const outputPanel = within(rendered.getByTestId('log-details-output'));
|
||||
|
||||
expect(header.getByText('AI Agent')).toBeInTheDocument();
|
||||
expect(header.getByText('Success in 10ms')).toBeInTheDocument();
|
||||
expect(await inputPanel.findByText('hey')).toBeInTheDocument();
|
||||
expect(await outputPanel.findByText('Hello!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should toggle input and output panel when the button is clicked', async () => {
|
||||
const rendered = render({
|
||||
isOpen: true,
|
||||
logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),
|
||||
});
|
||||
|
||||
const header = within(rendered.getByTestId('log-details-header'));
|
||||
|
||||
expect(rendered.queryByTestId('log-details-input')).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-output')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(header.getByText('Output'));
|
||||
|
||||
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
|
||||
expect(rendered.queryByTestId('log-details-output')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should close input panel by dragging the divider to the left end', async () => {
|
||||
const rendered = render({
|
||||
isOpen: true,
|
||||
logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),
|
||||
});
|
||||
|
||||
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
|
||||
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 0, clientY: 0 }));
|
||||
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 0, clientY: 0 }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rendered.queryByTestId('log-details-input')).not.toBeInTheDocument();
|
||||
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should close output panel by dragging the divider to the right end', async () => {
|
||||
const rendered = render({
|
||||
isOpen: true,
|
||||
logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),
|
||||
});
|
||||
|
||||
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
|
||||
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 1000, clientY: 0 }));
|
||||
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 1000, clientY: 0 }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
|
||||
expect(rendered.queryByTestId('log-details-output')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import ExecutionSummary from '@/components/CanvasChat/future/components/ExecutionSummary.vue';
|
||||
import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.vue';
|
||||
import RunDataView from '@/components/CanvasChat/future/components/RunDataView.vue';
|
||||
import { useResizablePanel } from '@/components/CanvasChat/future/composables/useResizablePanel';
|
||||
import { LOG_DETAILS_CONTENT, type LogDetailsContent } from '@/components/CanvasChat/types/logs';
|
||||
import NodeIcon from '@/components/NodeIcon.vue';
|
||||
import { getSubtreeTotalConsumedTokens, type TreeNode } from '@/components/RunDataAi/utils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
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 { type ITaskData } from 'n8n-workflow';
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
|
||||
const { isOpen } = defineProps<{ isOpen: boolean }>();
|
||||
const MIN_IO_PANEL_WIDTH = 200;
|
||||
|
||||
const { isOpen, logEntry, window } = defineProps<{
|
||||
isOpen: boolean;
|
||||
logEntry: TreeNode;
|
||||
window?: Window;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ clickHeader: [] }>();
|
||||
|
||||
defineSlots<{ actions: {} }>();
|
||||
|
||||
const locale = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeTypeStore = useNodeTypesStore();
|
||||
|
||||
const content = ref<LogDetailsContent>(LOG_DETAILS_CONTENT.BOTH);
|
||||
|
||||
const node = computed<INodeUi | undefined>(() => workflowsStore.nodesByName[logEntry.node]);
|
||||
const type = computed(() => (node.value ? nodeTypeStore.getNodeType(node.value.type) : undefined));
|
||||
const runData = computed<ITaskData | undefined>(
|
||||
() =>
|
||||
(workflowsStore.workflowExecutionData?.data?.resultData.runData[logEntry.node] ?? [])[
|
||||
logEntry.runIndex
|
||||
],
|
||||
);
|
||||
const consumedTokens = computed(() => getSubtreeTotalConsumedTokens(logEntry));
|
||||
const isTriggerNode = computed(() => type.value?.group.includes('trigger'));
|
||||
const container = useTemplateRef<HTMLElement>('container');
|
||||
const resizer = useResizablePanel('N8N_LOGS_INPUT_PANEL_WIDTH', {
|
||||
container,
|
||||
defaultSize: (size) => size / 2,
|
||||
minSize: MIN_IO_PANEL_WIDTH,
|
||||
maxSize: (size) => size - MIN_IO_PANEL_WIDTH,
|
||||
allowCollapse: true,
|
||||
allowFullSize: true,
|
||||
});
|
||||
const shouldResize = computed(() => content.value === LOG_DETAILS_CONTENT.BOTH);
|
||||
|
||||
function handleToggleInput(open?: boolean) {
|
||||
const wasOpen = [LOG_DETAILS_CONTENT.INPUT, LOG_DETAILS_CONTENT.BOTH].includes(content.value);
|
||||
|
||||
if (open === wasOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
content.value = wasOpen ? LOG_DETAILS_CONTENT.OUTPUT : LOG_DETAILS_CONTENT.BOTH;
|
||||
|
||||
telemetry.track('User toggled log view sub pane', {
|
||||
pane: 'input',
|
||||
newState: wasOpen ? 'hidden' : 'visible',
|
||||
});
|
||||
}
|
||||
|
||||
function handleToggleOutput(open?: boolean) {
|
||||
const wasOpen = [LOG_DETAILS_CONTENT.OUTPUT, LOG_DETAILS_CONTENT.BOTH].includes(content.value);
|
||||
|
||||
if (open === wasOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
content.value = wasOpen ? LOG_DETAILS_CONTENT.INPUT : LOG_DETAILS_CONTENT.BOTH;
|
||||
|
||||
telemetry.track('User toggled log view sub pane', {
|
||||
pane: 'output',
|
||||
newState: wasOpen ? 'hidden' : 'visible',
|
||||
});
|
||||
}
|
||||
|
||||
function handleResizeEnd() {
|
||||
if (resizer.isCollapsed.value) {
|
||||
handleToggleInput(false);
|
||||
}
|
||||
|
||||
if (resizer.isFullSize.value) {
|
||||
handleToggleOutput(false);
|
||||
}
|
||||
|
||||
resizer.onResizeEnd();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container" data-test-id="log-details">
|
||||
<PanelHeader
|
||||
title="Log details"
|
||||
data-test-id="logs-details-header"
|
||||
@click="emit('clickHeader')"
|
||||
>
|
||||
<div ref="container" :class="$style.container" data-test-id="log-details">
|
||||
<PanelHeader data-test-id="log-details-header" @click="emit('clickHeader')">
|
||||
<template #title>
|
||||
<div :class="$style.title">
|
||||
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
|
||||
<N8nText tag="div" :bold="true" size="small" :class="$style.name">
|
||||
{{ node?.name }}
|
||||
</N8nText>
|
||||
<ExecutionSummary
|
||||
v-if="isOpen"
|
||||
:class="$style.executionSummary"
|
||||
:status="runData?.executionStatus ?? 'unknown'"
|
||||
:consumed-tokens="consumedTokens"
|
||||
:time-took="runData?.executionTime"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div v-if="isOpen && !isTriggerNode" :class="$style.actions">
|
||||
<N8nButton
|
||||
size="mini"
|
||||
type="secondary"
|
||||
:class="content === LOG_DETAILS_CONTENT.OUTPUT ? '' : $style.pressed"
|
||||
@click.stop="handleToggleInput"
|
||||
>
|
||||
{{ locale.baseText('logs.details.header.actions.input') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
size="mini"
|
||||
type="secondary"
|
||||
:class="content === LOG_DETAILS_CONTENT.INPUT ? '' : $style.pressed"
|
||||
@click.stop="handleToggleOutput"
|
||||
>
|
||||
{{ locale.baseText('logs.details.header.actions.output') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<slot name="actions" />
|
||||
</template>
|
||||
</PanelHeader>
|
||||
<div v-if="isOpen" :class="$style.content" data-test-id="logs-details-body" />
|
||||
<div v-if="isOpen" :class="$style.content" data-test-id="logs-details-body">
|
||||
<N8nResizeWrapper
|
||||
v-if="!isTriggerNode && content !== LOG_DETAILS_CONTENT.OUTPUT"
|
||||
:class="{
|
||||
[$style.inputResizer]: true,
|
||||
[$style.collapsed]: resizer.isCollapsed.value,
|
||||
[$style.full]: resizer.isFullSize.value,
|
||||
}"
|
||||
:width="resizer.size.value"
|
||||
:style="shouldResize ? { width: `${resizer.size.value ?? 0}px` } : undefined"
|
||||
:supported-directions="['right']"
|
||||
:is-resizing-enabled="shouldResize"
|
||||
:window="window"
|
||||
@resize="resizer.onResize"
|
||||
@resizeend="handleResizeEnd"
|
||||
>
|
||||
<RunDataView
|
||||
data-test-id="log-details-input"
|
||||
pane-type="input"
|
||||
:title="locale.baseText('logs.details.header.actions.input')"
|
||||
:log-entry="logEntry"
|
||||
/>
|
||||
</N8nResizeWrapper>
|
||||
<RunDataView
|
||||
v-if="isTriggerNode || content !== LOG_DETAILS_CONTENT.INPUT"
|
||||
data-test-id="log-details-output"
|
||||
pane-type="output"
|
||||
:class="$style.outputPanel"
|
||||
:title="locale.baseText('logs.details.header.actions.output')"
|
||||
:log-entry="logEntry"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,11 +181,58 @@ defineSlots<{ actions: {} }>();
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-foreground-xlight);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2xs);
|
||||
padding-inline-end: var(--spacing-2xs);
|
||||
|
||||
.pressed {
|
||||
background-color: var(--color-button-secondary-focus-outline);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.executionSummary {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
padding: var(--spacing-s);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.outputPanel {
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.inputResizer {
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:not(:is(:last-child, .collapsed, .full)) {
|
||||
border-right: var(--border-base);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('LogsOverviewPanel', () => {
|
||||
const mergedProps: InstanceType<typeof LogsOverviewPanel>['$props'] = {
|
||||
isOpen: false,
|
||||
isReadOnly: false,
|
||||
isCompact: false,
|
||||
executionTree: createLogEntries(
|
||||
workflowsStore.getCurrentWorkflow(),
|
||||
workflowsStore.workflowExecutionData?.data?.resultData.runData ?? {},
|
||||
|
||||
@@ -12,17 +12,18 @@ import {
|
||||
getTotalConsumedTokens,
|
||||
type TreeNode,
|
||||
} from '@/components/RunDataAi/utils';
|
||||
import { upperFirst } from 'lodash-es';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
|
||||
import LogsOverviewRow from '@/components/CanvasChat/future/components/LogsOverviewRow.vue';
|
||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useRouter } from 'vue-router';
|
||||
import ExecutionSummary from '@/components/CanvasChat/future/components/ExecutionSummary.vue';
|
||||
|
||||
const { isOpen, isReadOnly, selected, executionTree } = defineProps<{
|
||||
const { isOpen, isReadOnly, selected, isCompact, executionTree } = defineProps<{
|
||||
isOpen: boolean;
|
||||
isReadOnly: boolean;
|
||||
selected?: TreeNode;
|
||||
isReadOnly: boolean;
|
||||
isCompact: boolean;
|
||||
executionTree: TreeNode[];
|
||||
}>();
|
||||
|
||||
@@ -44,27 +45,7 @@ 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 },
|
||||
]);
|
||||
const executionStatusText = computed(() => {
|
||||
const execution = workflowsStore.workflowExecutionData;
|
||||
|
||||
if (!execution) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (execution.startedAt && execution.stoppedAt) {
|
||||
return locale.baseText('logs.overview.body.summaryText', {
|
||||
interpolate: {
|
||||
status: upperFirst(execution.status),
|
||||
time: locale.displayTimer(
|
||||
+new Date(execution.stoppedAt) - +new Date(execution.startedAt),
|
||||
true,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return upperFirst(execution.status);
|
||||
});
|
||||
const execution = computed(() => workflowsStore.workflowExecutionData);
|
||||
const consumedTokens = computed(() =>
|
||||
getTotalConsumedTokens(...executionTree.map(getSubtreeTotalConsumedTokens)),
|
||||
);
|
||||
@@ -123,6 +104,7 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
|
||||
type="secondary"
|
||||
icon="trash"
|
||||
icon-size="medium"
|
||||
:class="$style.clearButton"
|
||||
@click.stop="onClearExecutionData"
|
||||
>{{ locale.baseText('logs.overview.header.actions.clearExecution') }}</N8nButton
|
||||
>
|
||||
@@ -146,19 +128,17 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
|
||||
{{ locale.baseText('logs.overview.body.empty.message') }}
|
||||
</N8nText>
|
||||
<div v-else :class="$style.scrollable">
|
||||
<N8nText
|
||||
v-if="executionStatusText !== undefined"
|
||||
tag="div"
|
||||
color="text-light"
|
||||
size="small"
|
||||
<ExecutionSummary
|
||||
v-if="execution"
|
||||
:class="$style.summary"
|
||||
>
|
||||
<span>{{ executionStatusText }}</span>
|
||||
<ConsumedTokenCountText
|
||||
v-if="consumedTokens.totalTokens > 0"
|
||||
:consumed-tokens="consumedTokens"
|
||||
/>
|
||||
</N8nText>
|
||||
:status="execution.status"
|
||||
:consumed-tokens="consumedTokens"
|
||||
:time-took="
|
||||
execution.startedAt && execution.stoppedAt
|
||||
? +new Date(execution.stoppedAt) - +new Date(execution.startedAt)
|
||||
: undefined
|
||||
"
|
||||
/>
|
||||
<ElTree
|
||||
v-if="executionTree.length > 0"
|
||||
node-key="id"
|
||||
@@ -175,7 +155,7 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
|
||||
:node="elTreeNode"
|
||||
:is-read-only="isReadOnly"
|
||||
:is-selected="data.node === selected?.node && data.runIndex === selected?.runIndex"
|
||||
:is-compact="selected !== undefined"
|
||||
:is-compact="isCompact"
|
||||
:should-show-consumed-tokens="consumedTokens.totalTokens > 0"
|
||||
@toggle-expanded="handleToggleExpanded"
|
||||
@open-ndv="handleOpenNdv"
|
||||
@@ -196,6 +176,8 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@import '@/styles/variables';
|
||||
|
||||
.container {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
@@ -206,6 +188,11 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
|
||||
background-color: var(--color-foreground-xlight);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
border: none;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
@@ -227,28 +214,19 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
padding: var(--spacing-2xs);
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-block: var(--spacing-2xs);
|
||||
|
||||
& > * {
|
||||
padding-inline: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
& > *:not(:last-child) {
|
||||
border-right: var(--border-base);
|
||||
}
|
||||
margin-bottom: var(--spacing-4xs);
|
||||
padding: var(--spacing-4xs) var(--spacing-2xs) 0 var(--spacing-2xs);
|
||||
min-height: calc(30px + var(--spacing-s));
|
||||
}
|
||||
|
||||
.tree {
|
||||
margin-top: var(--spacing-2xs);
|
||||
padding: 0 var(--spacing-2xs) var(--spacing-2xs) var(--spacing-2xs);
|
||||
|
||||
& :global(.el-icon) {
|
||||
display: none;
|
||||
@@ -261,5 +239,13 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
|
||||
right: 0;
|
||||
top: 0;
|
||||
margin: var(--spacing-2xs);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s $ease-out-expo;
|
||||
|
||||
.content:hover & {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -274,6 +274,7 @@ watch(
|
||||
}
|
||||
|
||||
.name {
|
||||
flex-basis: 0;
|
||||
flex-grow: 1;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
@@ -288,6 +289,10 @@ watch(
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.compact & {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.compact:hover & {
|
||||
width: auto;
|
||||
}
|
||||
@@ -313,6 +318,10 @@ watch(
|
||||
width: 10%;
|
||||
text-align: right;
|
||||
|
||||
.compact & {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.compact:hover & {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { LOGS_PANEL_STATE, type LogsPanelState } from '@/components/CanvasChat/types/logs';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useStyles } from '@/composables/useStyles';
|
||||
import { N8nIconButton, N8nTooltip } from '@n8n/design-system';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const { panelState, showPopOutButton } = defineProps<{
|
||||
panelState: LogsPanelState;
|
||||
const { isOpen, showToggleButton, showPopOutButton } = defineProps<{
|
||||
isOpen: boolean;
|
||||
showToggleButton: boolean;
|
||||
showPopOutButton: boolean;
|
||||
}>();
|
||||
|
||||
@@ -17,16 +17,12 @@ const locales = useI18n();
|
||||
const tooltipZIndex = computed(() => appStyles.APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON + 100);
|
||||
const popOutButtonText = computed(() => locales.baseText('runData.panel.actions.popOut'));
|
||||
const toggleButtonText = computed(() =>
|
||||
locales.baseText(
|
||||
panelState === LOGS_PANEL_STATE.ATTACHED
|
||||
? 'runData.panel.actions.collapse'
|
||||
: 'runData.panel.actions.open',
|
||||
),
|
||||
locales.baseText(isOpen ? 'runData.panel.actions.collapse' : 'runData.panel.actions.open'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div :class="$style.container">
|
||||
<N8nTooltip v-if="showPopOutButton" :z-index="tooltipZIndex" :content="popOutButtonText">
|
||||
<N8nIconButton
|
||||
icon="pop-out"
|
||||
@@ -37,16 +33,12 @@ const toggleButtonText = computed(() =>
|
||||
@click.stop="emit('popOut')"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
<N8nTooltip
|
||||
v-if="panelState !== LOGS_PANEL_STATE.FLOATING"
|
||||
:z-index="tooltipZIndex"
|
||||
:content="toggleButtonText"
|
||||
>
|
||||
<N8nTooltip v-if="showToggleButton" :z-index="tooltipZIndex" :content="toggleButtonText">
|
||||
<N8nIconButton
|
||||
type="secondary"
|
||||
size="small"
|
||||
icon-size="medium"
|
||||
:icon="panelState === LOGS_PANEL_STATE.ATTACHED ? 'chevron-down' : 'chevron-up'"
|
||||
:icon="isOpen ? 'chevron-down' : 'chevron-up'"
|
||||
:aria-label="toggleButtonText"
|
||||
style="color: var(--color-text-base)"
|
||||
@click.stop="emit('toggleOpen')"
|
||||
@@ -54,3 +46,14 @@ const toggleButtonText = computed(() =>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.container button {
|
||||
border: none;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { N8nText } from '@n8n/design-system';
|
||||
|
||||
defineProps<{ title: string }>();
|
||||
const { title } = defineProps<{ title?: string }>();
|
||||
|
||||
defineSlots<{ actions: {} }>();
|
||||
defineSlots<{ actions: {}; title?: {} }>();
|
||||
|
||||
const emit = defineEmits<{ click: [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header :class="$style.container" @click="emit('click')">
|
||||
<N8nText :class="$style.title" :bold="true" size="small">{{ title }}</N8nText>
|
||||
<N8nText :class="$style.title" :bold="true" size="small">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</N8nText>
|
||||
<div :class="$style.actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
@@ -44,9 +46,13 @@ const emit = defineEmits<{ click: [] }>();
|
||||
.title {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-base);
|
||||
@@ -54,9 +60,4 @@ const emit = defineEmits<{ click: [] }>();
|
||||
/* Let button heights not affect the header height */
|
||||
margin-block: calc(-1 * var(--spacing-s));
|
||||
}
|
||||
|
||||
.actions button {
|
||||
border: none;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import RunData from '@/components/RunData.vue';
|
||||
import { type TreeNode } from '@/components/RunDataAi/utils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { type NodePanelType } from '@/Interface';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { N8nLink, N8nText } from '@n8n/design-system';
|
||||
import { uniqBy } from 'lodash-es';
|
||||
import { computed } from 'vue';
|
||||
import { I18nT } from 'vue-i18n';
|
||||
|
||||
const { title, logEntry, paneType } = defineProps<{
|
||||
title: string;
|
||||
paneType: NodePanelType;
|
||||
logEntry: TreeNode;
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const ndvStore = useNDVStore();
|
||||
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||
const node = computed(() => {
|
||||
if (logEntry.depth > 0 || paneType === 'output') {
|
||||
return workflowsStore.nodesByName[logEntry.node];
|
||||
}
|
||||
|
||||
const parent = workflow.value.getParentNodesByDepth(logEntry.node)[0];
|
||||
|
||||
if (!parent) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return workflowsStore.nodesByName[parent.name];
|
||||
});
|
||||
const isMultipleInput = computed(
|
||||
() =>
|
||||
paneType === 'input' &&
|
||||
uniqBy(
|
||||
workflow.value.getParentNodesByDepth(logEntry.node).filter((n) => n.name !== logEntry.node),
|
||||
(n) => n.name,
|
||||
).length > 1,
|
||||
);
|
||||
|
||||
function handleClickOpenNdv() {
|
||||
ndvStore.setActiveNodeName(logEntry.node);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RunData
|
||||
v-if="node"
|
||||
:node="node"
|
||||
:workflow="workflow"
|
||||
:run-index="logEntry.runIndex"
|
||||
:too-much-data-title="locale.baseText('ndv.output.tooMuchData.title')"
|
||||
:no-data-in-branch-message="locale.baseText('ndv.output.noOutputDataInBranch')"
|
||||
:executing-message="locale.baseText('ndv.output.executing')"
|
||||
:pane-type="paneType"
|
||||
:disable-run-index-selection="true"
|
||||
:compact="true"
|
||||
:disable-pin="true"
|
||||
:disable-edit="true"
|
||||
table-header-bg-color="light"
|
||||
>
|
||||
<template #header>
|
||||
<N8nText :class="$style.title" :bold="true" color="text-light" size="small">
|
||||
{{ title }}
|
||||
</N8nText>
|
||||
</template>
|
||||
|
||||
<template #no-output-data>
|
||||
<N8nText :bold="true" color="text-dark" size="large">
|
||||
{{ locale.baseText('ndv.output.noOutputData.title') }}
|
||||
</N8nText>
|
||||
</template>
|
||||
|
||||
<template v-if="isMultipleInput" #content>
|
||||
<!-- leave empty -->
|
||||
</template>
|
||||
|
||||
<template v-if="isMultipleInput" #callout-message>
|
||||
<I18nT keypath="logs.details.body.multipleInputs">
|
||||
<template #button>
|
||||
<N8nLink size="small" @click="handleClickOpenNdv">
|
||||
{{ locale.baseText('logs.details.body.multipleInputs.openingTheNode') }}
|
||||
</N8nLink>
|
||||
</template>
|
||||
</I18nT>
|
||||
</template>
|
||||
</RunData>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.title {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 3px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
import { computed, type ShallowRef } from 'vue';
|
||||
import {
|
||||
LOCAL_STORAGE_OVERVIEW_PANEL_WIDTH,
|
||||
LOCAL_STORAGE_PANEL_HEIGHT,
|
||||
LOCAL_STORAGE_PANEL_WIDTH,
|
||||
} from '../../composables/useResize';
|
||||
import { LOGS_PANEL_STATE } from '../../types/logs';
|
||||
import { usePiPWindow } from '../../composables/usePiPWindow';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { watch } from 'vue';
|
||||
import { useResizablePanel } from './useResizablePanel';
|
||||
|
||||
export function useLayout(
|
||||
pipContainer: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
pipContent: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
container: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
logsContainer: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
) {
|
||||
const canvasStore = useCanvasStore();
|
||||
const telemetry = useTelemetry();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const panelState = computed(() => workflowsStore.logsPanelState);
|
||||
|
||||
const resizer = useResizablePanel(LOCAL_STORAGE_PANEL_HEIGHT, {
|
||||
container: document.body,
|
||||
position: 'bottom',
|
||||
snap: false,
|
||||
defaultSize: (size) => size * 0.3,
|
||||
minSize: 160,
|
||||
maxSize: (size) => size * 0.75,
|
||||
allowCollapse: true,
|
||||
});
|
||||
|
||||
const chatPanelResizer = useResizablePanel(LOCAL_STORAGE_PANEL_WIDTH, {
|
||||
container,
|
||||
defaultSize: (size) => size * 0.3,
|
||||
minSize: 300,
|
||||
maxSize: (size) => size * 0.8,
|
||||
});
|
||||
|
||||
const overviewPanelResizer = useResizablePanel(LOCAL_STORAGE_OVERVIEW_PANEL_WIDTH, {
|
||||
container: logsContainer,
|
||||
defaultSize: (size) => size * 0.3,
|
||||
minSize: 80,
|
||||
maxSize: 500,
|
||||
allowFullSize: true,
|
||||
});
|
||||
|
||||
const isOpen = computed(() =>
|
||||
panelState.value === LOGS_PANEL_STATE.CLOSED
|
||||
? resizer.isResizing.value && resizer.size.value > 0
|
||||
: !resizer.isCollapsed.value,
|
||||
);
|
||||
const isCollapsingDetailsPanel = computed(() => overviewPanelResizer.isFullSize.value);
|
||||
|
||||
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
|
||||
initialHeight: 400,
|
||||
initialWidth: window.document.body.offsetWidth * 0.8,
|
||||
container: pipContainer,
|
||||
content: pipContent,
|
||||
shouldPopOut: computed(() => panelState.value === LOGS_PANEL_STATE.FLOATING),
|
||||
onRequestClose: () => {
|
||||
if (!isOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
telemetry.track('User toggled log view', { new_state: 'attached' });
|
||||
workflowsStore.setPreferPoppedOutLogsView(false);
|
||||
},
|
||||
});
|
||||
|
||||
function handleToggleOpen(open?: boolean) {
|
||||
const wasOpen = panelState.value !== LOGS_PANEL_STATE.CLOSED;
|
||||
|
||||
if (open === wasOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
workflowsStore.toggleLogsPanelOpen(open);
|
||||
|
||||
telemetry.track('User toggled log view', {
|
||||
new_state: wasOpen ? 'collapsed' : 'attached',
|
||||
});
|
||||
}
|
||||
|
||||
function handlePopOut() {
|
||||
telemetry.track('User toggled log view', { new_state: 'floating' });
|
||||
workflowsStore.toggleLogsPanelOpen(true);
|
||||
workflowsStore.setPreferPoppedOutLogsView(true);
|
||||
}
|
||||
|
||||
function handleResizeEnd() {
|
||||
if (panelState.value === LOGS_PANEL_STATE.CLOSED && !resizer.isCollapsed.value) {
|
||||
handleToggleOpen(true);
|
||||
}
|
||||
|
||||
if (resizer.isCollapsed.value) {
|
||||
handleToggleOpen(false);
|
||||
}
|
||||
|
||||
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 */,
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
height: resizer.size,
|
||||
chatPanelWidth: chatPanelResizer.size,
|
||||
overviewPanelWidth: overviewPanelResizer.size,
|
||||
canPopOut,
|
||||
isOpen,
|
||||
isCollapsingDetailsPanel,
|
||||
isPoppedOut,
|
||||
isOverviewPanelFullWidth: overviewPanelResizer.isFullSize,
|
||||
pipWindow,
|
||||
onToggleOpen: handleToggleOpen,
|
||||
onPopOut: handlePopOut,
|
||||
onResize: resizer.onResize,
|
||||
onResizeEnd: handleResizeEnd,
|
||||
onChatPanelResize: chatPanelResizer.onResize,
|
||||
onChatPanelResizeEnd: chatPanelResizer.onResizeEnd,
|
||||
onOverviewPanelResize: overviewPanelResizer.onResize,
|
||||
onOverviewPanelResizeEnd: overviewPanelResizer.onResizeEnd,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { type ResizeData } from '@n8n/design-system';
|
||||
import { useResizablePanel } from './useResizablePanel';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
describe(useResizablePanel, () => {
|
||||
let localStorageKey = uuid();
|
||||
let container = document.createElement('div');
|
||||
let resizeData: ResizeData;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageKey = uuid();
|
||||
|
||||
container = document.createElement('div');
|
||||
|
||||
Object.defineProperty(container, 'offsetWidth', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return 1000;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(container, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return 800;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(container, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1000,
|
||||
height: 800,
|
||||
}) as DOMRect;
|
||||
},
|
||||
});
|
||||
|
||||
resizeData = {
|
||||
height: Math.random(),
|
||||
width: Math.random(),
|
||||
dX: Math.random(),
|
||||
dY: Math.random(),
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
direction: 'right',
|
||||
};
|
||||
});
|
||||
|
||||
it('should return defaultSize if value is missing in local storage', () => {
|
||||
const { size } = useResizablePanel(localStorageKey, { container, defaultSize: 444 });
|
||||
|
||||
expect(size.value).toBe(444);
|
||||
});
|
||||
|
||||
it('should restore value from local storage if valid number is stored', () => {
|
||||
window.localStorage.setItem(localStorageKey, '333');
|
||||
|
||||
const { size } = useResizablePanel(localStorageKey, { container, defaultSize: 444 });
|
||||
|
||||
expect(size.value).toBe(333);
|
||||
});
|
||||
|
||||
it('should update size when onResize is called', () => {
|
||||
const { size, onResize } = useResizablePanel(localStorageKey, { container, defaultSize: 444 });
|
||||
|
||||
onResize({ ...resizeData, x: 555 });
|
||||
expect(size.value).toBe(555);
|
||||
});
|
||||
|
||||
it('should calculate and return height if position is "bottom"', () => {
|
||||
const { size, onResize } = useResizablePanel(localStorageKey, {
|
||||
container,
|
||||
defaultSize: 444,
|
||||
position: 'bottom',
|
||||
});
|
||||
|
||||
onResize({ ...resizeData, y: 222 });
|
||||
expect(size.value).toBe(578); // container height minus y
|
||||
});
|
||||
|
||||
it('should return size bound in the range between minSize and maxSize', () => {
|
||||
const { size, onResize } = useResizablePanel(localStorageKey, {
|
||||
container,
|
||||
defaultSize: 444,
|
||||
minSize: 200,
|
||||
maxSize: (containerSize) => containerSize * 0.9,
|
||||
});
|
||||
|
||||
onResize({ ...resizeData, x: 100 });
|
||||
expect(size.value).toBe(200);
|
||||
|
||||
onResize({ ...resizeData, x: 950 });
|
||||
expect(size.value).toBe(900);
|
||||
});
|
||||
|
||||
it('should update manually updated size so that proportion is maintained when container is resized', async () => {
|
||||
const spyResizeObserver = vi.spyOn(window, 'ResizeObserver');
|
||||
|
||||
const { size, onResize } = useResizablePanel(localStorageKey, {
|
||||
container,
|
||||
defaultSize: 444,
|
||||
minSize: 200,
|
||||
maxSize: (containerSize) => containerSize * 0.9,
|
||||
});
|
||||
|
||||
expect(spyResizeObserver).toHaveBeenCalledTimes(1);
|
||||
|
||||
onResize({ ...resizeData, x: 600 });
|
||||
|
||||
expect(size.value).toBe(600);
|
||||
Object.defineProperty(container, 'offsetWidth', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return 500;
|
||||
},
|
||||
});
|
||||
spyResizeObserver.mock.calls[0]?.[0]?.([], {} as ResizeObserver);
|
||||
await nextTick();
|
||||
expect(size.value).toBe(300);
|
||||
});
|
||||
|
||||
it('should return 0 and isCollapsed=true while resizing beyond minSize if allowCollapse is set to true', () => {
|
||||
const { size, isCollapsed, onResize, onResizeEnd } = useResizablePanel(localStorageKey, {
|
||||
container,
|
||||
defaultSize: 444,
|
||||
minSize: 300,
|
||||
allowCollapse: true,
|
||||
});
|
||||
|
||||
expect(size.value).toBe(444);
|
||||
expect(isCollapsed.value).toBe(false);
|
||||
|
||||
onResize({ ...resizeData, x: 200 });
|
||||
expect(size.value).toBe(300);
|
||||
expect(isCollapsed.value).toBe(false);
|
||||
|
||||
onResize({ ...resizeData, x: 10 });
|
||||
expect(size.value).toBe(0);
|
||||
expect(isCollapsed.value).toBe(true);
|
||||
|
||||
onResizeEnd();
|
||||
expect(size.value).toBe(300);
|
||||
expect(isCollapsed.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should return container size and isFullSize=true while resizing close to container size if allowFullSize is set to true', () => {
|
||||
const { size, isFullSize, onResize, onResizeEnd } = useResizablePanel(localStorageKey, {
|
||||
container,
|
||||
defaultSize: 444,
|
||||
maxSize: 800,
|
||||
allowFullSize: true,
|
||||
});
|
||||
|
||||
expect(size.value).toBe(444);
|
||||
expect(isFullSize.value).toBe(false);
|
||||
|
||||
onResize({ ...resizeData, x: 900 });
|
||||
expect(size.value).toBe(800);
|
||||
expect(isFullSize.value).toBe(false);
|
||||
|
||||
onResize({ ...resizeData, x: 999 });
|
||||
expect(size.value).toBe(1000);
|
||||
expect(isFullSize.value).toBe(true);
|
||||
|
||||
onResizeEnd();
|
||||
expect(size.value).toBe(800);
|
||||
expect(isFullSize.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { type ResizeData } from '@n8n/design-system';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { computed, type MaybeRef, ref, unref, watch } from 'vue';
|
||||
|
||||
type GetSize = number | ((containerSize: number) => number);
|
||||
|
||||
interface UseResizerV2Options {
|
||||
/**
|
||||
* Container element, to which relative size is calculated (doesn't necessarily have to be DOM parent node)
|
||||
*/
|
||||
container: MaybeRef<HTMLElement | null>;
|
||||
defaultSize: GetSize;
|
||||
minSize?: GetSize;
|
||||
maxSize?: GetSize;
|
||||
/**
|
||||
* Which end of the container the resizable element itself is located
|
||||
*/
|
||||
position?: 'left' | 'bottom';
|
||||
/**
|
||||
* If set to true, snaps to default size when resizing close to it
|
||||
*/
|
||||
snap?: boolean;
|
||||
/**
|
||||
* If set to true, resizing beyond minSize sets size to 0 and isCollapsed to true
|
||||
* until onResizeEnd is called
|
||||
*/
|
||||
allowCollapse?: boolean;
|
||||
/**
|
||||
* If set to true, resizing beyond maxSize sets size to the container size and
|
||||
* isFullSize to true until onResizeEnd is called
|
||||
*/
|
||||
allowFullSize?: boolean;
|
||||
}
|
||||
|
||||
export function useResizablePanel(
|
||||
localStorageKey: string,
|
||||
{
|
||||
container,
|
||||
defaultSize,
|
||||
snap = true,
|
||||
minSize = 0,
|
||||
maxSize = (size) => size,
|
||||
position = 'left',
|
||||
allowCollapse,
|
||||
allowFullSize,
|
||||
}: UseResizerV2Options,
|
||||
) {
|
||||
const containerSize = ref(0);
|
||||
const size = useLocalStorage(localStorageKey, -1, { writeDefaults: false });
|
||||
const isResizing = ref(false);
|
||||
const constrainedSize = computed(() => {
|
||||
if (isResizing.value && allowCollapse && size.value < 30) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (isResizing.value && allowFullSize && size.value > containerSize.value - 30) {
|
||||
return containerSize.value;
|
||||
}
|
||||
|
||||
const defaultSizeValue = resolveSize(defaultSize, containerSize.value);
|
||||
|
||||
if (Number.isNaN(size.value) || size.value < 0) {
|
||||
return defaultSizeValue;
|
||||
}
|
||||
|
||||
const minSizeValue = resolveSize(minSize, containerSize.value);
|
||||
const maxSizeValue = resolveSize(maxSize, containerSize.value);
|
||||
|
||||
return Math.max(
|
||||
minSizeValue,
|
||||
Math.min(
|
||||
snap && Math.abs(defaultSizeValue - size.value) < 30 ? defaultSizeValue : size.value,
|
||||
maxSizeValue,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
function getSize(el: { width: number; height: number }) {
|
||||
return position === 'bottom' ? el.height : el.width;
|
||||
}
|
||||
|
||||
function getOffsetSize(el: { offsetWidth: number; offsetHeight: number }) {
|
||||
return position === 'bottom' ? el.offsetHeight : el.offsetWidth;
|
||||
}
|
||||
|
||||
function getValue(data: { x: number; y: number }) {
|
||||
return position === 'bottom' ? data.y : data.x;
|
||||
}
|
||||
|
||||
function resolveSize(getter: GetSize, containerSizeValue: number): number {
|
||||
return typeof getter === 'number' ? getter : getter(containerSizeValue);
|
||||
}
|
||||
|
||||
function onResize(data: ResizeData) {
|
||||
const containerRect = unref(container)?.getBoundingClientRect();
|
||||
|
||||
isResizing.value = true;
|
||||
size.value = Math.max(
|
||||
0,
|
||||
position === 'bottom'
|
||||
? (containerRect ? getSize(containerRect) : 0) - getValue(data)
|
||||
: getValue(data) - (containerRect ? getValue(containerRect) : 0),
|
||||
);
|
||||
}
|
||||
|
||||
function onResizeEnd() {
|
||||
isResizing.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => unref(container),
|
||||
(el, _, onCleanUp) => {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
containerSize.value = getOffsetSize(el);
|
||||
});
|
||||
|
||||
observer.observe(el);
|
||||
|
||||
containerSize.value = getOffsetSize(el);
|
||||
|
||||
onCleanUp(() => observer.disconnect());
|
||||
},
|
||||
{ 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),
|
||||
isFullSize: computed(() => isResizing.value && constrainedSize.value >= containerSize.value),
|
||||
size: constrainedSize,
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
};
|
||||
}
|
||||
@@ -12,3 +12,11 @@ export const LOGS_PANEL_STATE = {
|
||||
} as const;
|
||||
|
||||
export type LogsPanelState = (typeof LOGS_PANEL_STATE)[keyof typeof LOGS_PANEL_STATE];
|
||||
|
||||
export const LOG_DETAILS_CONTENT = {
|
||||
INPUT: 'input',
|
||||
OUTPUT: 'output',
|
||||
BOTH: 'both',
|
||||
};
|
||||
|
||||
export type LogDetailsContent = (typeof LOG_DETAILS_CONTENT)[keyof typeof LOG_DETAILS_CONTENT];
|
||||
|
||||
@@ -352,6 +352,7 @@ function activatePane() {
|
||||
|
||||
<template>
|
||||
<RunData
|
||||
:class="$style.runData"
|
||||
:node="currentNode"
|
||||
:nodes="isMappingMode ? rootNodesParents : parentNodes"
|
||||
:workflow="workflow"
|
||||
@@ -521,6 +522,10 @@ function activatePane() {
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.runData {
|
||||
background-color: var(--color-run-data-background);
|
||||
}
|
||||
|
||||
.mappedNode {
|
||||
padding: 0 var(--spacing-s) var(--spacing-s);
|
||||
}
|
||||
|
||||
@@ -323,6 +323,7 @@ const activatePane = () => {
|
||||
<template>
|
||||
<RunData
|
||||
ref="runDataRef"
|
||||
:class="$style.runData"
|
||||
:node="node"
|
||||
:workflow="workflow"
|
||||
:run-index="runIndex"
|
||||
@@ -440,6 +441,9 @@ const activatePane = () => {
|
||||
:global([data-output-type='logs'] [class*='displayModes']) {
|
||||
display: none;
|
||||
}
|
||||
.runData {
|
||||
background-color: var(--color-run-data-background);
|
||||
}
|
||||
.outputTypeSelect {
|
||||
margin-bottom: var(--spacing-4xs);
|
||||
width: fit-content;
|
||||
|
||||
@@ -78,7 +78,6 @@ import {
|
||||
N8nInfoTip,
|
||||
N8nLink,
|
||||
N8nOption,
|
||||
N8nRadioButtons,
|
||||
N8nSelect,
|
||||
N8nSpinner,
|
||||
N8nTabs,
|
||||
@@ -92,6 +91,8 @@ import { useSchemaPreviewStore } from '@/stores/schemaPreview.store';
|
||||
import { asyncComputed } from '@vueuse/core';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import ViewSubExecution from './ViewSubExecution.vue';
|
||||
import RunDataItemCount from '@/components/RunDataItemCount.vue';
|
||||
import RunDataDisplayModeSelect from '@/components/RunDataDisplayModeSelect.vue';
|
||||
|
||||
const LazyRunDataTable = defineAsyncComponent(
|
||||
async () => await import('@/components/RunDataTable.vue'),
|
||||
@@ -119,7 +120,7 @@ type Props = {
|
||||
runIndex: number;
|
||||
tooMuchDataTitle: string;
|
||||
executingMessage: string;
|
||||
pushRef: string;
|
||||
pushRef?: string;
|
||||
paneType: NodePanelType;
|
||||
noDataInBranchMessage: string;
|
||||
node?: INodeUi | null;
|
||||
@@ -135,6 +136,11 @@ type Props = {
|
||||
isPaneActive?: boolean;
|
||||
hidePagination?: boolean;
|
||||
calloutMessage?: string;
|
||||
disableRunIndexSelection?: boolean;
|
||||
disableEdit?: boolean;
|
||||
disablePin?: boolean;
|
||||
compact?: boolean;
|
||||
tableHeaderBgColor?: 'base' | 'light';
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -149,7 +155,26 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isExecuting: false,
|
||||
hidePagination: false,
|
||||
calloutMessage: undefined,
|
||||
disableRunIndexSelection: false,
|
||||
disableEdit: false,
|
||||
disablePin: false,
|
||||
compact: false,
|
||||
tableHeaderBgColor: 'base',
|
||||
});
|
||||
|
||||
defineSlots<{
|
||||
content: {};
|
||||
'callout-message': {};
|
||||
header: {};
|
||||
'input-select': {};
|
||||
'before-data': {};
|
||||
'run-info': {};
|
||||
'node-waiting': {};
|
||||
'node-not-run': {};
|
||||
'no-output-data': {};
|
||||
'recovered-artificial-output-data': {};
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: [search: string];
|
||||
runChange: [runIndex: number];
|
||||
@@ -249,27 +274,6 @@ const canPinData = computed(
|
||||
pinnedData.isValidNodeType.value &&
|
||||
!(binaryData.value && binaryData.value.length > 0),
|
||||
);
|
||||
const displayModes = computed(() => {
|
||||
const defaults: Array<{ label: string; value: IRunDataDisplayMode }> = [
|
||||
{ label: i18n.baseText('runData.schema'), value: 'schema' },
|
||||
{ label: i18n.baseText('runData.table'), value: 'table' },
|
||||
{ label: i18n.baseText('runData.json'), value: 'json' },
|
||||
];
|
||||
|
||||
if (binaryData.value.length) {
|
||||
defaults.push({ label: i18n.baseText('runData.binary'), value: 'binary' });
|
||||
}
|
||||
|
||||
if (
|
||||
isPaneTypeOutput.value &&
|
||||
activeNode.value?.type === HTML_NODE_TYPE &&
|
||||
activeNode.value.parameters.operation === 'generateHtmlTemplate'
|
||||
) {
|
||||
defaults.unshift({ label: 'HTML', value: 'html' });
|
||||
}
|
||||
|
||||
return defaults;
|
||||
});
|
||||
|
||||
const hasNodeRun = computed(() =>
|
||||
Boolean(
|
||||
@@ -512,6 +516,9 @@ const parentNodePinnedData = computed(() => {
|
||||
});
|
||||
|
||||
const showPinButton = computed(() => {
|
||||
if (props.disablePin) {
|
||||
return false;
|
||||
}
|
||||
if (!rawInputData.value.length && !pinnedData.hasData.value) {
|
||||
return false;
|
||||
}
|
||||
@@ -593,6 +600,14 @@ const hasPreviewSchema = asyncComputed(async () => {
|
||||
return false;
|
||||
}, false);
|
||||
|
||||
const itemsCountProps = computed<InstanceType<typeof RunDataItemCount>['$props']>(() => ({
|
||||
search: search.value,
|
||||
dataCount: dataCount.value,
|
||||
unfilteredDataCount: unfilteredDataCount.value,
|
||||
subExecutionsCount: activeTaskMetadata.value?.subExecutionsCount,
|
||||
muted: props.compact || (props.paneType === 'input' && maxRunIndex.value === 0),
|
||||
}));
|
||||
|
||||
watch(node, (newNode, prevNode) => {
|
||||
if (newNode?.id === prevNode?.id) return;
|
||||
init();
|
||||
@@ -1303,7 +1318,10 @@ defineExpose({ enterEditMode });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['run-data', $style.container]" @mouseover="activatePane">
|
||||
<div
|
||||
:class="['run-data', $style.container, props.compact ? $style.compact : '']"
|
||||
@mouseover="activatePane"
|
||||
>
|
||||
<N8nCallout
|
||||
v-if="
|
||||
!isPaneTypeInput &&
|
||||
@@ -1371,19 +1389,27 @@ defineExpose({ enterEditMode });
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<N8nRadioButtons
|
||||
<RunDataDisplayModeSelect
|
||||
v-show="
|
||||
hasPreviewSchema ||
|
||||
(hasNodeRun && (inputData.length || binaryData.length || search) && !editMode.enabled)
|
||||
"
|
||||
:model-value="displayMode"
|
||||
:options="displayModes"
|
||||
data-test-id="ndv-run-data-display-mode"
|
||||
@update:model-value="onDisplayModeChange"
|
||||
:class="$style.displayModeSelect"
|
||||
:compact="props.compact"
|
||||
:value="displayMode"
|
||||
:has-binary-data="binaryData.length > 0"
|
||||
:pane-type="paneType"
|
||||
:node-generates-html="
|
||||
activeNode?.type === HTML_NODE_TYPE &&
|
||||
activeNode.parameters.operation === 'generateHtmlTemplate'
|
||||
"
|
||||
@change="onDisplayModeChange"
|
||||
/>
|
||||
|
||||
<RunDataItemCount v-if="props.compact" v-bind="itemsCountProps" />
|
||||
|
||||
<N8nIconButton
|
||||
v-if="canPinData && !isReadOnlyRoute && !readOnlyEnv"
|
||||
v-if="!props.disableEdit && canPinData && !isReadOnlyRoute && !readOnlyEnv"
|
||||
v-show="!editMode.enabled"
|
||||
:title="i18n.baseText('runData.editOutput')"
|
||||
:circle="false"
|
||||
@@ -1407,7 +1433,7 @@ defineExpose({ enterEditMode });
|
||||
@toggle-pin-data="onTogglePinData({ source: 'pin-icon-click' })"
|
||||
/>
|
||||
|
||||
<div v-show="editMode.enabled" :class="$style.editModeActions">
|
||||
<div v-if="!props.disableEdit" v-show="editMode.enabled" :class="$style.editModeActions">
|
||||
<N8nButton
|
||||
type="tertiary"
|
||||
:label="i18n.baseText('runData.editor.cancel')"
|
||||
@@ -1428,7 +1454,7 @@ defineExpose({ enterEditMode });
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="maxRunIndex > 0 && !displaysMultipleNodes"
|
||||
v-if="maxRunIndex > 0 && !displaysMultipleNodes && !props.disableRunIndexSelection"
|
||||
v-show="!editMode.enabled"
|
||||
:class="$style.runSelector"
|
||||
>
|
||||
@@ -1479,9 +1505,11 @@ defineExpose({ enterEditMode });
|
||||
|
||||
<slot v-if="!displaysMultipleNodes" name="before-data" />
|
||||
|
||||
<div v-if="props.calloutMessage" :class="$style.hintCallout">
|
||||
<div v-if="props.calloutMessage || $slots['callout-message']" :class="$style.hintCallout">
|
||||
<N8nCallout theme="info" data-test-id="run-data-callout">
|
||||
<N8nText v-n8n-html="props.calloutMessage" size="small"></N8nText>
|
||||
<slot name="callout-message">
|
||||
<N8nText v-n8n-html="props.calloutMessage" size="small"></N8nText>
|
||||
</slot>
|
||||
</N8nCallout>
|
||||
</div>
|
||||
|
||||
@@ -1518,6 +1546,7 @@ defineExpose({ enterEditMode });
|
||||
|
||||
<div
|
||||
v-else-if="
|
||||
!props.compact &&
|
||||
hasNodeRun &&
|
||||
!isSearchInSchemaView &&
|
||||
((dataCount > 0 && maxRunIndex === 0) || search) &&
|
||||
@@ -1525,37 +1554,12 @@ defineExpose({ enterEditMode });
|
||||
!displaysMultipleNodes
|
||||
"
|
||||
v-show="!editMode.enabled"
|
||||
:class="[$style.itemsCount, { [$style.muted]: paneType === 'input' && maxRunIndex === 0 }]"
|
||||
:class="$style.itemsCount"
|
||||
data-test-id="ndv-items-count"
|
||||
>
|
||||
<slot v-if="inputSelectLocation === 'items'" name="input-select"></slot>
|
||||
|
||||
<N8nText v-if="search" :class="$style.itemsText">
|
||||
{{
|
||||
i18n.baseText('ndv.search.items', {
|
||||
adjustToNumber: unfilteredDataCount,
|
||||
interpolate: { matched: dataCount, count: unfilteredDataCount },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
<N8nText v-else :class="$style.itemsText">
|
||||
<span>
|
||||
{{
|
||||
i18n.baseText('ndv.output.items', {
|
||||
adjustToNumber: dataCount,
|
||||
interpolate: { count: dataCount },
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-if="activeTaskMetadata?.subExecutionsCount">
|
||||
{{
|
||||
i18n.baseText('ndv.output.andSubExecutions', {
|
||||
adjustToNumber: activeTaskMetadata.subExecutionsCount,
|
||||
interpolate: { count: activeTaskMetadata.subExecutionsCount },
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</N8nText>
|
||||
<RunDataItemCount v-bind="itemsCountProps" />
|
||||
<ViewSubExecution
|
||||
v-if="activeTaskMetadata && !(paneType === 'input' && hasInputOverwrite)"
|
||||
:task-metadata="activeTaskMetadata"
|
||||
@@ -1771,6 +1775,7 @@ defineExpose({ enterEditMode });
|
||||
:total-runs="maxRunIndex"
|
||||
:has-default-hover-state="paneType === 'input' && !search"
|
||||
:search="search"
|
||||
:header-bg-color="tableHeaderBgColor"
|
||||
@mounted="emit('tableMounted', $event)"
|
||||
@active-row-changed="onItemHover"
|
||||
@display-mode-change="onDisplayModeChange"
|
||||
@@ -1947,6 +1952,8 @@ defineExpose({ enterEditMode });
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@import '@/styles/variables';
|
||||
|
||||
.infoIcon {
|
||||
color: var(--color-foreground-dark);
|
||||
}
|
||||
@@ -1970,7 +1977,6 @@ defineExpose({ enterEditMode });
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--color-run-data-background);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1994,6 +2000,11 @@ defineExpose({ enterEditMode });
|
||||
min-height: calc(30px + var(--spacing-s));
|
||||
scrollbar-width: thin;
|
||||
|
||||
.compact & {
|
||||
margin-bottom: var(--spacing-4xs);
|
||||
padding: var(--spacing-4xs) var(--spacing-s) 0 var(--spacing-s);
|
||||
}
|
||||
|
||||
> *:first-child {
|
||||
flex-grow: 1;
|
||||
}
|
||||
@@ -2055,18 +2066,6 @@ defineExpose({ enterEditMode });
|
||||
padding-right: var(--spacing-s);
|
||||
padding-bottom: var(--spacing-s);
|
||||
flex-flow: wrap;
|
||||
|
||||
.itemsText {
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.muted .itemsText {
|
||||
color: var(--color-text-light);
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.inputSelect {
|
||||
@@ -2183,6 +2182,7 @@ defineExpose({ enterEditMode });
|
||||
.displayModes {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
gap: var(--spacing-2xs);
|
||||
}
|
||||
@@ -2260,6 +2260,20 @@ defineExpose({ enterEditMode });
|
||||
.schema {
|
||||
padding: 0 var(--spacing-s);
|
||||
}
|
||||
|
||||
.search,
|
||||
.displayModeSelect {
|
||||
.compact & {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s $ease-out-expo;
|
||||
}
|
||||
|
||||
.compact:hover & {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { createTestNode, createTestTaskData, createTestWorkflowObject } from '@/__tests__/mocks';
|
||||
import {
|
||||
createTestLogEntry,
|
||||
createTestNode,
|
||||
createTestTaskData,
|
||||
createTestWorkflowObject,
|
||||
} from '@/__tests__/mocks';
|
||||
import {
|
||||
createAiData,
|
||||
findLogEntryToAutoSelect,
|
||||
getTreeNodeData,
|
||||
createLogEntries,
|
||||
type TreeNode,
|
||||
} from '@/components/RunDataAi/utils';
|
||||
import {
|
||||
AGENT_LANGCHAIN_NODE_TYPE,
|
||||
@@ -13,19 +17,6 @@ import {
|
||||
NodeConnectionTypes,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
function createTestLogEntry(data: Partial<TreeNode>): TreeNode {
|
||||
return {
|
||||
node: 'test node',
|
||||
runIndex: 0,
|
||||
id: String(Math.random()),
|
||||
children: [],
|
||||
consumedTokens: { completionTokens: 0, totalTokens: 0, promptTokens: 0, isEstimate: false },
|
||||
depth: 0,
|
||||
startTime: 0,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
describe(getTreeNodeData, () => {
|
||||
it('should generate one node per execution', () => {
|
||||
const workflow = createTestWorkflowObject({
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { type NodePanelType, type IRunDataDisplayMode } from '@/Interface';
|
||||
import { N8nIcon, N8nRadioButtons } from '@n8n/design-system';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const { compact, value, hasBinaryData, paneType, nodeGeneratesHtml } = defineProps<{
|
||||
compact: boolean;
|
||||
value: IRunDataDisplayMode;
|
||||
hasBinaryData: boolean;
|
||||
paneType: NodePanelType;
|
||||
nodeGeneratesHtml: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ change: [IRunDataDisplayMode] }>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const options = computed(() => {
|
||||
const defaults: Array<{ label: string; value: IRunDataDisplayMode }> = [
|
||||
{ label: i18n.baseText('runData.schema'), value: 'schema' },
|
||||
{ label: i18n.baseText('runData.table'), value: 'table' },
|
||||
{ label: i18n.baseText('runData.json'), value: 'json' },
|
||||
];
|
||||
|
||||
if (hasBinaryData) {
|
||||
defaults.push({ label: i18n.baseText('runData.binary'), value: 'binary' });
|
||||
}
|
||||
|
||||
if (paneType === 'output' && nodeGeneratesHtml) {
|
||||
defaults.unshift({ label: 'HTML', value: 'html' });
|
||||
}
|
||||
|
||||
return defaults;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nRadioButtons
|
||||
:model-value="value"
|
||||
:options="options"
|
||||
data-test-id="ndv-run-data-display-mode"
|
||||
@update:model-value="(selected) => emit('change', selected)"
|
||||
>
|
||||
<template v-if="compact" #option="option">
|
||||
<N8nIcon v-if="option.value === 'table'" icon="table" size="small" :class="$style.icon" />
|
||||
<N8nIcon v-else-if="option.value === 'json'" icon="json" size="small" :class="$style.icon" />
|
||||
<N8nIcon
|
||||
v-else-if="option.value === 'binary'"
|
||||
icon="binary"
|
||||
size="small"
|
||||
:class="$style.icon"
|
||||
/>
|
||||
<N8nIcon
|
||||
v-else-if="option.value === 'schema'"
|
||||
icon="schema"
|
||||
size="small"
|
||||
:class="$style.icon"
|
||||
/>
|
||||
<N8nIcon v-else-if="option.value === 'html'" icon="html" size="small" :class="$style.icon" />
|
||||
<span v-else>{{ option.label }}</span>
|
||||
</template>
|
||||
</N8nRadioButtons>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.icon {
|
||||
padding-inline: var(--spacing-2xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { N8nText } from '@n8n/design-system';
|
||||
|
||||
const {
|
||||
dataCount,
|
||||
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 : '']">
|
||||
{{
|
||||
i18n.baseText('ndv.search.items', {
|
||||
adjustToNumber: unfilteredDataCount,
|
||||
interpolate: { matched: dataCount, count: unfilteredDataCount },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
<N8nText v-else :class="[$style.itemsText, muted ? $style.muted : '']">
|
||||
<span>
|
||||
{{
|
||||
i18n.baseText('ndv.output.items', {
|
||||
adjustToNumber: dataCount,
|
||||
interpolate: { count: dataCount },
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-if="subExecutionsCount > 0">
|
||||
{{
|
||||
i18n.baseText('ndv.output.andSubExecutions', {
|
||||
adjustToNumber: subExecutionsCount,
|
||||
interpolate: { count: subExecutionsCount },
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</N8nText>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.itemsText {
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-light);
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
</style>
|
||||
@@ -23,7 +23,7 @@ const LazyRunDataJsonActions = defineAsyncComponent(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
editMode: { enabled?: boolean; value?: string };
|
||||
pushRef: string;
|
||||
pushRef?: string;
|
||||
paneType: string;
|
||||
node: INodeUi;
|
||||
inputData: INodeExecutionData[];
|
||||
|
||||
@@ -25,7 +25,7 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
node: INodeUi;
|
||||
paneType: string;
|
||||
pushRef: string;
|
||||
pushRef?: string;
|
||||
distanceFromActive: number;
|
||||
selectedJsonPath: string;
|
||||
jsonData: IDataObject[];
|
||||
|
||||
@@ -32,6 +32,7 @@ type Props = {
|
||||
mappingEnabled?: boolean;
|
||||
hasDefaultHoverState?: boolean;
|
||||
search?: string;
|
||||
headerBgColor?: 'base' | 'light';
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -41,6 +42,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
mappingEnabled: false,
|
||||
hasDefaultHoverState: false,
|
||||
search: '',
|
||||
headerBgColor: 'base',
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
activeRowChanged: [row: number | null];
|
||||
@@ -422,7 +424,12 @@ watch(focusedMappableInput, (curr) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[$style.dataDisplay, { [$style.highlight]: highlight }]">
|
||||
<div
|
||||
:class="[
|
||||
$style.dataDisplay,
|
||||
{ [$style.highlight]: highlight, [$style.lightHeader]: headerBgColor === 'light' },
|
||||
]"
|
||||
>
|
||||
<table v-if="tableData.columns && tableData.columns.length === 0" :class="$style.table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -680,7 +687,8 @@ watch(focusedMappableInput, (curr) => {
|
||||
border-collapse: separate;
|
||||
text-align: left;
|
||||
width: calc(100%);
|
||||
font-size: var(--font-size-s);
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-base);
|
||||
|
||||
th {
|
||||
background-color: var(--color-background-base);
|
||||
@@ -691,11 +699,19 @@ watch(focusedMappableInput, (curr) => {
|
||||
top: 0;
|
||||
color: var(--color-text-dark);
|
||||
z-index: 1;
|
||||
|
||||
.lightHeader & {
|
||||
background-color: var(--color-background-light);
|
||||
}
|
||||
|
||||
&.tableRightMargin {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: top;
|
||||
padding: var(--spacing-2xs) var(--spacing-2xs) var(--spacing-2xs) var(--spacing-3xs);
|
||||
padding: var(--spacing-4xs) var(--spacing-3xs);
|
||||
border-bottom: var(--border-base);
|
||||
border-left: var(--border-base);
|
||||
overflow-wrap: break-word;
|
||||
@@ -743,7 +759,7 @@ watch(focusedMappableInput, (curr) => {
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-2xs);
|
||||
padding: var(--spacing-4xs) var(--spacing-3xs);
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
@@ -828,7 +844,6 @@ watch(focusedMappableInput, (curr) => {
|
||||
|
||||
.tableRightMargin {
|
||||
// becomes necessary with large tables
|
||||
background-color: var(--color-background-base) !important;
|
||||
width: var(--spacing-s);
|
||||
border-right: none !important;
|
||||
border-top: none !important;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
exports[`InputPanel > should render 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="run-data container"
|
||||
class="run-data container runData"
|
||||
data-test-id="ndv-input-panel"
|
||||
data-v-2e5cd75c=""
|
||||
>
|
||||
@@ -38,7 +38,9 @@ exports[`InputPanel > should render 1`] = `
|
||||
class="button active medium"
|
||||
data-test-id="radio-button-mapping"
|
||||
>
|
||||
|
||||
Mapping
|
||||
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
@@ -51,7 +53,9 @@ exports[`InputPanel > should render 1`] = `
|
||||
class="button medium"
|
||||
data-test-id="radio-button-debugging"
|
||||
>
|
||||
|
||||
Debugging
|
||||
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -65,7 +69,7 @@ exports[`InputPanel > should render 1`] = `
|
||||
>
|
||||
<!---->
|
||||
<div
|
||||
class="n8n-radio-buttons radioGroup"
|
||||
class="n8n-radio-buttons radioGroup displayModeSelect"
|
||||
data-test-id="ndv-run-data-display-mode"
|
||||
data-v-2e5cd75c=""
|
||||
role="radiogroup"
|
||||
@@ -81,7 +85,9 @@ exports[`InputPanel > should render 1`] = `
|
||||
class="button active medium"
|
||||
data-test-id="radio-button-schema"
|
||||
>
|
||||
|
||||
Schema
|
||||
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
@@ -94,7 +100,9 @@ exports[`InputPanel > should render 1`] = `
|
||||
class="button medium"
|
||||
data-test-id="radio-button-table"
|
||||
>
|
||||
|
||||
Table
|
||||
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
@@ -107,13 +115,16 @@ exports[`InputPanel > should render 1`] = `
|
||||
class="button medium"
|
||||
data-test-id="radio-button-json"
|
||||
>
|
||||
|
||||
JSON
|
||||
|
||||
</div>
|
||||
</label>
|
||||
|
||||
</div>
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<div
|
||||
class="editModeActions"
|
||||
data-v-2e5cd75c=""
|
||||
|
||||
Reference in New Issue
Block a user