feat(editor): Schema view (#4615)

* feat(editor): Generate custom schema from data (#4562)

* feat(core): adding a type package to n8n

* feat(editor): adding custom schema generator

* fix: add new types package to lock file

* fix: remove n8n_io/types package

* fix: adding path to generated schema

* fix: handling nested lists in schema generation

* fix: add date support to schema generation

* fix: define dates in ISO format

* fix: using test instead of it in repeated tests

* fix(editor): JSON schema treat nested lists as object to allow mapping each level

* fix(editor): rename JSON schema type

* fix(editor): make JSON schema path required

* fix(editor): using JSON schema bracket notation for object props to handle exceptional keys

* fix(editor): reorder JSON schema generator function args

* feat(editor): Add date recognizer util function (#4620)

*  Implemented date recogniser fuction
*  Added unit tests for date recogniser
* ✔️ Fixing linting errors
* 👌 Updating test cases

* feat(editor): Implement JSON Schema view UI functionalities (#4601)

* feat(core): adding a type package to n8n

* feat(editor): adding custom schema generator

* fix: add new types package to lock file

* fix: remove n8n_io/types package

* fix: adding path to generated schema

* fix: handling nested lists in schema generation

* fix: add date support to schema generation

* fix: define dates in ISO format

* fix: using test instead of it in repeated tests

* fix(editor): JSON schema treat nested lists as object to allow mapping each level

* fix(editor): rename JSON schema type

* fix(editor): make JSON schema path required

* fix(editor): using JSON schema bracket notation for object props to handle exceptional keys

* fix(editor): reorder JSON schema generator function args

* fix(editor): WIP json schema view

* fix(editor): formatting fix

* fix(editor): WIP json schema viewer

* fix(editor): fix schema generator and add deep merge

* fix(editor): WIP update json schema view components

* fix(editor): extend valid date checking

* fix(editor): WIP improving JSON schema view

* chore(editor): code formatting

* feat(editor): WIP Json schema view mapping + animations

* feat(editor): WIP update mergeDeep

* feat(editor): adding first item of json data to the end once more to get sample data from the first item

* feat(editor): adding first item of json data to the end once more to get sample data from the first item

* fix(editor): improving draggable design

* fix(editor): move util functions to their correct place after merge conflict

* fix(editor): move some type guards

* fix(editor): move some type guards

* fix(editor): change import path in unit test

* fix(editor): import missing interface

* fix(editor): remove unused functions and parts from json schema generation

* feat(editor): Add telemetry calls to JSON schema mapping (#4695)

* feat(editor): WIP JSON schema telemetry call

* feat(editor): make telemetry usable outside of Vue component context

* chore(editor): remove unused variable

* Merge branch 'feature/json-schema-view' of github.com:n8n-io/n8n into n8n-5410-add-telemetry-calls

# Conflicts:
#	packages/editor-ui/src/components/RunDataJsonSchema.vue

* fix(editor): VUE typing for telemetry

* fix(editor): enable PostHog feature flag

* fix(editor): Schema design review (#4740)

* refactor(editor): rename JsonSchema to Schema

* fix(editor): schema component name

* fix(editor): schema pill style

* fix(editor): schema type date as string

* fix(editor): schema styles (support long text + firefox)

* fix(editor): schema truncate text if it's too long

* fix(editor): schema types

* fix(editor): droppable styles

* fix(editor): schema component props

* fix(editor): fix draggable pill styles

* fix(editor): schema view styles

* fix(editor): schema mapping tooltip

* fix(editor): schema mapping styles

* fix(editor): mapping styles

* fix(editor): empty schema case

* fix(editor): delay mapping tooltip

* test(editor): add schema view snapshot test

* fix(editor): schema empty string

* fix(editor): schema string without space

* fix(editor): update schema test snapshot

* fix(editor): applying review comments

* fix(editor): make n8nExternalHooks optional

* fix(editor): remove TODO comment

Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
Csaba Tuncsik 2022-12-06 12:50:06 +01:00 committed by GitHub
parent 9485e2f12a
commit 4528f34462
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1343 additions and 65 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

View file

@ -1026,8 +1026,8 @@ export interface NestedRecord<T> {
[key: string]: T | NestedRecord<T>;
}
export type IRunDataDisplayMode = 'table' | 'json' | 'binary';
export type nodePanelType = 'input' | 'output';
export type IRunDataDisplayMode = 'table' | 'json' | 'binary' | 'schema';
export type NodePanelType = 'input' | 'output';
export interface TargetItem {
nodeName: string;
@ -1290,3 +1290,21 @@ export interface CurlToJSONResponse {
"parameters.sendQuery": boolean;
"parameters.sendBody": boolean;
}
export type Basic = string | number | boolean;
export type Primitives = Basic | bigint | symbol;
export type Optional<T> = T | undefined | null;
export type SchemaType =
| 'string'
| 'number'
| 'boolean'
| 'bigint'
| 'symbol'
| 'array'
| 'object'
| 'function'
| 'null'
| 'undefined';
export type Schema = { type: SchemaType, key?: string, value: string | Schema[], path: string };

View file

@ -1,5 +1,6 @@
import '@testing-library/jest-dom';
import Vue from 'vue';
import '../plugins';
Vue.config.productionTip = false;
Vue.config.devtools = false;

View file

@ -90,7 +90,11 @@ export default Vue.extend({
}
this.draggingEl = e.target as HTMLElement;
if (this.targetDataKey && this.draggingEl && this.draggingEl.dataset.target !== this.targetDataKey) {
if (this.targetDataKey && this.draggingEl.dataset?.target !== this.targetDataKey) {
this.draggingEl = this.draggingEl.closest(`[data-target="${this.targetDataKey}"]`) as HTMLElement;
}
if (this.targetDataKey && this.draggingEl?.dataset?.target !== this.targetDataKey) {
return;
}

View file

@ -51,7 +51,7 @@ import mixins from 'vue-typed-mixins';
import { debounceHelper } from '@/mixins/debounce';
import { mapStores } from 'pinia';
import { useNDVStore } from '@/stores/ndv';
import { nodePanelType } from '@/Interface';
import { NodePanelType } from '@/Interface';
const SIDE_MARGIN = 24;
@ -127,7 +127,7 @@ export default mixins(debounceHelper).extend({
relativeLeft: number,
relativeRight: number
} {
return this.ndvStore.getMainPanelDimensions(this.currentNodePaneType as nodePanelType);
return this.ndvStore.getMainPanelDimensions(this.currentNodePaneType);
},
supportedResizeDirections(): string[] {
const supportedDirections = ['right'];
@ -278,7 +278,7 @@ export default mixins(debounceHelper).extend({
if(isMaxRight) {
this.ndvStore.setMainPanelDimensions({
panelType: this.currentNodePaneType as nodePanelType,
panelType: this.currentNodePaneType,
dimensions: {
relativeLeft: 1 - this.mainPanelDimensions.relativeWidth - this.maximumRightPosition,
relativeRight: this.maximumRightPosition as number,
@ -288,7 +288,7 @@ export default mixins(debounceHelper).extend({
}
this.ndvStore.setMainPanelDimensions({
panelType: this.currentNodePaneType as nodePanelType,
panelType: this.currentNodePaneType,
dimensions: {
relativeLeft: isInputless ? this.minimumLeftPosition : mainPanelRelativeLeft,
relativeRight: mainPanelRelativeRight,

View file

@ -1165,18 +1165,23 @@ export default mixins(
.droppable {
--input-border-color: var(--color-secondary-tint-1);
--input-background-color: var(--color-secondary-tint-3);
--input-border-color: var(--color-secondary);
--input-background-color: var(--color-foreground-xlight);
--input-border-style: dashed;
textarea, input {
border-width: 1.5px;
}
}
.activeDrop {
--input-border-color: var(--color-success);
--input-background-color: var(--color-success-tint-2);
--input-background-color: var(--color-foreground-xlight);
--input-border-style: solid;
textarea, input {
cursor: grabbing !important;
border-width: 1px;
}
}

View file

@ -24,7 +24,7 @@
type="mapping"
:disabled="isDropDisabled"
:sticky="true"
:stickyOffset="4"
:stickyOffset="3"
@drop="onDrop"
>
<template #default="{ droppable, activeDrop }">
@ -98,6 +98,7 @@ export default mixins(
menuExpanded: false,
forceShowExpression: false,
dataMappingTooltipButtons: [] as IN8nButton[],
mappingTooltipEnabled: false,
};
},
props: {
@ -165,18 +166,22 @@ export default mixins(
return this.ndvStore.inputPanelDisplayMode;
},
showMappingTooltip (): boolean {
return this.focused && this.isInputTypeString && !this.isInputDataEmpty && window.localStorage.getItem(LOCAL_STORAGE_MAPPING_FLAG) !== 'true';
return this.mappingTooltipEnabled && this.focused && this.isInputTypeString && !this.isInputDataEmpty && window.localStorage.getItem(LOCAL_STORAGE_MAPPING_FLAG) !== 'true';
},
},
methods: {
onFocus() {
this.focused = true;
setTimeout(() => {
this.mappingTooltipEnabled = true;
}, 500);
if (!this.parameter.noDataExpression) {
this.ndvStore.setMappableNDVInputFocus(this.parameter.displayName);
}
},
onBlur() {
this.focused = false;
this.mappingTooltipEnabled = false;
if (!this.parameter.noDataExpression) {
this.ndvStore.setMappableNDVInputFocus('');
}

View file

@ -121,7 +121,7 @@
<slot name="run-info"></slot>
</div>
<div v-if="maxOutputIndex > 0 && branches.length > 1" :class="{[$style.tabs]: displayMode === 'table'}">
<div v-if="maxOutputIndex > 0 && branches.length > 1" :class="$style.tabs">
<n8n-tabs :value="currentOutputIndex" @input="onBranchChange" :options="branches" />
</div>
@ -249,6 +249,16 @@
:totalRuns="maxRunIndex"
/>
<run-data-schema
v-else-if="hasNodeRun && displayMode === 'schema' && jsonData?.length > 0"
:data="jsonData"
:mappingEnabled="mappingEnabled"
:distanceFromActive="distanceFromActive"
:node="node"
:runIndex="runIndex"
:totalRuns="maxRunIndex"
/>
<div v-else-if="displayMode === 'binary' && binaryData.length === 0" :class="$style.center">
<n8n-text align="center" tag="div">{{ $locale.baseText('runData.noBinaryDataFound') }}</n8n-text>
</div>
@ -351,6 +361,7 @@ import {
INodeUpdatePropertiesInformation,
IRunDataDisplayMode,
ITab,
NodePanelType,
} from '@/Interface';
import {
@ -382,6 +393,7 @@ import { useNodeTypesStore } from "@/stores/nodeTypes";
const RunDataTable = () => import('@/components/RunDataTable.vue');
const RunDataJson = () => import('@/components/RunDataJson.vue');
const RunDataSchema = () => import('@/components/RunDataSchema.vue');
export type EnterEditModeArgs = {
origin: 'editIconButton' | 'insertTestDataLink',
@ -402,6 +414,7 @@ export default mixins(
CodeEditor,
RunDataTable,
RunDataJson,
RunDataSchema,
},
props: {
nodeUi: {
@ -432,7 +445,7 @@ export default mixins(
type: String,
},
paneType: {
type: String,
type: String as PropType<NodePanelType>,
},
overrideOutputs: {
type: Array as PropType<number[]>,
@ -513,7 +526,7 @@ export default mixins(
return DATA_EDITING_DOCS_URL;
},
displayMode(): IRunDataDisplayMode {
return this.ndvStore.getPanelDisplayMode(this.paneType as "input" | "output");
return this.ndvStore.getPanelDisplayMode(this.paneType);
},
node(): INodeUi | null {
return (this.nodeUi as INodeUi | null) || null;
@ -537,10 +550,13 @@ export default mixins(
{ label: this.$locale.baseText('runData.table'), value: 'table'},
{ label: this.$locale.baseText('runData.json'), value: 'json'},
];
if (this.binaryData.length) {
return [ ...defaults,
{ label: this.$locale.baseText('runData.binary'), value: 'binary'},
];
defaults.push({ label: this.$locale.baseText('runData.binary'), value: 'binary'});
}
if (this.isPaneTypeInput && window.posthog?.isFeatureEnabled?.('schema-view')) {
defaults.unshift({ label: this.$locale.baseText('runData.schema'), value: 'schema'});
}
return defaults;
@ -957,7 +973,7 @@ export default mixins(
},
onDisplayModeChange(displayMode: IRunDataDisplayMode) {
const previous = this.displayMode;
this.ndvStore.setPanelDisplayMode({pane: this.paneType as "input" | "output", mode: displayMode});
this.ndvStore.setPanelDisplayMode({pane: this.paneType, mode: displayMode});
const dataContainer = this.$refs.dataContainer;
if (dataContainer) {

View file

@ -225,7 +225,6 @@ export default mixins(externalHooks).extend({
height: 100%;
padding-bottom: var(--spacing-3xl);
background-color: var(--color-background-base);
padding-top: var(--spacing-s);
&:hover {
/* Shows .actionsGroup element from <run-data-json-actions /> child component */
@ -251,7 +250,10 @@ export default mixins(externalHooks).extend({
}
.dragPill {
padding: var(--spacing-4xs) var(--spacing-4xs) var(--spacing-3xs) var(--spacing-4xs);
display: flex;
height: 24px;
align-items: center;
padding: 0 var(--spacing-4xs);
color: var(--color-text-xlight);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);

View file

@ -0,0 +1,61 @@
import { PiniaVuePlugin } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { render } from '@testing-library/vue';
import RunDataJsonSchema from '@/components/RunDataSchema.vue';
import { STORES } from "@/constants";
describe('RunDataJsonSchema.vue', () => {
it('renders json schema properly', () => {
const {container} = render(RunDataJsonSchema, {
pinia: createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: {
templates: {
enabled: true,
host: 'https://api.n8n.io/api/',
},
},
},
},
}),
props: {
mappingEnabled: true,
distanceFromActive: 1,
runIndex: 1,
totalRuns: 2,
node: {
parameters: {
keepOnlySet: false,
values: {},
options: {},
},
id: '820ea733-d8a6-4379-8e73-88a2347ea003',
name: 'Set',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [
380,
1060,
],
disabled: false,
},
data: [{ name: 'John', age: 22, hobbies: ['surfing', 'traveling'] }, { name: 'Joe', age: 33, hobbies: ['skateboarding', 'gaming'] }],
},
mocks: {
$locale: {
baseText() {
return '';
},
},
$store: {
getters: {},
},
},
},
vue => {
vue.use(PiniaVuePlugin);
});
expect(container).toMatchSnapshot();
});
});

View file

@ -0,0 +1,157 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { INodeUi, Schema } from "@/Interface";
import RunDataSchemaItem from "@/components/RunDataSchemaItem.vue";
import Draggable from '@/components/Draggable.vue';
import { useNDVStore } from "@/stores/ndv";
import { useWebhooksStore } from "@/stores/webhooks";
import { runExternalHook } from "@/mixins/externalHooks";
import { telemetry } from "@/plugins/telemetry";
import { IDataObject } from "n8n-workflow";
import { getSchema, mergeDeep } from "@/utils";
type Props = {
data: IDataObject[]
mappingEnabled: boolean
distanceFromActive: number
runIndex: number
totalRuns: number
node: INodeUi | null
}
const props = withDefaults(defineProps<Props>(), {
distanceFromActive: 0,
});
const draggingPath = ref<string>('');
const ndvStore = useNDVStore();
const webhooksStore = useWebhooksStore();
const schema = computed<Schema>(() => {
const [head, ...tail] = props.data;
return getSchema(mergeDeep([head, ...tail, head]));
});
const onDragStart = (el: HTMLElement) => {
if (el && el.dataset?.path) {
draggingPath.value = el.dataset.path;
}
ndvStore.resetMappingTelemetry();
};
const onDragEnd = (el: HTMLElement) => {
draggingPath.value = '';
setTimeout(() => {
const mappingTelemetry = ndvStore.mappingTelemetry;
const telemetryPayload = {
src_node_type: props.node?.type,
src_field_name: el.dataset.name || '',
src_nodes_back: props.distanceFromActive,
src_run_index: props.runIndex,
src_runs_total: props.totalRuns,
src_field_nest_level: el.dataset.depth || 0,
src_view: 'schema',
src_element: el,
success: false,
...mappingTelemetry,
};
runExternalHook('runDataJson.onDragEnd', webhooksStore, telemetryPayload);
telemetry.track('User dragged data for mapping', telemetryPayload);
}, 1000); // ensure dest data gets set if drop
};
</script>
<template>
<div :class="$style.schemaWrapper">
<draggable
type="mapping"
targetDataKey="mappable"
:disabled="!mappingEnabled"
@dragstart="onDragStart"
@dragend="onDragEnd"
>
<template #preview="{ canDrop, el }">
<div v-if="el" :class="[$style.dragPill, canDrop ? $style.droppablePill : $style.defaultPill]" v-html="el.outerHTML" />
</template>
<template>
<div :class="$style.schema">
<run-data-schema-item
:schema="schema"
:level="0"
:parent="null"
:subKey="`${schema.type}-0-0`"
:mappingEnabled="mappingEnabled"
:draggingPath="draggingPath"
:distanceFromActive="distanceFromActive"
:node="node"
/>
</div>
</template>
</draggable>
</div>
</template>
<style lang="scss" module>
.schemaWrapper {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
overflow: auto;
line-height: 1.5;
word-break: normal;
height: 100%;
width: 100%;
background-color: var(--color-background-base);
}
.schema {
display: inline-block;
padding: 0 var(--spacing-s) var(--spacing-s);
}
.dragPill {
display: inline-flex;
height: 24px;
padding: 0 var(--spacing-3xs);
border: 1px solid var(--color-foreground-light);
border-radius: 4px;
background: var(--color-background-xlight);
font-size: var(--font-size-2xs);
color: var(--color-text-base);
white-space: nowrap;
align-items: center;
span {
display: flex;
height: 100%;
align-items: center;
}
}
.droppablePill {
&,
span span {
color: var(--color-success);
border-color: var(--color-success-light);
background: var(--color-success-tint-3);
}
}
.defaultPill {
transform: translate(-50%, -100%);
box-shadow: 0 2px 6px rgba(68, 28, 23, 0.2);
&,
span span {
color: var(--color-primary);
border-color: var(--color-primary-tint-1);
background: var(--color-primary-tint-3);
}
}
</style>

View file

@ -0,0 +1,267 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { INodeUi, Schema } from "@/Interface";
import { checkExhaustive, shorten } from "@/utils";
type Props = {
schema: Schema
level: number
parent: Schema | null
subKey: string
mappingEnabled: boolean
draggingPath: string
distanceFromActive: number
node: INodeUi | null
}
const props = defineProps<Props>();
const isSchemaValueArray = computed(() => Array.isArray(props.schema.value));
const isSchemaParentTypeArray = computed(() => props.parent?.type === 'array');
const isFlat = computed(() => props.level === 0 && Array.isArray(props.schema.value) && props.schema.value.every(v => !Array.isArray(v.value)));
const key = computed((): string | undefined => isSchemaParentTypeArray.value ? `[${props.schema.key}]` : props.schema.key);
const schemaName = computed(() => isSchemaParentTypeArray.value ? `${props.schema.type}[${props.schema.key}]` : props.schema.key);
const text = computed(() => Array.isArray(props.schema.value) ? '' : shorten(props.schema.value, 600, 0));
const getJsonParameterPath = (path: string): string => `{{ ${props.distanceFromActive === 1 ? '$json' : `$node["${ props.node!.name }"].json`}${path} }}`;
const transitionDelay = (i:number) => `${i * 0.033}s`;
const getIconBySchemaType = (type: Schema['type']): string => {
switch (type) {
case 'object':
return 'cube';
case 'array':
return 'list';
case 'string':
case 'null':
return 'font';
case 'number':
return 'hashtag';
case 'boolean':
return 'check-square';
case 'function':
return 'code';
case 'bigint':
return 'calculator';
case 'symbol':
return 'sun';
case 'undefined':
return 'ban';
}
checkExhaustive(type);
};
</script>
<template>
<div :class="$style.item">
<div
v-if="level > 0 || level === 0 && !isSchemaValueArray"
:title="schema.type"
:class="{
[$style.pill]: true,
[$style.mappable]: mappingEnabled,
[$style.dragged]: draggingPath === schema.path,
}"
>
<span
:class="$style.label"
:data-value="getJsonParameterPath(schema.path)"
:data-name="schemaName"
:data-path="schema.path"
:data-depth="level"
data-target="mappable"
>
<font-awesome-icon :icon="getIconBySchemaType(schema.type)" size="sm"/>
<span v-if="isSchemaParentTypeArray">{{ parent.key }}</span>
<span v-if="key" :class="{[$style.arrayIndex]: isSchemaParentTypeArray}">{{ key }}</span>
</span>
</div>
<span v-if="text" :class="$style.text">{{ text }}</span>
<input :id="subKey" type="checkbox" checked />
<label v-if="level > 0 && isSchemaValueArray" :class="$style.toggle" :for="subKey">
<font-awesome-icon icon="angle-up" />
</label>
<div v-if="isSchemaValueArray" :class="{[$style.sub]: true, [$style.flat]: isFlat}">
<run-data-schema-item v-for="(s, i) in schema.value"
:key="`${s.type}-${level}-${i}`"
:schema="s"
:level="level + 1"
:parent="schema"
:subKey="`${s.type}-${level}-${i}`"
:mappingEnabled="mappingEnabled"
:draggingPath="draggingPath"
:distanceFromActive="distanceFromActive"
:node="node"
:style="{transitionDelay: transitionDelay(i)}"
/>
</div>
</div>
</template>
<style lang="scss" module>
@import '@/styles/css-animation-helpers.scss';
.item {
display: block;
position: relative;
transition: all 0.3s $ease-out-expo;
.item {
padding-top: var(--spacing-2xs);
padding-left: var(--spacing-l);
}
input {
position: absolute;
left: -100%;
~ .sub {
height: 0;
> .item {
transform: translateX(-100%);
}
}
&:checked {
~ .toggle svg {
transform: rotate(180deg);
}
~ .sub {
height: auto;
> .item {
transform: translateX(0);
}
}
}
}
&::after {
content: '';
display: block;
clear: both;
}
}
.sub {
display: block;
overflow: hidden;
transition: all 0.2s $ease-out-expo;
clear: both;
&.flat {
> .item {
padding-left: 0;
}
}
&:nth-of-type(1) {
> .item:nth-of-type(1) {
padding-top: 0;
.toggle {
top: -2px;
}
}
}
}
.pill {
float: left;
display: inline-flex;
height: 24px;
padding: 0 var(--spacing-3xs);
border: 1px solid var(--color-foreground-light);
border-radius: 4px;
background: var(--color-background-xlight);
font-size: var(--font-size-2xs);
color: var(--color-text-dark);
span {
display: flex;
height: 100%;
align-items: center;
svg {
path {
fill: var(--color-text-light);
}
}
}
&.mappable {
cursor: grab;
&:hover {
&,
span span {
background-color: var(--color-background-light);
border-color: var(--color-foreground-base);
}
}
}
&.dragged {
&,
&:hover,
span {
color: var(--color-primary);
border-color: var(--color-primary-tint-1);
background: var(--color-primary-tint-3);
svg {
path {
fill: var(--color-primary);
}
}
}
}
}
.label {
> span {
margin-left: var(--spacing-3xs);
padding-left: var(--spacing-3xs);
border-left: 1px solid var(--color-foreground-light);
&.arrayIndex {
border: 0;
padding-left: 0;
margin-left: 0;
}
}
}
.text {
display: block;
padding-top: var(--spacing-4xs);
padding-left: var(--spacing-2xs);
font-weight: var(--font-weight-normal);
font-size: var(--font-size-2xs);
overflow: hidden;
word-break: break-word;
}
.toggle {
display: flex;
position: absolute;
padding: var(--spacing-2xs);
left: 0;
top: 5px;
justify-content: center;
align-items: center;
cursor: pointer;
user-select: none;
font-weight: normal;
font-size: var(--font-size-s);
overflow: hidden;
svg {
transition: all 0.3s $ease-out-expo;
}
}
</style>

View file

@ -634,7 +634,10 @@ export default mixins(externalHooks).extend({
}
.dragPill {
padding: var(--spacing-4xs) var(--spacing-4xs) var(--spacing-3xs) var(--spacing-4xs);
display: flex;
height: 24px;
align-items: center;
padding: 0 var(--spacing-4xs);
color: var(--color-text-xlight);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);

View file

@ -0,0 +1,326 @@
// Vitest Snapshot v1
exports[`RunDataJsonSchema.vue > renders json schema properly 1`] = `
<div>
<div
class="_schemaWrapper_1w572_1"
>
<div
class=""
>
<div
class="_schema_1w572_1"
>
<div
class="_item_eg159_1"
>
<!---->
<!---->
<input
checked="checked"
id="object-0-0"
type="checkbox"
/>
<!---->
<div
class="_sub_eg159_14"
>
<div
class="_item_eg159_1"
style="transition-delay: 0s;"
>
<div
class="_pill_eg159_51 _mappable_eg159_70"
title="string"
>
<span
class="_label_eg159_89"
data-depth="1"
data-name="name"
data-path="[\\"name\\"]"
data-target="mappable"
data-value="{{ $json[\\"name\\"] }}"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-font fa-w-14 fa-sm"
data-icon="font"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
class=""
d="M432 416h-23.41L277.88 53.69A32 32 0 0 0 247.58 32h-47.16a32 32 0 0 0-30.3 21.69L39.41 416H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16h-19.58l23.3-64h152.56l23.3 64H304a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zM176.85 272L224 142.51 271.15 272z"
fill="currentColor"
/>
</svg>
<!---->
<span
class=""
>
name
</span>
</span>
</div>
<span
class="_text_eg159_100"
>
John
</span>
<input
checked="checked"
id="string-0-0"
type="checkbox"
/>
<!---->
<!---->
</div>
<div
class="_item_eg159_1"
style="transition-delay: 0.033s;"
>
<div
class="_pill_eg159_51 _mappable_eg159_70"
title="number"
>
<span
class="_label_eg159_89"
data-depth="1"
data-name="age"
data-path="[\\"age\\"]"
data-target="mappable"
data-value="{{ $json[\\"age\\"] }}"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-hashtag fa-w-14 fa-sm"
data-icon="hashtag"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
class=""
d="M440.667 182.109l7.143-40c1.313-7.355-4.342-14.109-11.813-14.109h-74.81l14.623-81.891C377.123 38.754 371.468 32 363.997 32h-40.632a12 12 0 0 0-11.813 9.891L296.175 128H197.54l14.623-81.891C213.477 38.754 207.822 32 200.35 32h-40.632a12 12 0 0 0-11.813 9.891L132.528 128H53.432a12 12 0 0 0-11.813 9.891l-7.143 40C33.163 185.246 38.818 192 46.289 192h74.81L98.242 320H19.146a12 12 0 0 0-11.813 9.891l-7.143 40C-1.123 377.246 4.532 384 12.003 384h74.81L72.19 465.891C70.877 473.246 76.532 480 84.003 480h40.632a12 12 0 0 0 11.813-9.891L151.826 384h98.634l-14.623 81.891C234.523 473.246 240.178 480 247.65 480h40.632a12 12 0 0 0 11.813-9.891L315.472 384h79.096a12 12 0 0 0 11.813-9.891l7.143-40c1.313-7.355-4.342-14.109-11.813-14.109h-74.81l22.857-128h79.096a12 12 0 0 0 11.813-9.891zM261.889 320h-98.634l22.857-128h98.634l-22.857 128z"
fill="currentColor"
/>
</svg>
<!---->
<span
class=""
>
age
</span>
</span>
</div>
<span
class="_text_eg159_100"
>
22
</span>
<input
checked="checked"
id="number-0-1"
type="checkbox"
/>
<!---->
<!---->
</div>
<div
class="_item_eg159_1"
style="transition-delay: 0.066s;"
>
<div
class="_pill_eg159_51 _mappable_eg159_70"
title="array"
>
<span
class="_label_eg159_89"
data-depth="1"
data-name="hobbies"
data-path="[\\"hobbies\\"]"
data-target="mappable"
data-value="{{ $json[\\"hobbies\\"] }}"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-list fa-w-16 fa-sm"
data-icon="list"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
class=""
d="M80 368H16a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h64a16 16 0 0 0 16-16v-64a16 16 0 0 0-16-16zm0-320H16A16 16 0 0 0 0 64v64a16 16 0 0 0 16 16h64a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16zm0 160H16a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h64a16 16 0 0 0 16-16v-64a16 16 0 0 0-16-16zm416 176H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-320H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16zm0 160H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16z"
fill="currentColor"
/>
</svg>
<!---->
<span
class=""
>
hobbies
</span>
</span>
</div>
<!---->
<input
checked="checked"
id="array-0-2"
type="checkbox"
/>
<label
class="_toggle_eg159_20"
for="array-0-2"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-angle-up fa-w-10"
data-icon="angle-up"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 320 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
class=""
d="M177 159.7l136 136c9.4 9.4 9.4 24.6 0 33.9l-22.6 22.6c-9.4 9.4-24.6 9.4-33.9 0L160 255.9l-96.4 96.4c-9.4 9.4-24.6 9.4-33.9 0L7 329.7c-9.4-9.4-9.4-24.6 0-33.9l136-136c9.4-9.5 24.6-9.5 34-.1z"
fill="currentColor"
/>
</svg>
</label>
<div
class="_sub_eg159_14"
>
<div
class="_item_eg159_1"
style="transition-delay: 0s;"
>
<div
class="_pill_eg159_51 _mappable_eg159_70"
title="string"
>
<span
class="_label_eg159_89"
data-depth="2"
data-name="string[0]"
data-path="[\\"hobbies\\"][0]"
data-target="mappable"
data-value="{{ $json[\\"hobbies\\"][0] }}"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-font fa-w-14 fa-sm"
data-icon="font"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
class=""
d="M432 416h-23.41L277.88 53.69A32 32 0 0 0 247.58 32h-47.16a32 32 0 0 0-30.3 21.69L39.41 416H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16h-19.58l23.3-64h152.56l23.3 64H304a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zM176.85 272L224 142.51 271.15 272z"
fill="currentColor"
/>
</svg>
<span>
hobbies
</span>
<span
class="_arrayIndex_eg159_94"
>
[0]
</span>
</span>
</div>
<span
class="_text_eg159_100"
>
surfing
</span>
<input
checked="checked"
id="string-1-0"
type="checkbox"
/>
<!---->
<!---->
</div>
<div
class="_item_eg159_1"
style="transition-delay: 0.033s;"
>
<div
class="_pill_eg159_51 _mappable_eg159_70"
title="string"
>
<span
class="_label_eg159_89"
data-depth="2"
data-name="string[1]"
data-path="[\\"hobbies\\"][1]"
data-target="mappable"
data-value="{{ $json[\\"hobbies\\"][1] }}"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-font fa-w-14 fa-sm"
data-icon="font"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
class=""
d="M432 416h-23.41L277.88 53.69A32 32 0 0 0 247.58 32h-47.16a32 32 0 0 0-30.3 21.69L39.41 416H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16h-19.58l23.3-64h152.56l23.3 64H304a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zM176.85 272L224 142.51 271.15 272z"
fill="currentColor"
/>
</svg>
<span>
hobbies
</span>
<span
class="_arrayIndex_eg159_94"
>
[1]
</span>
</span>
</div>
<span
class="_text_eg159_100"
>
traveling
</span>
<input
checked="checked"
id="string-1-1"
type="checkbox"
/>
<!---->
<!---->
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="teleporter hidden"
data-v-d4e6e290=""
/>
</div>
</div>
</div>
`;

View file

@ -1,29 +1,31 @@
import { IExternalHooks, IRootState } from '@/Interface';
import { store } from '@/store';
import { IExternalHooks } from '@/Interface';
import { useWebhooksStore } from '@/stores/webhooks';
import { IDataObject } from 'n8n-workflow';
import { Store } from 'pinia';
import Vue from 'vue';
declare global {
interface Window {
n8nExternalHooks?: Record<string, Record<string, Array<(store: Store, metadata?: IDataObject) => Promise<void>>>>;
}
}
export async function runExternalHook(
eventName: string,
store: Store,
metadata?: IDataObject,
) {
// @ts-ignore
if (!window.n8nExternalHooks) {
return;
}
const [resource, operator] = eventName.split('.');
// @ts-ignore
if (window.n8nExternalHooks[resource] && window.n8nExternalHooks[resource][operator]) {
// @ts-ignore
if (window.n8nExternalHooks[resource]?.[operator]) {
const hookMethods = window.n8nExternalHooks[resource][operator];
for (const hookmethod of hookMethods) {
await hookmethod(store, metadata);
for (const hookMethod of hookMethods) {
await hookMethod(store, metadata);
}
}
}

View file

@ -362,6 +362,7 @@
"dataMapping.success.moreInfo": "Check out our <a href=\"https://docs.n8n.io/data/data-mapping\" target=\"_blank\">docs</a> for more details on mapping data in n8n",
"dataMapping.tableHint": "<img src='/static/data-mapping-gif.gif'/> Drag a column onto <b>{name}</b> to map it",
"dataMapping.jsonHint": "<img src='/static/json-mapping-gif.gif'/> Drag a JSON key onto <b>{name}</b> to map data",
"dataMapping.schemaHint": "<img src='/static/schema-mapping-gif.gif'/> Drag a datapill onto <b>{name}</b> to map data",
"dataMapping.mapKeyToField": "Map '{name}' to a field",
"dataMapping.mapAllKeysToField": "Map every '{name}' to a field",
"dataMapping.tableView.tableColumnsExceeded": "Some columns are hidden",
@ -983,6 +984,7 @@
"runData.invalidPinnedData": "Invalid pinned data",
"runData.items": "Items",
"runData.json": "JSON",
"runData.schema": "Schema",
"runData.mimeType": "Mime Type",
"runData.fileSize": "File Size",
"runData.ms": "ms",

View file

@ -10,13 +10,15 @@ import {
faArrowLeft,
faArrowRight,
faAt,
faBan,
faBook,
faBoxOpen,
faBug,
faBan,
faCalculator,
faCalendar,
faCheck,
faCheckCircle,
faCheckSquare,
faChevronDown,
faChevronUp,
faChevronLeft,
@ -52,12 +54,14 @@ import {
faFilter,
faFlask,
faFolderOpen,
faFont,
faGlobeAmericas,
faGift,
faGlobe,
faGraduationCap,
faGripVertical,
faHandPointLeft,
faHashtag,
faHdd,
faHome,
faHourglass,
@ -67,6 +71,7 @@ import {
faInfoCircle,
faKey,
faLink,
faList,
faLightbulb,
faMapSigns,
faMousePointer,
@ -137,9 +142,11 @@ addIcon(faBan);
addIcon(faBook);
addIcon(faBoxOpen);
addIcon(faBug);
addIcon(faCalculator);
addIcon(faCalendar);
addIcon(faCheck);
addIcon(faCheckCircle);
addIcon(faCheckSquare);
addIcon(faChevronLeft);
addIcon(faChevronRight);
addIcon(faChevronDown);
@ -176,11 +183,13 @@ addIcon(faFilePdf);
addIcon(faFilter);
addIcon(faFlask);
addIcon(faFolderOpen);
addIcon(faFont);
addIcon(faGift);
addIcon(faGlobe);
addIcon(faGlobeAmericas);
addIcon(faGraduationCap);
addIcon(faHandPointLeft);
addIcon(faHashtag);
addIcon(faHdd);
addIcon(faHome);
addIcon(faHourglass);
@ -190,6 +199,7 @@ addIcon(faInfo);
addIcon(faInfoCircle);
addIcon(faKey);
addIcon(faLink);
addIcon(faList);
addIcon(faLightbulb);
addIcon(faMapSigns);
addIcon(faMousePointer);

View file

@ -6,22 +6,11 @@ import {
} from 'n8n-workflow';
import { Route } from "vue-router";
import type { INodeCreateElement, IRootState } from "@/Interface";
import type { INodeCreateElement } from "@/Interface";
import type { IUserNodesPanelSession } from "./telemetry.types";
import { useSettingsStore } from "@/stores/settings";
import { useRootStore } from "@/stores/n8nRootStore";
export function TelemetryPlugin(vue: typeof _Vue): void {
const telemetry = new Telemetry();
Object.defineProperty(vue, '$telemetry', {
get() { return telemetry; },
});
Object.defineProperty(vue.prototype, '$telemetry', {
get() { return telemetry; },
});
}
export class Telemetry {
private pageEventQueue: Array<{route: Route}>;
@ -51,7 +40,7 @@ export class Telemetry {
instanceId: string;
userId?: string;
versionCli: string
},
},
) {
if (!telemetrySettings.enabled || !telemetrySettings.config || this.rudderStack) return;
@ -252,3 +241,14 @@ export class Telemetry {
this.rudderStack.load(key, url, options);
}
}
export const telemetry = new Telemetry();
export function TelemetryPlugin(vue: typeof _Vue): void {
Object.defineProperty(vue, '$telemetry', {
get() { return telemetry; },
});
Object.defineProperty(vue.prototype, '$telemetry', {
get() { return telemetry; },
});
}

View file

@ -1,5 +1,5 @@
import { STORES } from "@/constants";
import { INodeUi, IRunDataDisplayMode, NDVState, XYPosition } from "@/Interface";
import { INodeUi, IRunDataDisplayMode, NDVState, NodePanelType, XYPosition } from "@/Interface";
import { IRunData } from "n8n-workflow";
import { defineStore } from "pinia";
import Vue from "vue";
@ -11,7 +11,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
mainPanelDimensions: {},
sessionId: '',
input: {
displayMode: 'table',
displayMode: window.posthog?.isFeatureEnabled?.('schema-view') ? 'schema': 'table',
nodeName: undefined,
run: undefined,
branch: undefined,
@ -60,7 +60,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
return executionData.data?.resultData?.runData?.[inputNodeName]?.[inputRunIndex]?.data?.main?.[inputBranchIndex];
},
getPanelDisplayMode() {
return (panel: 'input' | 'output') => this[panel].displayMode;
return (panel: NodePanelType) => this[panel].displayMode;
},
inputPanelDisplayMode(): IRunDataDisplayMode {
return this.input.displayMode;
@ -125,7 +125,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
resetNDVSessionId(): void {
Vue.set(this, 'sessionId', '');
},
setPanelDisplayMode(params: {pane: 'input' | 'output', mode: IRunDataDisplayMode}): void {
setPanelDisplayMode(params: {pane: NodePanelType, mode: IRunDataDisplayMode}): void {
Vue.set(this[params.pane], 'displayMode', params.mode);
},
setOutputPanelEditModeEnabled(isEnabled: boolean): void {

View file

@ -0,0 +1 @@
$ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);

View file

@ -1,8 +1,10 @@
import { isEmpty, intersection } from "@/utils";
import jp from "jsonpath";
import { isEmpty, intersection, mergeDeep, getSchema, isValidDate } from "@/utils";
import { Schema } from "@/Interface";
describe("Utils", () => {
describe("isEmpty", () => {
it.each([
test.each([
[undefined, true],
[null, true],
[{}, true],
@ -38,4 +40,287 @@ describe("Utils", () => {
expect(intersection([1, 2, 2, 3, 4], [2, 3, 3, 4], [2, 1, 5, 4, 4, 1], [2, 4, 5, 5, 6, 7])).toEqual([2, 4]);
});
});
describe("mergeDeep", () => {
test.each([
[
[[1, 2], [3, 4]],
{},
[3, 4],
],
[
[[1, 2], [3, 4]],
{ concatArrays: true },
[1, 2, 3, 4],
],
[
[[1, 2], [3, 4]],
{ overwriteArrays: true },
[3, 4],
],
[
[[1, 2, 3], [4, 5]],
{},
[4, 5, 3],
],
[
[[1, 2, 3], [4, 5]],
{ concatArrays: true },
[1, 2, 3, 4, 5],
],
[
[[1, 2, 3], [4, 5]],
{ overwriteArrays: true },
[4, 5],
],
[
[[1, 2], [3, 4, 5]],
{},
[3, 4, 5],
],
[
[[1, 2], [3, 4, 5]],
{ concatArrays: true },
[1, 2, 3, 4, 5],
],
[
[[1, 2], [3, 4, 5]],
{ overwriteArrays: true },
[3, 4, 5],
],
[
[{a:1, b: [1, 2, {d: 2}]},{}],
{},
{a:1, b: [1, 2, {d:2}]},
],
[
[{a:1, b: [1, 2, {d: 2}]},{}],
{ concatArrays: true },
{a:1, b: [1, 2, {d:2}]},
],
[
[{a:1, b: [1, 2, {d: 2}]},{}],
{ overwriteArrays: true },
{a:1, b: [1, 2, {d:2}]},
],
[
[[{a:1, b: [1, 2, {d: 2}]}],[]],
{},
[{a:1, b: [1, 2, {d:2}]}],
],
[
[[{a:1, b: [1, 2, {d: 2}]}],[]],
{ concatArrays: true },
[{a:1, b: [1, 2, {d:2}]}],
],
[
[[{a:1, b: [1, 2, {d: 2}]}],[]],
{ overwriteArrays: true },
[],
],
[
[{a: 1, b: [1, 2, 3]}, {a: 2, b: [4, 5, 6, 7], c: "2"}, {a: 3, b: [8, 9], d: "3"}],
{},
{a: 3, b: [8, 9, 6, 7], c: "2", d: "3"},
],
[
[{a: 1, b: [1, 2, 3]}, {a: 2, b: [4, 5, 6, 7], c: "2"}, {a: 3, b: [8, 9], d: "3"}],
{ concatArrays: true },
{a: 3, b: [1, 2, 3, 4, 5, 6, 7, 8, 9], c: "2", d: "3"},
],
[
[{a: 1, b: [1, 2, 3]}, {a: 2, b: [4, 5, 6, 7], c: "2"}, {a: 3, b: [8, 9], d: "3"}],
{ overwriteArrays: true },
{a: 3, b: [8, 9], c: "2", d: "3"},
],
[
[{a: 1, b: [{x: 'a'}]}, {a: 2, b: [{y: 'b'}], c: "2"}, {a: 3, b: [{z: 'c'}], d: "3"}],
{},
{a: 3, b: [{x: 'a', y: 'b', z: 'c'}], c: "2", d: "3"},
],
[
[{a: 1, b: [{x: 'a'}]}, {a: 2, b: [{y: 'b'}], c: "2"}, {a: 3, b: [{z: 'c'}], d: "3"}],
{ concatArrays: true },
{a: 3, b: [{x: 'a'}, {y: 'b'}, {z: 'c'}], c: "2", d: "3"},
],
[
[{a: 1, b: [{x: 'a'}]}, {a: 2, b: [{y: 'b'}], c: "2"}, {a: 3, b: [{z: 'c'}], d: "3"}],
{ overwriteArrays: true },
{a: 3, b: [{z: 'c'}], c: "2", d: "3"},
],
[
[{a: 1, b: [{x: 'a'}, {w: 'd'}]}, {a: 2, b: [{y: 'b'}], c: "2"}, {a: 3, b: [{z: 'c'}], d: "3"}],
{},
{a: 3, b: [{z: 'c'}, {w: 'd'}], c: "2", d: "3"},
],
[
[{a: 1, b: [{x: 'a'}, {w: 'd'}]}, {a: 2, b: [{y: 'b'}], c: "2"}, {a: 3, b: [{z: 'c'}], d: "3"}],
{ concatArrays: true },
{a: 3, b: [{x: 'a'}, {w: 'd'}, {y: 'b'}, {z: 'c'}], c: "2", d: "3"},
],
[
[{a: 1, b: [{x: 'a'}, {w: 'd'}]}, {a: 2, b: [{y: 'b'}], c: "2"}, {a: 3, b: [{z: 'c'}], d: "3"}],
{ overwriteArrays: true },
{a: 3, b: [{z: 'c'}], c: "2", d: "3"},
],
])(`case %#. input %j, options %j should return %j`, (sources, options, expected) => {
expect(mergeDeep([...sources], options)).toEqual(expected);
});
});
describe("getSchema", () => {
test.each([
[
,
{ type: 'undefined', value: 'undefined', path: '' },
],
[
undefined,
{ type: 'undefined', value: 'undefined', path: '' },
],
[
null,
{ type: 'null', value: '[null]', path: '' },
],
[
'John',
{ type: 'string', value: 'John', path: '' },
],
[
'123',
{ type: 'string', value: '123', path: '' },
],
[
123,
{ type: 'number', value: '123', path: '' },
],
[
true,
{ type: 'boolean', value: 'true', path: '' },
],
[
false,
{ type: 'boolean', value: 'false', path: '' },
],
[
() => {},
{ type: 'function', value: '', path: '' },
],
[
{},
{ type: 'object', value: [], path: '' },
],
[
[],
{ type: 'array', value: [], path: '' },
],
[
new Date('2022-11-22T00:00:00.000Z'),
{ type: 'string', value: '2022-11-22T00:00:00.000Z', path: '' },
],
[
Symbol('x'),
{ type: 'symbol', value: 'Symbol(x)', path: '' },
],
[
1n,
{ type: 'bigint', value: '1', path: '' },
],
[
['John', 1, true],
{ type: 'array', value: [{ type: 'string', value: 'John', key: '0', path: '[0]' }, { type: 'number', value: '1', key: '1', path: '[1]' }, { type: 'boolean', value: 'true', key: '2', path: '[2]' }], path: '' },
],
[
{ people: ['Joe', 'John']},
{ type: 'object', value: [{ type: 'array', key: 'people', value: [{ type: 'string', value: 'Joe', key: '0', path: '["people"][0]' }, { type: 'string', value: 'John', key: '1', path: '["people"][1]' }], path: '["people"]' }], path: '' },
],
[
[{ name: 'John', age: 22 }, { name: 'Joe', age: 33 }],
{ type: 'array', value: [{ type: 'object', key: '0', value: [{ type: 'string', key: 'name', value: 'John', path: '[0]["name"]'}, { type: 'number', key: 'age', value: '22', path: '[0]["age"]' }], path: '[0]'}, { type: 'object', key: '1', value: [{ type: 'string', key: 'name', value: 'Joe', path: '[1]["name"]'}, { type: 'number', key: 'age', value: '33', path: '[1]["age"]' }], path: '[1]'}], path: '' },
],
[
[{ name: 'John', age: 22, hobbies: ['surfing', 'traveling'] }, { name: 'Joe', age: 33, hobbies: ['skateboarding', 'gaming'] }],
{ type: 'array', value: [{ type: 'object', key: '0', value: [{ type: 'string', key: 'name', value: 'John', path: '[0]["name"]'}, { type: 'number', key: 'age', value: '22', path: '[0]["age"]' }, {type: 'array', key: 'hobbies', value: [{type: 'string', key: '0', value: 'surfing', path: '[0]["hobbies"][0]'},{type: 'string', key: '1', value: 'traveling', path: '[0]["hobbies"][1]'}], path: '[0]["hobbies"]'}], path: '[0]'}, { type: 'object', key: '1', value: [{ type: 'string', key: 'name', value: 'Joe', path: '[1]["name"]'}, { type: 'number', key: 'age', value: '33', path: '[1]["age"]' }, {type: 'array', key: 'hobbies', value: [{type: 'string', key: '0', value: 'skateboarding', path: '[1]["hobbies"][0]'},{type: 'string', key: '1', value: 'gaming', path: '[1]["hobbies"][1]'}], path: '[1]["hobbies"]'}], path: '[1]'}], path: '' },
],
[
[],
{ type: 'array', value: [], path: '' },
],
[
[[1,2]],
{ type: 'array', value: [{ type: 'array', key: '0', value: [{type: 'number', key: '0', value: '1', path: '[0][0]'}, {type: 'number', key: '1', value: '2', path: '[0][1]'}], path: '[0]' }], path: '' },
],
[
[[{ name: 'John', age: 22 }, { name: 'Joe', age: 33 }]],
{ type: 'array', value: [{type: 'array', key: '0', value:[{ type: 'object', key: '0', value: [{ type: 'string', key: 'name', value: 'John', path: '[0][0]["name"]'}, { type: 'number', key: 'age', value: '22', path: '[0][0]["age"]' }], path: '[0][0]'}, { type: 'object', key: '1', value: [{ type: 'string', key: 'name', value: 'Joe', path: '[0][1]["name"]'}, { type: 'number', key: 'age', value: '33', path: '[0][1]["age"]' }], path: '[0][1]'}], path: '[0]'}], path: '' },
],
[
[{ dates: [[new Date('2022-11-22T00:00:00.000Z'), new Date('2022-11-23T00:00:00.000Z')], [new Date('2022-12-22T00:00:00.000Z'), new Date('2022-12-23T00:00:00.000Z')]] }],
{ type: 'array', value: [{ type: 'object', key: '0', value: [{ type: 'array', key: 'dates', value: [{ type: 'array', key: '0', value: [{type: 'string', key: '0', value: '2022-11-22T00:00:00.000Z', path: '[0]["dates"][0][0]'}, {type: 'string', key: '1', value: '2022-11-23T00:00:00.000Z', path: '[0]["dates"][0][1]'}], path: '[0]["dates"][0]' }, { type: 'array', key: '1', value: [{type: 'string', key: '0', value: '2022-12-22T00:00:00.000Z', path: '[0]["dates"][1][0]'}, {type: 'string', key: '1', value: '2022-12-23T00:00:00.000Z', path: '[0]["dates"][1][1]'}], path: '[0]["dates"][1]' }], path: '[0]["dates"]' }], path: '[0]' }], path: '' },
],
])('should return the correct json schema for %s', (input, schema) => {
expect(getSchema(input)).toEqual(schema);
});
it('should return the correct data when using the generated json path on an object', () => {
const input = { people: ['Joe', 'John']};
const schema = getSchema(input) as Schema;
const pathData = jp.query(input, `$${ ((schema.value as Schema[])[0].value as Schema[])[0].path }`);
expect(pathData).toEqual(['Joe']);
});
it('should return the correct data when using the generated json path on a list', () => {
const input = [{ name: 'John', age: 22, hobbies: ['surfing', 'traveling'] }, { name: 'Joe', age: 33, hobbies: ['skateboarding', 'gaming'] }];
const schema = getSchema(input) as Schema;
const pathData = jp.query(input, `$${ (((schema.value as Schema[])[0].value as Schema[])[2].value as Schema[])[1].path }`);
expect(pathData).toEqual(['traveling']);
});
it('should return the correct data when using the generated json path on a list of list', () => {
const input = [[1,2]];
const schema = getSchema(input) as Schema;
const pathData = jp.query(input, `$${ (((schema.value as Schema[])[0]).value as Schema[])[1].path }`);
expect(pathData).toEqual([2]);
});
it('should return the correct data when using the generated json path on a list of list of objects', () => {
const input = [[{ name: 'John', age: 22 }, { name: 'Joe', age: 33 }]];
const schema = getSchema(input) as Schema;
const pathData = jp.query(input, `$${ (((schema.value as Schema[])[0].value as Schema[])[1].value as Schema[])[1].path}`);
expect(pathData).toEqual([33]);
});
it('should return the correct data when using the generated json path on a list of objects with a list of date tuples', () => {
const input = [{ dates: [[new Date('2022-11-22T00:00:00.000Z'), new Date('2022-11-23T00:00:00.000Z')], [new Date('2022-12-22T00:00:00.000Z'), new Date('2022-12-23T00:00:00.000Z')]] }];
const schema = getSchema(input) as Schema;
const pathData = jp.query(input, `$${ ((((schema.value as Schema[])[0].value as Schema[])[0].value as Schema[])[0].value as Schema[])[0].path}`);
expect(pathData).toEqual([new Date('2022-11-22T00:00:00.000Z')]);
});
});
describe("dateTests", () => {
test.each([
'04-08-2021',
'15.11.2022 12:34h',
'15.11.2022. 12:34h',
'21-03-1988 12:34h',
'2022-11-15',
'11/11/2022',
1668470400000,
'2021-1-01',
'2021-01-1',
'2021/11/24',
'2021/04/08',
'Mar 25 2015',
'25 Mar 2015',
'2019-06-11T00:00',
'2022-11-15T19:21:13.932Z',
'Tue Jan 01 2019 02:07:00 GMT+0530',
new Date(),
'4/08/2021',
'2021/04/04',
])('should correctly recognize dates', (input) => {
expect(isValidDate(input)).toBeTruthy();
});
});
});

View file

@ -1,10 +1,14 @@
import { INodeParameterResourceLocator } from "n8n-workflow";
import { ICredentialsResponse } from "../Interface";
import {INodeParameterResourceLocator} from "n8n-workflow";
import {ICredentialsResponse} from "@/Interface";
/*
Type guards used in editor-ui project
*/
export const checkExhaustive = (value: never): never => {
throw new Error(`Unhandled value: ${value}`);
};
export function isResourceLocatorValue(value: unknown): value is INodeParameterResourceLocator {
return Boolean(typeof value === 'object' && value && 'mode' in value && 'value' in value);
}
@ -16,3 +20,13 @@ export function isNotNull<T>(value: T | null): value is T {
export function isValidCredentialResponse(value: unknown): value is ICredentialsResponse {
return typeof value === 'object' && value !== null && 'id' in value;
}
export const isObj = (obj: unknown): obj is object => !!obj && Object.getPrototypeOf(obj) === Object.prototype;
export function isString(value: unknown): value is string {
return typeof value === 'string';
}
export function isNumber(value: unknown): value is number {
return typeof value === 'number';
}

View file

@ -1,5 +1,7 @@
import dateformat from 'dateformat';
import { IDataObject, jsonParse } from 'n8n-workflow';
import { Schema, Optional, Primitives } from "@/Interface";
import { isObj } from "@/utils/typeGuards";
/*
Constants and utility functions than can be used to manipulate different data types and objects
@ -40,7 +42,6 @@ export const intersection = <T>(...arrays: T[][]): T[] => {
return [...new Set(rest.length ? intersection(ab, ...rest) : ab)];
};
export function abbreviateNumber(num: number) {
const tier = (Math.log10(Math.abs(num)) / 3) | 0;
@ -61,18 +62,6 @@ export function convertToHumanReadableDate (epochTime: number) {
return dateformat(epochTime, 'd mmmm, yyyy @ HH:MM Z');
}
export function isString(value: unknown): value is string {
return typeof value === 'string';
}
export function isStringNumber(value: unknown): value is string {
return !isNaN(Number(value));
}
export function isNumber(value: unknown): value is number {
return typeof value === 'number';
}
export function stringSizeInBytes(input: string | IDataObject | IDataObject[] | undefined): number {
if (input === undefined) return 0;
@ -135,3 +124,113 @@ export const clearJsonKey = (userInput: string | object) => {
return parsedUserInput.map(item => isJsonKeyObject(item) ? item.json : item);
};
// Holds weird date formats that we encounter when working with strings
// Should be extended as new cases are found
const CUSTOM_DATE_FORMATS = [
/\d{1,2}-\d{1,2}-\d{4}/, // Should handle dash separated dates with year at the end
/\d{1,2}\.\d{1,2}\.\d{4}/, // Should handle comma separated dates
];
export const isValidDate = (input: string | number | Date): boolean => {
try {
// Try to construct date object using input
const date = new Date(input);
// This will not fail for wrong dates so have to check like this:
if (date.getTime() < 0) {
return false;
} else if (date.toString() !== 'Invalid Date') {
return true;
} else if (typeof input === 'string') {
// Try to cover edge cases with regex
for (const regex of CUSTOM_DATE_FORMATS) {
if (input.match(regex)) {
return true;
}
}
return false;
}
return false;
} catch (e) {
return false;
}
};
export const getObjectKeys = <T extends object, K extends keyof T>(o: T): K[] => Object.keys(o) as K[];
export const mergeDeep = <T extends object | Primitives>(sources: T[], options?: Partial<Record<'overwriteArrays' | 'concatArrays', boolean>>): T => sources.reduce((target, source) => {
if(Array.isArray(target) && Array.isArray(source)){
const tLength = target.length;
const sLength = source.length;
if(tLength === 0 || options?.overwriteArrays) {
return source;
}
if(sLength === 0) {
return target;
}
if(options?.concatArrays) {
return [...target, ...source];
}
if(tLength === sLength) {
return target.map((item, index) => mergeDeep([item, source[index]], options));
} else if(tLength < sLength) {
return source.map((item, index) => mergeDeep([target[index], item], options));
} else {
return [...source, ...target.slice(sLength)];
}
} else if(isObj(target) && isObj(source)) {
const targetKeys = getObjectKeys(target);
const sourceKeys = getObjectKeys(source);
const allKeys = [...new Set([...targetKeys, ...sourceKeys])];
const mergedObject = Object.create(Object.prototype);
for (const key of allKeys) {
if (targetKeys.includes(key) && sourceKeys.includes(key)) {
mergedObject[key] = mergeDeep([target[key] as T, source[key] as T], options);
} else if (targetKeys.includes(key)) {
mergedObject[key] = target[key];
} else {
mergedObject[key] = source[key];
}
}
return mergedObject;
} else {
return source;
}
}, (Array.isArray(sources[0]) ? [] : {}) as T);
export const getSchema = (input: Optional<Primitives | object>, path = ''): Schema => {
let schema:Schema = { type: 'undefined', value: 'undefined', path };
switch (typeof input) {
case 'object':
if (input === null) {
schema = { type: 'null', value: '[null]', path };
} else if (input instanceof Date) {
schema = { type: 'string', value: input.toISOString(), path };
} else if (Array.isArray(input)) {
schema = {
type: 'array',
value: input.map((item, index) => ({key: index.toString(), ...getSchema(item,`${path}[${index}]`)})),
path,
};
} else if (isObj(input)) {
schema = {
type: 'object',
value: Object.entries(input).map(([k, v]) => ({ key: k, ...getSchema(v, path + `["${ k }"]`)})),
path,
};
}
break;
case 'function':
schema = { type: 'function', value: ``, path };
break;
default:
schema = { type: typeof input, value: String(input), path };
}
return schema;
};