perf(editor): Virtualize SchemaView (#11694)

This commit is contained in:
Raúl Gómez Morales 2024-11-28 11:04:24 +01:00 committed by GitHub
parent 3a5bd12945
commit 9c6def9197
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2660 additions and 94 deletions

View file

@ -172,4 +172,5 @@ export interface FrontendSettings {
blockFileAccessToN8nFiles: boolean;
};
betaFeatures: FrontendBetaFeatures[];
virtualSchemaView: boolean;
}

View file

@ -405,4 +405,11 @@ export const schema = {
doc: 'Set this to 1 to enable the new partial execution logic by default.',
},
},
virtualSchemaView: {
doc: 'Whether to display the virtualized schema view',
format: Boolean,
default: false,
env: 'N8N_VIRTUAL_SCHEMA_VIEW',
},
};

View file

@ -231,6 +231,7 @@ export class FrontendService {
blockFileAccessToN8nFiles: this.securityConfig.blockFileAccessToN8nFiles,
},
betaFeatures: this.frontendConfig.betaFeatures,
virtualSchemaView: config.getEnv('virtualSchemaView'),
};
}

View file

@ -79,6 +79,7 @@
"vue-json-pretty": "2.2.4",
"vue-markdown-render": "catalog:frontend",
"vue-router": "catalog:frontend",
"vue-virtual-scroller": "2.0.0-beta.8",
"vue3-touch-events": "^4.1.3",
"xss": "catalog:"
},

View file

@ -126,4 +126,5 @@ export const defaultSettings: FrontendSettings = {
enabled: false,
},
betaFeatures: [],
virtualSchemaView: false,
};

View file

@ -46,6 +46,7 @@ import { useExternalHooks } from '@/composables/useExternalHooks';
import { useI18n } from '@/composables/useI18n';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useNodeType } from '@/composables/useNodeType';
import { useSettingsStore } from '@/stores/settings.store';
import type { PinDataSource, UnpinDataSource } from '@/composables/usePinnedData';
import { usePinnedData } from '@/composables/usePinnedData';
import { useTelemetry } from '@/composables/useTelemetry';
@ -86,8 +87,11 @@ const LazyRunDataTable = defineAsyncComponent(
const LazyRunDataJson = defineAsyncComponent(
async () => await import('@/components/RunDataJson.vue'),
);
const LazyRunDataSchema = defineAsyncComponent(
async () => await import('@/components/RunDataSchema.vue'),
const LazyRunDataSchema = defineAsyncComponent(async () =>
useSettingsStore().settings.virtualSchemaView
? await import('@/components/VirtualSchema.vue')
: await import('@/components/RunDataSchema.vue'),
);
const LazyRunDataHtml = defineAsyncComponent(
async () => await import('@/components/RunDataHtml.vue'),

View file

@ -0,0 +1,439 @@
import { createComponentRenderer } from '@/__tests__/render';
import VirtualSchema from '@/components/VirtualSchema.vue';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { userEvent } from '@testing-library/user-event';
import { cleanup, waitFor } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import {
createTestNode,
defaultNodeDescriptions,
mockNodeTypeDescription,
} from '@/__tests__/mocks';
import { IF_NODE_TYPE, SET_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { mock } from 'vitest-mock-extended';
import type { IWorkflowDb } from '@/Interface';
import { NodeConnectionType, type IDataObject, type INodeExecutionData } from 'n8n-workflow';
import * as nodeHelpers from '@/composables/useNodeHelpers';
import { useNDVStore } from '@/stores/ndv.store';
import { fireEvent } from '@testing-library/dom';
import { useTelemetry } from '@/composables/useTelemetry';
const mockNode1 = createTestNode({
name: 'Manual Trigger',
type: MANUAL_TRIGGER_NODE_TYPE,
typeVersion: 1,
disabled: false,
});
const mockNode2 = createTestNode({
name: 'Set2',
type: SET_NODE_TYPE,
typeVersion: 1,
disabled: false,
});
const disabledNode = createTestNode({
name: 'Disabled Node',
type: SET_NODE_TYPE,
typeVersion: 1,
disabled: true,
});
const ifNode = createTestNode({
name: 'If',
type: IF_NODE_TYPE,
typeVersion: 1,
disabled: false,
});
const aiTool = createTestNode({
name: 'AI Tool',
type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
typeVersion: 1,
disabled: false,
});
const unknownNodeType = createTestNode({
name: 'Unknown Node Type',
type: 'unknown',
});
const defaultNodes = [
{ name: 'Manual Trigger', indicies: [], depth: 1 },
{ name: 'Set2', indicies: [], depth: 2 },
];
async function setupStore() {
const workflow = mock<IWorkflowDb>({
id: '123',
name: 'Test Workflow',
connections: {},
active: true,
nodes: [mockNode1, mockNode2, disabledNode, ifNode, aiTool, unknownNodeType],
});
const pinia = createPinia();
setActivePinia(pinia);
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
nodeTypesStore.setNodeTypes([
...defaultNodeDescriptions,
mockNodeTypeDescription({
name: MANUAL_TRIGGER_NODE_TYPE,
outputs: [NodeConnectionType.Main],
}),
mockNodeTypeDescription({
name: IF_NODE_TYPE,
outputs: [NodeConnectionType.Main, NodeConnectionType.Main],
}),
]);
workflowsStore.workflow = workflow;
return pinia;
}
function mockNodeOutputData(nodeName: string, data: IDataObject[], outputIndex = 0) {
const originalNodeHelpers = nodeHelpers.useNodeHelpers();
vi.spyOn(nodeHelpers, 'useNodeHelpers').mockImplementation(() => {
return {
...originalNodeHelpers,
getNodeInputData: vi.fn((node, _, output) => {
if (node.name === nodeName && output === outputIndex) {
return data.map((json) => ({ json }));
}
return [];
}),
};
});
}
describe('RunDataSchema.vue', () => {
let renderComponent: ReturnType<typeof createComponentRenderer>;
const DynamicScrollerStub = {
props: {
items: Array,
},
template:
'<div><template v-for="item in items"><slot v-bind="{ item }"></slot></template></div>',
methods: {
scrollToItem: vi.fn(),
},
};
const DynamicScrollerItemStub = {
template: '<slot></slot>',
};
beforeEach(async () => {
cleanup();
renderComponent = createComponentRenderer(VirtualSchema, {
global: {
stubs: {
DynamicScroller: DynamicScrollerStub,
DynamicScrollerItem: DynamicScrollerItemStub,
FontAwesomeIcon: true,
},
},
pinia: await setupStore(),
props: {
mappingEnabled: true,
runIndex: 1,
outputIndex: 0,
totalRuns: 2,
paneType: 'input',
connectionType: 'main',
search: '',
nodes: defaultNodes,
},
});
});
it('renders schema for empty data', async () => {
const { getAllByText, getAllByTestId } = renderComponent();
expect(getAllByText("No fields - item(s) exist, but they're empty").length).toBe(2);
// Collapse second node
await userEvent.click(getAllByTestId('run-data-schema-header')[1]);
expect(getAllByText("No fields - item(s) exist, but they're empty").length).toBe(1);
});
it('renders schema for data', async () => {
useWorkflowsStore().pinData({
node: mockNode1,
data: [
{ json: { name: 'John', age: 22, hobbies: ['surfing', 'traveling'] } },
{ json: { name: 'Joe', age: 33, hobbies: ['skateboarding', 'gaming'] } },
],
});
useWorkflowsStore().pinData({
node: mockNode2,
data: [
{ json: { name: 'John', age: 22, hobbies: ['surfing', 'traveling'] } },
{ json: { name: 'Joe', age: 33, hobbies: ['skateboarding', 'gaming'] } },
],
});
const { getAllByTestId } = renderComponent();
const headers = getAllByTestId('run-data-schema-header');
expect(headers.length).toBe(2);
expect(headers[0]).toHaveTextContent('Manual Trigger');
expect(headers[0]).toHaveTextContent('2 items');
expect(headers[1]).toHaveTextContent('Set2');
const items = getAllByTestId('run-data-schema-item');
expect(items[0]).toHaveTextContent('nameJohn');
expect(items[1]).toHaveTextContent('age22');
expect(items[2]).toHaveTextContent('hobbies');
expect(items[3]).toHaveTextContent('hobbies[0]surfing');
expect(items[4]).toHaveTextContent('hobbies[1]traveling');
});
it('renders schema in output pane', async () => {
const { container } = renderComponent({
props: {
nodes: [],
paneType: 'output',
node: mockNode1,
data: [
{ name: 'John', age: 22, hobbies: ['surfing', 'traveling'] },
{ name: 'Joe', age: 33, hobbies: ['skateboarding', 'gaming'] },
],
},
});
expect(container).toMatchSnapshot();
});
it('renders schema with spaces and dots', () => {
useWorkflowsStore().pinData({
node: mockNode1,
data: [
{
json: {
'hello world': [
{
test: {
'more to think about': 1,
},
'test.how': 'ignore',
},
],
},
},
],
});
const { container } = renderComponent();
expect(container).toMatchSnapshot();
});
it('renders no data to show for data empty objects', () => {
useWorkflowsStore().pinData({
node: mockNode1,
data: [{ json: {} }, { json: {} }],
});
const { getAllByText } = renderComponent();
expect(getAllByText("No fields - item(s) exist, but they're empty").length).toBe(2);
});
// this can happen when setting the output to [{}]
it('renders empty state to show for empty data', () => {
useWorkflowsStore().pinData({
node: mockNode1,
data: [{} as INodeExecutionData],
});
const { getAllByText } = renderComponent({ props: { paneType: 'output' } });
expect(getAllByText("No fields - item(s) exist, but they're empty").length).toBe(1);
});
it('renders disabled nodes correctly', () => {
const { getByTestId } = renderComponent({
props: {
nodes: [{ name: disabledNode.name, indicies: [], depth: 1 }],
},
});
expect(getByTestId('run-data-schema-header')).toHaveTextContent(
`${disabledNode.name} (Deactivated)`,
);
});
it('renders schema for correct output branch', async () => {
mockNodeOutputData(
'If',
[
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
],
1,
);
const { getByTestId } = renderComponent({
props: {
nodes: [{ name: 'If', indicies: [1], depth: 2 }],
},
});
await waitFor(() => {
expect(getByTestId('run-data-schema-header')).toHaveTextContent('If');
expect(getByTestId('run-data-schema-header')).toHaveTextContent('2 items');
expect(getByTestId('run-data-schema-header')).toMatchSnapshot();
});
});
it('renders previous nodes schema for AI tools', async () => {
mockNodeOutputData(
'If',
[
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
],
0,
);
const { getByTestId } = renderComponent({
props: {
nodes: [
{
name: 'If',
indicies: [], // indicies are not set for AI tools
depth: 2,
},
],
node: aiTool,
},
});
await waitFor(() => {
expect(getByTestId('run-data-schema-header')).toHaveTextContent('If');
expect(getByTestId('run-data-schema-header')).toHaveTextContent('2 items');
expect(getByTestId('run-data-schema-header')).toMatchSnapshot();
});
});
it('renders its own data for AI tools in debug mode', async () => {
const { getByTestId } = renderComponent({
props: {
nodes: [], // in debug mode nodes are empty
node: aiTool,
data: [{ output: 'AI tool output' }],
},
});
expect(getByTestId('run-data-schema-item')).toHaveTextContent('AI tool output');
});
test.each([[[{ tx: false }, { tx: false }]], [[{ tx: '' }, { tx: '' }]], [[{ tx: [] }]]])(
'renders schema instead of showing no data for %o',
(data) => {
useWorkflowsStore().pinData({
node: mockNode1,
data: data.map((item) => ({ json: item })),
});
const { getAllByTestId } = renderComponent();
expect(getAllByTestId('run-data-schema-item')[0]).toHaveTextContent('tx');
},
);
it('should filter invalid connections', () => {
const { pinData } = useWorkflowsStore();
pinData({
node: mockNode1,
data: [{ json: { tx: 1 } }],
});
pinData({
node: mockNode2,
data: [{ json: { tx: 2 } }],
});
const { getAllByTestId } = renderComponent({
props: {
nodes: [
{ name: mockNode1.name, indicies: [], depth: 1 },
{ name: 'unknown', indicies: [], depth: 1 },
{ name: mockNode2.name, indicies: [], depth: 1 },
{ name: unknownNodeType.name, indicies: [], depth: 1 },
],
},
});
expect(getAllByTestId('run-data-schema-item').length).toBe(2);
});
it('should show connections', () => {
const ndvStore = useNDVStore();
vi.spyOn(ndvStore, 'ndvNodeInputNumber', 'get').mockReturnValue({
[defaultNodes[0].name]: [0],
[defaultNodes[1].name]: [0, 1, 2],
});
const { getAllByTestId } = renderComponent();
const headers = getAllByTestId('run-data-schema-header');
expect(headers.length).toBe(2);
expect(headers[0]).toHaveTextContent('Input 0');
expect(headers[1]).toHaveTextContent('Inputs 0, 1, 2');
});
it('should handle drop event', async () => {
const ndvStore = useNDVStore();
useWorkflowsStore().pinData({
node: mockNode1,
data: [{ json: { name: 'John', age: 22, hobbies: ['surfing', 'traveling'] } }],
});
const telemetry = useTelemetry();
const trackSpy = vi.spyOn(telemetry, 'track');
const reset = vi.spyOn(ndvStore, 'resetMappingTelemetry');
const { getAllByTestId } = renderComponent();
const items = getAllByTestId('run-data-schema-item');
expect(items.length).toBe(6);
expect(items[0].className).toBe('schema-item draggable');
expect(items[0]).toHaveTextContent('nameJohn');
const pill = items[0].querySelector('.pill') as Element;
fireEvent(pill, new MouseEvent('mousedown', { bubbles: true }));
fireEvent(window, new MouseEvent('mousemove', { bubbles: true }));
expect(reset).toHaveBeenCalled();
fireEvent(window, new MouseEvent('mouseup', { bubbles: true }));
await waitFor(() =>
expect(trackSpy).toHaveBeenCalledWith(
'User dragged data for mapping',
expect.any(Object),
expect.any(Object),
),
);
});
it('should expand all nodes when searching', async () => {
useWorkflowsStore().pinData({
node: mockNode1,
data: [{ json: { name: 'John' } }],
});
useWorkflowsStore().pinData({
node: mockNode2,
data: [{ json: { name: 'John' } }],
});
const { getAllByTestId, queryAllByTestId, rerender } = renderComponent();
const headers = getAllByTestId('run-data-schema-header');
expect(headers.length).toBe(2);
expect(getAllByTestId('run-data-schema-item').length).toBe(2);
// Collapse all nodes
await Promise.all(headers.map(async ($header) => await userEvent.click($header)));
expect(queryAllByTestId('run-data-schema-item').length).toBe(0);
await rerender({ search: 'John' });
expect(getAllByTestId('run-data-schema-item').length).toBe(2);
});
});

View file

@ -0,0 +1,292 @@
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
import type { INodeUi } from '@/Interface';
import VirtualSchemaItem from '@/components/VirtualSchemaItem.vue';
import VirtualSchemaHeader from '@/components/VirtualSchemaHeader.vue';
import { N8nText } from 'n8n-design-system';
import Draggable from '@/components/Draggable.vue';
import { useNDVStore } from '@/stores/ndv.store';
import { useTelemetry } from '@/composables/useTelemetry';
import { NodeConnectionType, type IConnectedNode, type IDataObject } from 'n8n-workflow';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useI18n } from '@/composables/useI18n';
import MappingPill from './MappingPill.vue';
import { useDataSchema, useFlattenSchema, type SchemaNode } from '@/composables/useDataSchema';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { executionDataToJson } from '@/utils/nodeTypesUtils';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import {
DynamicScroller,
DynamicScrollerItem,
type RecycleScrollerInstance,
} from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
type Props = {
nodes?: IConnectedNode[];
node?: INodeUi | null;
data?: IDataObject[];
mappingEnabled?: boolean;
runIndex?: number;
outputIndex?: number;
totalRuns?: number;
paneType: 'input' | 'output';
connectionType?: NodeConnectionType;
search?: string;
};
const props = withDefaults(defineProps<Props>(), {
nodes: () => [],
distanceFromActive: 1,
node: null,
data: () => [],
runIndex: 0,
outputIndex: 0,
totalRuns: 1,
connectionType: NodeConnectionType.Main,
search: '',
mappingEnabled: false,
});
const telemetry = useTelemetry();
const i18n = useI18n();
const ndvStore = useNDVStore();
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const { getSchemaForExecutionData, filterSchema } = useDataSchema();
const { closedNodes, flattenSchema, flattenMultipleSchemas, toggleLeaf, toggleNode } =
useFlattenSchema();
const { getNodeInputData } = useNodeHelpers();
const emit = defineEmits<{
'clear:search': [];
}>();
const scroller = ref<RecycleScrollerInstance>();
const toggleNodeAndScrollTop = (id: string) => {
toggleNode(id);
scroller.value?.scrollToItem(0);
};
watch(
() => props.search,
(newSearch) => {
if (!newSearch) return;
closedNodes.value.clear();
},
);
const getNodeSchema = (fullNode: INodeUi, connectedNode: IConnectedNode) => {
const pinData = workflowsStore.pinDataByNodeName(connectedNode.name);
const connectedOutputIndexes = connectedNode.indicies.length > 0 ? connectedNode.indicies : [0];
const data =
pinData ??
connectedOutputIndexes
.map((outputIndex) =>
executionDataToJson(
getNodeInputData(
fullNode,
props.runIndex,
outputIndex,
props.paneType,
props.connectionType,
),
),
)
.flat();
return {
schema: getSchemaForExecutionData(data),
connectedOutputIndexes,
itemsCount: data.length,
};
};
const nodeSchema = computed(() =>
filterSchema(getSchemaForExecutionData(props.data), props.search),
);
const nodesSchemas = computed<SchemaNode[]>(() => {
return props.nodes.reduce<SchemaNode[]>((acc, node) => {
const fullNode = workflowsStore.getNodeByName(node.name);
if (!fullNode) return acc;
const nodeType = nodeTypesStore.getNodeType(fullNode.type, fullNode.typeVersion);
if (!nodeType) return acc;
const { schema, connectedOutputIndexes, itemsCount } = getNodeSchema(fullNode, node);
const filteredSchema = filterSchema(schema, props.search);
if (!filteredSchema) return acc;
acc.push({
node: fullNode,
connectedOutputIndexes,
depth: node.depth,
itemsCount,
nodeType,
schema: filteredSchema,
});
return acc;
}, []);
});
const nodeAdditionalInfo = (node: INodeUi) => {
const returnData: string[] = [];
if (node.disabled) {
returnData.push(i18n.baseText('node.disabled'));
}
const connections = ndvStore.ndvNodeInputNumber[node.name];
if (connections) {
if (connections.length === 1) {
returnData.push(`Input ${connections}`);
} else {
returnData.push(`Inputs ${connections.join(', ')}`);
}
}
return returnData.length ? `(${returnData.join(' | ')})` : '';
};
const flattenedNodes = computed(() =>
flattenMultipleSchemas(nodesSchemas.value, nodeAdditionalInfo),
);
const flattenNodeSchema = computed(() =>
nodeSchema.value ? flattenSchema({ schema: nodeSchema.value, depth: 0, level: -1 }) : [],
);
/**
* In debug mode nodes are empty
*/
const isDebugging = computed(() => !props.nodes.length);
const items = computed(() => {
if (isDebugging.value || props.paneType === 'output') {
return flattenNodeSchema.value;
}
return flattenedNodes.value;
});
const noSearchResults = computed(() => {
return Boolean(props.search.trim()) && !Boolean(items.value.length);
});
const onDragStart = () => {
ndvStore.resetMappingTelemetry();
};
const onDragEnd = (el: HTMLElement) => {
setTimeout(() => {
const mappingTelemetry = ndvStore.mappingTelemetry;
const telemetryPayload = {
src_node_type: el.dataset.nodeType,
src_field_name: el.dataset.name ?? '',
src_nodes_back: el.dataset.depth,
src_run_index: props.runIndex,
src_runs_total: props.totalRuns,
src_field_nest_level: el.dataset.level ?? 0,
src_view: 'schema',
src_element: el,
success: false,
...mappingTelemetry,
};
void useExternalHooks().run('runDataJson.onDragEnd', telemetryPayload);
telemetry.track('User dragged data for mapping', telemetryPayload, { withPostHog: true });
}, 250); // ensure dest data gets set if drop
};
</script>
<template>
<div class="run-data-schema full-height">
<div v-if="noSearchResults" class="no-results">
<N8nText tag="h3" size="large">{{ i18n.baseText('ndv.search.noNodeMatch.title') }}</N8nText>
<N8nText>
<i18n-t keypath="ndv.search.noMatch.description" tag="span">
<template #link>
<a href="#" @click="emit('clear:search')">
{{ i18n.baseText('ndv.search.noMatch.description.link') }}
</a>
</template>
</i18n-t>
</N8nText>
<N8nText v-if="paneType === 'output'">
{{ i18n.baseText('ndv.search.noMatchSchema.description') }}
</N8nText>
</div>
<Draggable
v-if="items.length"
class="full-height"
type="mapping"
target-data-key="mappable"
:disabled="!mappingEnabled"
@dragstart="onDragStart"
@dragend="onDragEnd"
>
<template #preview="{ canDrop, el }">
<MappingPill v-if="el" :html="el.outerHTML" :can-drop="canDrop" />
</template>
<DynamicScroller
ref="scroller"
:items="items"
:min-item-size="38"
class="full-height scroller"
>
<template #default="{ item, index, active }">
<VirtualSchemaHeader
v-if="item.type === 'header'"
v-bind="item"
:collapsed="closedNodes.has(item.id)"
@click:toggle="toggleLeaf(item.id)"
@click="toggleNodeAndScrollTop(item.id)"
/>
<DynamicScrollerItem
v-else
:item="item"
:active="active"
:size-dependencies="[item.value]"
:data-index="index"
>
<VirtualSchemaItem
v-bind="item"
:search="search"
:draggable="mappingEnabled"
:collapsed="closedNodes.has(item.id)"
:highlight="ndvStore.highlightDraggables"
@click="toggleLeaf(item.id)"
>
</VirtualSchemaItem>
</DynamicScrollerItem>
</template>
</DynamicScroller>
</Draggable>
</div>
</template>
<style lang="css" scoped>
.full-height {
height: 100%;
}
.run-data-schema {
padding: 0;
}
.scroller {
padding: 0 var(--spacing-s);
}
.no-results {
text-align: center;
padding: var(--spacing-s) var(--spacing-s) var(--spacing-xl) var(--spacing-s);
}
</style>

View file

@ -0,0 +1,104 @@
<script lang="ts" setup>
import { computed } from 'vue';
import NodeIcon from '@/components/NodeIcon.vue';
import { type INodeTypeDescription } from 'n8n-workflow';
import { useI18n } from '@/composables/useI18n';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
const props = defineProps<{
title: string;
info?: string;
collapsable: boolean;
collapsed: boolean;
nodeType: INodeTypeDescription;
itemCount: number | null;
}>();
const i18n = useI18n();
const isTrigger = computed(() => props.nodeType.group.includes('trigger'));
const emit = defineEmits<{
'click:toggle': [];
}>();
</script>
<template>
<div class="schema-header" data-test-id="run-data-schema-header">
<div class="toggle" @click.capture.stop="emit('click:toggle')">
<FontAwesomeIcon icon="angle-down" :class="{ 'collapse-icon': true, collapsed }" />
</div>
<NodeIcon
class="icon"
:class="{ ['icon-trigger']: isTrigger }"
:node-type="nodeType"
:size="12"
/>
<div class="title">
{{ title }}
<span v-if="info" class="info">{{ info }}</span>
</div>
<FontAwesomeIcon v-if="isTrigger" class="trigger-icon" icon="bolt" size="xs" />
<div v-if="itemCount" class="item-count" data-test-id="run-data-schema-node-item-count">
{{ i18n.baseText('ndv.output.items', { interpolate: { count: itemCount } }) }}
</div>
</div>
</template>
<style lang="scss" scoped>
.schema-header {
display: flex;
align-items: center;
padding-bottom: var(--spacing-2xs);
cursor: pointer;
}
.toggle {
width: 30px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
}
.collapse-icon {
transition: transform 0.2s cubic-bezier(0.19, 1, 0.22, 1);
}
.collapsed {
transform: rotateZ(-90deg);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-3xs);
border: 1px solid var(--color-foreground-light);
border-radius: var(--border-radius-base);
background-color: var(--color-background-xlight);
margin-right: var(--spacing-2xs);
}
.icon-trigger {
border-radius: 16px 4px 4px 16px;
}
.title {
font-size: var(--font-size-2xs);
color: var(--color-text-dark);
}
.info {
margin-left: var(--spacing-2xs);
color: var(--color-text-light);
font-weight: var(--font-weight-regular);
}
.trigger-icon {
margin-left: var(--spacing-2xs);
color: var(--color-primary);
}
.item-count {
font-size: var(--font-size-2xs);
color: var(--color-text-light);
margin-left: auto;
}
</style>

View file

@ -0,0 +1,142 @@
<script lang="ts" setup>
import TextWithHighlights from './TextWithHighlights.vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
type Props = {
title?: string;
path?: string;
level?: number;
depth?: number;
expression?: string;
value?: string;
id: string;
icon: string;
collapsable?: boolean;
nodeType?: string;
highlight?: boolean;
draggable?: boolean;
collapsed?: boolean;
search?: string;
};
const props = defineProps<Props>();
const emit = defineEmits<{
click: [];
}>();
</script>
<template>
<div class="schema-item" :class="{ draggable }" data-test-id="run-data-schema-item">
<div class="toggle-container">
<div v-if="collapsable" class="toggle" @click="emit('click')">
<FontAwesomeIcon icon="angle-down" :class="{ 'collapse-icon': true, collapsed }" />
</div>
</div>
<div
v-if="title"
:data-name="title"
:data-path="path"
:data-depth="depth"
:data-nest-level="level"
:data-value="expression"
:data-node-type="nodeType"
data-target="mappable"
class="pill"
:class="{ 'pill--highlight': highlight }"
data-test-id="run-data-schema-node-name"
>
<FontAwesomeIcon class="type-icon" :icon size="sm" />
<TextWithHighlights class="title" :content="title" :search="props.search" />
</div>
<TextWithHighlights
data-test-id="run-data-schema-item-value"
class="text"
:content="value"
:search="props.search"
/>
</div>
</template>
<style lang="css" scoped>
.schema-item {
display: flex;
margin-left: calc(var(--spacing-l) * v-bind(level));
align-items: baseline;
padding-bottom: var(--spacing-2xs);
}
.toggle-container {
min-width: var(--spacing-l);
min-height: 17px;
}
.toggle {
cursor: pointer;
display: flex;
justify-content: center;
cursor: pointer;
font-size: var(--font-size-s);
}
.pill {
display: inline-flex;
height: 24px;
padding: 0 var(--spacing-3xs);
border: 1px solid var(--color-foreground-light);
border-radius: var(--border-radius-base);
background-color: var(--color-background-xlight);
font-size: var(--font-size-2xs);
color: var(--color-text-dark);
max-width: 50%;
align-items: center;
> *:not(:first-child) {
margin-left: var(--spacing-3xs);
padding-left: var(--spacing-3xs);
border-left: 1px solid var(--color-foreground-light);
}
}
.draggable .pill.pill--highlight {
color: var(--color-primary);
border-color: var(--color-primary-tint-1);
background-color: var(--color-primary-tint-3);
}
.draggable .pill.pill--highlight .type-icon {
color: var(--color-primary);
}
.draggable .pill {
cursor: grab;
}
.draggable .pill:hover {
background-color: var(--color-background-light);
border-color: var(--color-foreground-base);
}
.type-icon {
color: var(--color-text-light);
}
.title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
}
.text {
font-weight: var(--font-weight-normal);
font-size: var(--font-size-2xs);
margin-left: var(--spacing-2xs);
word-break: break-word;
}
.collapse-icon {
transition: transform 0.2s cubic-bezier(0.19, 1, 0.22, 1);
}
.collapsed {
transform: rotateZ(-90deg);
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import jp from 'jsonpath';
import { useDataSchema } from '@/composables/useDataSchema';
import { useDataSchema, useFlattenSchema } from '@/composables/useDataSchema';
import type { IExecutionResponse, INodeUi, Schema } from '@/Interface';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
@ -649,3 +649,39 @@ describe('useDataSchema', () => {
);
});
});
describe('useFlattenSchema', () => {
it('flattens a schema', () => {
const schema: Schema = {
path: '',
type: 'object',
value: [
{
key: 'obj',
path: '.obj',
type: 'object',
value: [
{
key: 'foo',
path: '.obj.foo',
type: 'object',
value: [
{
key: 'nested',
path: '.obj.foo.nested',
type: 'string',
value: 'bar',
},
],
},
],
},
],
};
expect(
useFlattenSchema().flattenSchema({
schema,
}).length,
).toBe(3);
});
});

View file

@ -1,15 +1,18 @@
import { ref } from 'vue';
import type { Optional, Primitives, Schema, INodeUi } from '@/Interface';
import {
type ITaskDataConnections,
type IDataObject,
type INodeExecutionData,
type INodeTypeDescription,
NodeConnectionType,
} from 'n8n-workflow';
import { merge } from 'lodash-es';
import { generatePath } from '@/utils/mappingUtils';
import { generatePath, getMappedExpression } from '@/utils/mappingUtils';
import { isObj } from '@/utils/typeGuards';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { isPresent } from '@/utils/typesUtils';
import { isPresent, shorten } from '@/utils/typesUtils';
import { useI18n } from '@/composables/useI18n';
export function useDataSchema() {
function getSchema(
@ -166,3 +169,208 @@ export function useDataSchema() {
filterSchema,
};
}
export type SchemaNode = {
node: INodeUi;
nodeType: INodeTypeDescription;
depth: number;
connectedOutputIndexes: number[];
itemsCount: number;
schema: Schema;
};
export type RenderItem = {
title?: string;
path?: string;
level?: number;
depth?: number;
expression?: string;
value?: string;
id: string;
icon: string;
collapsable?: boolean;
nodeType?: INodeUi['type'];
type: 'item';
};
export type RenderHeader = {
id: string;
title: string;
info?: string;
collapsable: boolean;
nodeType: INodeTypeDescription;
itemCount: number | null;
type: 'header';
};
type Renders = RenderHeader | RenderItem;
const icons = {
object: 'cube',
array: 'list',
['string']: 'font',
null: 'font',
['number']: 'hashtag',
['boolean']: 'check-square',
function: 'code',
bigint: 'calculator',
symbol: 'sun',
['undefined']: 'ban',
} as const;
const getIconBySchemaType = (type: Schema['type']): string => icons[type];
const emptyItem = (): RenderItem => ({
id: `empty-${window.crypto.randomUUID()}`,
icon: '',
value: useI18n().baseText('dataMapping.schemaView.emptyData'),
type: 'item',
});
const isDataEmpty = (schema: Schema) => {
// Utilize the generated schema instead of looping over the entire data again
// The schema for empty data is { type: 'object', value: [] }
const isObjectOrArray = schema.type === 'object';
const isEmpty = Array.isArray(schema.value) && schema.value.length === 0;
return isObjectOrArray && isEmpty;
};
const prefixTitle = (title: string, prefix?: string) => (prefix ? `${prefix}[${title}]` : title);
export const useFlattenSchema = () => {
const closedNodes = ref<Set<string>>(new Set());
const headerIds = ref<Set<string>>(new Set());
const toggleLeaf = (id: string) => {
if (closedNodes.value.has(id)) {
closedNodes.value.delete(id);
} else {
closedNodes.value.add(id);
}
};
const toggleNode = (id: string) => {
if (closedNodes.value.has(id)) {
closedNodes.value = new Set(headerIds.value);
closedNodes.value.delete(id);
} else {
closedNodes.value.add(id);
}
};
const flattenSchema = ({
schema,
node = { name: '', type: '' },
depth = 0,
prefix = '',
level = 0,
}: {
schema: Schema;
node?: { name: string; type: string };
depth?: number;
prefix?: string;
level?: number;
}): RenderItem[] => {
// only show empty item for the first level
if (isDataEmpty(schema) && depth <= 0) {
return [emptyItem()];
}
const expression = getMappedExpression({
nodeName: node.name,
distanceFromActive: depth,
path: schema.path,
});
if (Array.isArray(schema.value)) {
const items: RenderItem[] = [];
if (schema.key) {
items.push({
title: prefixTitle(schema.key, prefix),
path: schema.path,
expression,
depth,
level,
icon: getIconBySchemaType(schema.type),
id: expression,
collapsable: true,
nodeType: node.type,
type: 'item',
});
}
if (closedNodes.value.has(expression)) {
return items;
}
return items.concat(
schema.value
.map((item) => {
const itemPrefix = schema.type === 'array' ? schema.key : '';
return flattenSchema({
schema: item,
node,
depth,
prefix: itemPrefix,
level: level + 1,
});
})
.flat(),
);
} else if (schema.key) {
return [
{
title: prefixTitle(schema.key, prefix),
path: schema.path,
expression,
level,
depth,
value: shorten(schema.value, 600, 0),
id: expression,
icon: getIconBySchemaType(schema.type),
collapsable: false,
nodeType: node.type,
type: 'item',
},
];
}
return [];
};
const flattenMultipleSchemas = (
nodes: SchemaNode[],
additionalInfo: (node: INodeUi) => string,
) => {
headerIds.value.clear();
return nodes.reduce<Renders[]>((acc, item) => {
acc.push({
title: item.node.name,
id: item.node.name,
collapsable: true,
nodeType: item.nodeType,
itemCount: item.itemsCount,
info: additionalInfo(item.node),
type: 'header',
});
headerIds.value.add(item.node.name);
if (closedNodes.value.has(item.node.name)) {
return acc;
}
if (isDataEmpty(item.schema)) {
acc.push(emptyItem());
return acc;
}
acc.push(...flattenSchema(item));
return acc;
}, []);
};
return { closedNodes, toggleLeaf, toggleNode, flattenSchema, flattenMultipleSchemas };
};

View file

@ -0,0 +1,112 @@
declare module 'vue-virtual-scroller' {
import {
type ObjectEmitsOptions,
type PublicProps,
type SetupContext,
type SlotsType,
type VNode,
} from 'vue';
interface RecycleScrollerProps<T> {
items: readonly T[];
direction?: 'vertical' | 'horizontal';
itemSize?: number | null;
gridItems?: number;
itemSecondarySize?: number;
minItemSize?: number;
sizeField?: string;
typeField?: string;
keyField?: keyof T;
pageMode?: boolean;
prerender?: number;
buffer?: number;
emitUpdate?: boolean;
updateInterval?: number;
listClass?: string;
itemClass?: string;
listTag?: string;
itemTag?: string;
}
interface DynamicScrollerProps<T> extends RecycleScrollerProps<T> {
minItemSize: number;
}
interface RecycleScrollerEmitOptions extends ObjectEmitsOptions {
resize: () => void;
visible: () => void;
hidden: () => void;
update: (
startIndex: number,
endIndex: number,
visibleStartIndex: number,
visibleEndIndex: number,
) => void;
'scroll-start': () => void;
'scroll-end': () => void;
}
interface RecycleScrollerSlotProps<T> {
item: T;
index: number;
active: boolean;
}
interface RecycleScrollerSlots<T> {
default(slotProps: RecycleScrollerSlotProps<T>): unknown;
before(): unknown;
empty(): unknown;
after(): unknown;
}
export interface RecycleScrollerInstance {
getScroll(): { start: number; end: number };
scrollToItem(index: number): void;
scrollToPosition(position: number): void;
}
export const RecycleScroller: <T>(
props: RecycleScrollerProps<T> & PublicProps,
ctx?: SetupContext<RecycleScrollerEmitOptions, SlotsType<RecycleScrollerSlots<T>>>,
expose?: (exposed: RecycleScrollerInstance) => void,
) => VNode & {
__ctx?: {
props: RecycleScrollerProps<T> & PublicProps;
expose(exposed: RecycleScrollerInstance): void;
slots: RecycleScrollerSlots<T>;
};
};
export const DynamicScroller: <T>(
props: DynamicScrollerProps<T> & PublicProps,
ctx?: SetupContext<RecycleScrollerEmitOptions, SlotsType<RecycleScrollerSlots<T>>>,
expose?: (exposed: RecycleScrollerInstance) => void,
) => VNode & {
__ctx?: {
props: DynamicScrollerProps<T> & PublicProps;
expose(exposed: RecycleScrollerInstance): void;
slots: RecycleScrollerSlots<T>;
};
};
interface DynamicScrollerItemProps<T> {
item: T;
active: boolean;
sizeDependencies?: unknown[];
watchData?: boolean;
tag?: string;
emitResize?: boolean;
onResize?: () => void;
}
interface DynamicScrollerItemEmitOptions extends ObjectEmitsOptions {
resize: () => void;
}
export const DynamicScrollerItem: <T>(
props: DynamicScrollerItemProps<T> & PublicProps,
ctx?: SetupContext<DynamicScrollerItemEmitOptions>,
) => VNode;
export function IdState(options?: { idProp?: (value: any) => unknown }): unknown;
}

View file

@ -1114,7 +1114,7 @@ importers:
dependencies:
'@langchain/core':
specifier: 'catalog:'
version: 0.3.15(openai@4.69.0(zod@3.23.8))
version: 0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8))
'@n8n/client-oauth2':
specifier: workspace:*
version: link:../@n8n/client-oauth2
@ -1513,6 +1513,9 @@ importers:
vue-router:
specifier: catalog:frontend
version: 4.4.5(vue@3.5.11(typescript@5.7.2))
vue-virtual-scroller:
specifier: 2.0.0-beta.8
version: 2.0.0-beta.8(vue@3.5.11(typescript@5.7.2))
vue3-touch-events:
specifier: ^4.1.3
version: 4.1.3
@ -1942,7 +1945,7 @@ importers:
devDependencies:
'@langchain/core':
specifier: 'catalog:'
version: 0.3.15(openai@4.69.0)
version: 0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8))
'@types/deep-equal':
specifier: ^1.0.1
version: 1.0.1
@ -9244,6 +9247,9 @@ packages:
resolution: {integrity: sha512-MGZAq0Q3OuRYgZKvlB69z4gLN4G3PvgC4A2zhkCXCXrLD5wm2cCnwNB59xOBVA+srZ0zEes6u+VylcPIkB4SqA==}
engines: {node: 6.* || 8.* || >= 10.*}
mitt@2.1.0:
resolution: {integrity: sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==}
mjml-accordion@4.15.3:
resolution: {integrity: sha512-LPNVSj1LyUVYT9G1gWwSw3GSuDzDsQCu0tPB2uDsq4VesYNnU6v3iLCQidMiR6azmIt13OEozG700ygAUuA6Ng==}
@ -11004,10 +11010,6 @@ packages:
resolution: {integrity: sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
source-map-js@1.0.2:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'}
source-map-js@1.2.0:
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
engines: {node: '>=0.10.0'}
@ -12134,6 +12136,16 @@ packages:
peerDependencies:
vue: ^3.3.4
vue-observe-visibility@2.0.0-alpha.1:
resolution: {integrity: sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==}
peerDependencies:
vue: ^3.0.0
vue-resize@2.0.0-alpha.1:
resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==}
peerDependencies:
vue: ^3.0.0
vue-router@4.4.5:
resolution: {integrity: sha512-4fKZygS8cH1yCyuabAXGUAsyi1b2/o/OKgu/RUb+znIYOxPRxdkytJEx+0wGcpBE1pX6vUgh5jwWOKRGvuA/7Q==}
peerDependencies:
@ -12145,6 +12157,11 @@ packages:
peerDependencies:
typescript: ^5.7.2
vue-virtual-scroller@2.0.0-beta.8:
resolution: {integrity: sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==}
peerDependencies:
vue: ^3.2.0
vue3-touch-events@4.1.3:
resolution: {integrity: sha512-uXTclRzn7de1mgiDIZ8N4J/wnWl1vBPLTWr60fqoLXu7ifhDKpl83Q2m9qA20KfEiAy+L4X/xXGc5ptGmdPh4A==}
@ -14667,38 +14684,6 @@ snapshots:
transitivePeerDependencies:
- openai
'@langchain/core@0.3.15(openai@4.69.0(zod@3.23.8))':
dependencies:
ansi-styles: 5.2.0
camelcase: 6.3.0
decamelize: 1.2.0
js-tiktoken: 1.0.12
langsmith: 0.2.3(openai@4.69.0(zod@3.23.8))
mustache: 4.2.0
p-queue: 6.6.2
p-retry: 4.6.2
uuid: 10.0.0
zod: 3.23.8
zod-to-json-schema: 3.23.3(zod@3.23.8)
transitivePeerDependencies:
- openai
'@langchain/core@0.3.15(openai@4.69.0)':
dependencies:
ansi-styles: 5.2.0
camelcase: 6.3.0
decamelize: 1.2.0
js-tiktoken: 1.0.12
langsmith: 0.2.3(openai@4.69.0)
mustache: 4.2.0
p-queue: 6.6.2
p-retry: 4.6.2
uuid: 10.0.0
zod: 3.23.8
zod-to-json-schema: 3.23.3(zod@3.23.8)
transitivePeerDependencies:
- openai
'@langchain/google-common@0.1.1(@langchain/core@0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)))(zod@3.23.8)':
dependencies:
'@langchain/core': 0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8))
@ -19264,7 +19249,7 @@ snapshots:
eslint-import-resolver-node@0.3.9:
dependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
is-core-module: 2.13.1
resolve: 1.22.8
transitivePeerDependencies:
@ -19289,7 +19274,7 @@ snapshots:
eslint-module-utils@2.8.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
optionalDependencies:
'@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.7.2)
eslint: 8.57.0
@ -19309,7 +19294,7 @@ snapshots:
array.prototype.findlastindex: 1.2.3
array.prototype.flat: 1.3.2
array.prototype.flatmap: 1.3.2
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
doctrine: 2.1.0
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
@ -20102,7 +20087,7 @@ snapshots:
array-parallel: 0.1.3
array-series: 0.1.5
cross-spawn: 4.0.2
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -20430,7 +20415,7 @@ snapshots:
isstream: 0.1.2
jsonwebtoken: 9.0.2
mime-types: 2.1.35
retry-axios: 2.6.0(axios@1.7.4(debug@4.3.7))
retry-axios: 2.6.0(axios@1.7.4)
tough-cookie: 4.1.3
transitivePeerDependencies:
- supports-color
@ -21272,7 +21257,7 @@ snapshots:
form-data: 4.0.0
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.0
https-proxy-agent: 7.0.2
https-proxy-agent: 7.0.5
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.7
parse5: 7.1.2
@ -21467,28 +21452,6 @@ snapshots:
optionalDependencies:
openai: 4.69.0(encoding@0.1.13)(zod@3.23.8)
langsmith@0.2.3(openai@4.69.0(zod@3.23.8)):
dependencies:
'@types/uuid': 10.0.0
commander: 10.0.1
p-queue: 6.6.2
p-retry: 4.6.2
semver: 7.6.0
uuid: 10.0.0
optionalDependencies:
openai: 4.69.0(zod@3.23.8)
langsmith@0.2.3(openai@4.69.0):
dependencies:
'@types/uuid': 10.0.0
commander: 10.0.1
p-queue: 6.6.2
p-retry: 4.6.2
semver: 7.6.0
uuid: 10.0.0
optionalDependencies:
openai: 4.69.0(zod@3.23.8)
lazy-ass@1.6.0: {}
ldapts@4.2.6:
@ -22002,6 +21965,8 @@ snapshots:
lodash: 4.17.21
pretender: 3.4.7
mitt@2.1.0: {}
mjml-accordion@4.15.3(encoding@0.1.13):
dependencies:
'@babel/runtime': 7.24.7
@ -22823,22 +22788,6 @@ snapshots:
- encoding
- supports-color
openai@4.69.0(zod@3.23.8):
dependencies:
'@types/node': 18.16.16
'@types/node-fetch': 2.6.4
abort-controller: 3.0.0
agentkeepalive: 4.2.1
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0(encoding@0.1.13)
optionalDependencies:
zod: 3.23.8
transitivePeerDependencies:
- encoding
- supports-color
optional: true
openapi-sampler@1.5.1:
dependencies:
'@types/json-schema': 7.0.15
@ -23019,7 +22968,7 @@ snapshots:
pdf-parse@1.1.1:
dependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
node-ensure: 0.0.0
transitivePeerDependencies:
- supports-color
@ -23823,9 +23772,9 @@ snapshots:
ret@0.1.15: {}
retry-axios@2.6.0(axios@1.7.4(debug@4.3.7)):
retry-axios@2.6.0(axios@1.7.4):
dependencies:
axios: 1.7.4(debug@4.3.7)
axios: 1.7.4
retry-request@7.0.2(encoding@0.1.13):
dependencies:
@ -23850,7 +23799,7 @@ snapshots:
rhea@1.0.24:
dependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -23988,7 +23937,7 @@ snapshots:
dependencies:
chokidar: 4.0.1
immutable: 4.2.2
source-map-js: 1.0.2
source-map-js: 1.2.1
sax@1.2.4: {}
@ -24270,8 +24219,6 @@ snapshots:
smart-buffer: 4.2.0
optional: true
source-map-js@1.0.2: {}
source-map-js@1.2.0: {}
source-map-js@1.2.1: {}
@ -25494,6 +25441,14 @@ snapshots:
markdown-it: 13.0.2
vue: 3.5.11(typescript@5.7.2)
vue-observe-visibility@2.0.0-alpha.1(vue@3.5.11(typescript@5.7.2)):
dependencies:
vue: 3.5.11(typescript@5.7.2)
vue-resize@2.0.0-alpha.1(vue@3.5.11(typescript@5.7.2)):
dependencies:
vue: 3.5.11(typescript@5.7.2)
vue-router@4.4.5(vue@3.5.11(typescript@5.7.2)):
dependencies:
'@vue/devtools-api': 6.6.4
@ -25506,6 +25461,13 @@ snapshots:
semver: 7.6.0
typescript: 5.7.2
vue-virtual-scroller@2.0.0-beta.8(vue@3.5.11(typescript@5.7.2)):
dependencies:
mitt: 2.1.0
vue: 3.5.11(typescript@5.7.2)
vue-observe-visibility: 2.0.0-alpha.1(vue@3.5.11(typescript@5.7.2))
vue-resize: 2.0.0-alpha.1(vue@3.5.11(typescript@5.7.2))
vue3-touch-events@4.1.3: {}
vue@3.5.11(typescript@5.7.2):