Skip to content

Commit

Permalink
feat(editor): Add missing extension methods for expressions (#8845)
Browse files Browse the repository at this point in the history
  • Loading branch information
elsmr committed Mar 20, 2024
1 parent 7176cd1 commit 5e84c2a
Show file tree
Hide file tree
Showing 28 changed files with 809 additions and 39 deletions.
1 change: 1 addition & 0 deletions packages/editor-ui/src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,7 @@ export interface NDVState {
};
};
focusedMappableInput: string;
focusedInputPath: string;
mappingTelemetry: { [key: string]: string | number | boolean };
hoveringItem: null | TargetItem;
draggable: {
Expand Down
2 changes: 2 additions & 0 deletions packages/editor-ui/src/components/ParameterInputFull.vue
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ export default defineComponent({
if (!this.parameter.noDataExpression) {
this.ndvStore.setMappableNDVInputFocus(this.parameter.displayName);
}
this.ndvStore.setFocusedInputPath(this.path ?? '');
},
onBlur() {
this.focused = false;
Expand All @@ -239,6 +240,7 @@ export default defineComponent({
) {
this.ndvStore.setMappableNDVInputFocus('');
}
this.ndvStore.setFocusedInputPath('');
this.$emit('blur');
},
onMenuExpanded(expanded: boolean) {
Expand Down
8 changes: 6 additions & 2 deletions packages/editor-ui/src/composables/useWorkflowHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,17 @@ export function resolveParameter(
};

if (activeNode?.type === HTTP_REQUEST_NODE_TYPE) {
// Add $response for HTTP Request-Nodes as it is used
const EMPTY_RESPONSE = { statusCode: 200, headers: {}, body: {} };
const EMPTY_REQUEST = { headers: {}, body: {}, qs: {} };
// Add $request,$response,$pageCount for HTTP Request-Nodes as it is used
// in pagination expressions
additionalKeys.$pageCount = 0;
additionalKeys.$response = get(
executionData,
['data', 'executionData', 'contextData', `node:${activeNode.name}`, 'response'],
{},
EMPTY_RESPONSE,
);
additionalKeys.$request = EMPTY_REQUEST;
}

let runIndexCurrent = opts?.targetItem?.runIndex ?? 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -545,16 +545,16 @@ describe('Resolution-based completions', () => {
});

describe('recommended completions', () => {
test('should recommended toDate() for {{ "1-Feb-2024".| }}', () => {
test('should recommend toDateTime() for {{ "1-Feb-2024".| }}', () => {
// @ts-expect-error Spied function is mistyped
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('1-Feb-2024');

expect(completions('{{ "1-Feb-2024".| }}')?.[0]).toEqual(
expect.objectContaining({ label: 'toDate()', section: RECOMMENDED_SECTION }),
expect.objectContaining({ label: 'toDateTime()', section: RECOMMENDED_SECTION }),
);
});

test('should recommended toInt(),toFloat() for: {{ "5.3".| }}', () => {
test('should recommend toInt(),toFloat() for: {{ "5.3".| }}', () => {
// @ts-expect-error Spied function is mistyped
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('5.3');
const options = completions('{{ "5.3".| }}');
Expand All @@ -566,7 +566,7 @@ describe('Resolution-based completions', () => {
);
});

test('should recommended extractEmail() for: {{ "string with test@n8n.io in it".| }}', () => {
test('should recommend extractEmail() for: {{ "string with test@n8n.io in it".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped
'string with test@n8n.io in it',
Expand All @@ -577,7 +577,7 @@ describe('Resolution-based completions', () => {
);
});

test('should recommended extractDomain() for: {{ "test@n8n.io".| }}', () => {
test('should recommend extractDomain(), isEmail() for: {{ "test@n8n.io".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped
'test@n8n.io',
Expand All @@ -586,9 +586,26 @@ describe('Resolution-based completions', () => {
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'extractDomain()', section: RECOMMENDED_SECTION }),
);
expect(options?.[1]).toEqual(
expect.objectContaining({ label: 'isEmail()', section: RECOMMENDED_SECTION }),
);
});

test('should recommend extractDomain(), extractUrlPath() for: {{ "https://n8n.io/pricing".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped
'https://n8n.io/pricing',
);
const options = completions('{{ "https://n8n.io/pricing".| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'extractDomain()', section: RECOMMENDED_SECTION }),
);
expect(options?.[1]).toEqual(
expect.objectContaining({ label: 'extractUrlPath()', section: RECOMMENDED_SECTION }),
);
});

test('should recommended round(),floor(),ceil() for: {{ (5.46).| }}', () => {
test('should recommend round(),floor(),ceil() for: {{ (5.46).| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped
5.46,
Expand All @@ -604,6 +621,50 @@ describe('Resolution-based completions', () => {
expect.objectContaining({ label: 'ceil()', section: RECOMMENDED_SECTION }),
);
});

test('should recommend toDateTime("s") for: {{ (1900062210).| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped
1900062210,
);
const options = completions('{{ (1900062210).| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'toDateTime("s")', section: RECOMMENDED_SECTION }),
);
});

test('should recommend toDateTime("ms") for: {{ (1900062210000).| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped
1900062210000,
);
const options = completions('{{ (1900062210000).| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'toDateTime("ms")', section: RECOMMENDED_SECTION }),
);
});

test('should recommend toBoolean() for: {{ (0).| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped
0,
);
const options = completions('{{ (0).| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'toBoolean()', section: RECOMMENDED_SECTION }),
);
});

test('should recommend toBoolean() for: {{ "true".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped
'true',
);
const options = completions('{{ "true".| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'toBoolean()', section: RECOMMENDED_SECTION }),
);
});
});

describe('explicit completions (opened by Ctrl+Space or programatically)', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export const STRING_RECOMMENDED_OPTIONS = [

export const DATE_RECOMMENDED_OPTIONS = ['format()', 'minus()', 'plus()', 'extract()'];
export const LUXON_RECOMMENDED_OPTIONS = ['format()', 'minus()', 'plus()', 'diff()', 'extract()'];
export const OBJECT_RECOMMENDED_OPTIONS = ['keys()', 'values()', 'isEmpty()'];
export const OBJECT_RECOMMENDED_OPTIONS = ['keys()', 'values()', 'isEmpty()', 'hasField()'];
export const ARRAY_RECOMMENDED_OPTIONS = ['length', 'last()', 'includes()', 'map()', 'filter()'];
export const ARRAY_NUMBER_ONLY_METHODS = ['max()', 'min()', 'sum()', 'average()'];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
applyCompletion,
sortCompletionsAlpha,
hasRequiredArgs,
getDefaultArgs,
insertDefaultArgs,
} from './utils';
import type {
Completion,
Expand Down Expand Up @@ -155,6 +157,10 @@ function datatypeOptions(input: AutocompleteInput): Completion[] {
return stringOptions(input as AutocompleteInput<string>);
}

if (typeof resolved === 'boolean') {
return booleanOptions();
}

if (resolved instanceof DateTime) {
return luxonOptions(input as AutocompleteInput<DateTime>);
}
Expand Down Expand Up @@ -239,7 +245,7 @@ export const toOptions = (
) => {
return Object.entries(fnToDoc)
.sort((a, b) => a[0].localeCompare(b[0]))
.filter(([, docInfo]) => !docInfo.doc?.hidden || includeHidden)
.filter(([, docInfo]) => (docInfo.doc && !docInfo.doc?.hidden) || includeHidden)
.map(([fnName, docInfo]) => {
return createCompletionOption(typeName, fnName, optionType, docInfo, transformLabel);
});
Expand All @@ -258,7 +264,11 @@ const createCompletionOption = (
label,
type: optionType,
section: docInfo.doc?.section,
apply: applyCompletion(hasRequiredArgs(docInfo?.doc), transformLabel),
apply: applyCompletion({
hasArgs: hasRequiredArgs(docInfo?.doc),
defaultArgs: getDefaultArgs(docInfo?.doc),
transformLabel,
}),
};

option.info = () => {
Expand Down Expand Up @@ -395,8 +405,8 @@ const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
label: isFunction ? key + '()' : key,
type: isFunction ? 'function' : 'keyword',
section: getObjectPropertySection({ name, key, isFunction }),
apply: applyCompletion({ hasArgs, transformLabel }),
detail: getDetail(name, resolvedProp),
apply: applyCompletion(hasArgs, transformLabel),
};

const infoKey = [name, key].join('.');
Expand Down Expand Up @@ -466,7 +476,7 @@ const applySections = ({
recommendedSection = RECOMMENDED_SECTION,
}: {
options: Completion[];
recommended?: string[];
recommended?: Array<string | { label: string; args: unknown[] }>;
recommendedSection?: CompletionSection;
methodsSection?: CompletionSection;
propSection?: CompletionSection;
Expand All @@ -482,12 +492,12 @@ const applySections = ({
{} as Record<string, Completion>,
);
return recommended
.map(
(reco): Completion => ({
...optionByLabel[reco],
section: recommendedSection,
}),
)
.map((reco): Completion => {
const option = optionByLabel[typeof reco === 'string' ? reco : reco.label];
const label =
typeof reco === 'string' ? option.label : insertDefaultArgs(reco.label, reco.args);
return { ...option, label, section: recommendedSection };
})
.concat(
options
.filter((option) => !excludeRecommended || !recommendedSet.has(option.label))
Expand Down Expand Up @@ -529,19 +539,27 @@ const stringOptions = (input: AutocompleteInput<string>): Completion[] => {
if (validateFieldType('string', resolved, 'dateTime').valid) {
return applySections({
options,
recommended: ['toDate()'],
recommended: ['toDateTime()'],
sections: STRING_SECTIONS,
});
}

if (VALID_EMAIL_REGEX.test(resolved) || isUrl(resolved)) {
if (VALID_EMAIL_REGEX.test(resolved)) {
return applySections({
options,
recommended: ['extractDomain()', 'isEmail()', ...STRING_RECOMMENDED_OPTIONS],
sections: STRING_SECTIONS,
});
}

if (isUrl(resolved)) {
return applySections({
options,
recommended: ['extractDomain()', 'extractUrlPath()', ...STRING_RECOMMENDED_OPTIONS],
sections: STRING_SECTIONS,
});
}

if (resolved.split(/\s/).find((token) => VALID_EMAIL_REGEX.test(token))) {
return applySections({
options,
Expand All @@ -550,13 +568,39 @@ const stringOptions = (input: AutocompleteInput<string>): Completion[] => {
});
}

const trimmed = resolved.trim();
if (
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
) {
return applySections({
options,
recommended: ['parseJson()', ...STRING_RECOMMENDED_OPTIONS],
sections: STRING_SECTIONS,
});
}

if (['true', 'false'].includes(resolved.toLocaleLowerCase())) {
return applySections({
options,
recommended: ['toBoolean()', ...STRING_RECOMMENDED_OPTIONS],
sections: STRING_SECTIONS,
});
}

return applySections({
options,
recommended: STRING_RECOMMENDED_OPTIONS,
sections: STRING_SECTIONS,
});
};

const booleanOptions = (): Completion[] => {
return applySections({
options: sortCompletionsAlpha([...natives('boolean'), ...extensions('boolean')]),
});
};

const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
const { resolved, transformLabel } = input;
const options = sortCompletionsAlpha([
Expand All @@ -566,6 +610,36 @@ const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
const ONLY_INTEGER = ['isEven()', 'isOdd()'];

if (Number.isInteger(resolved)) {
const nowMillis = Date.now();
const marginMillis = 946_707_779_000; // 30y
const isPlausableMillisDateTime =
resolved > nowMillis - marginMillis && resolved < nowMillis + marginMillis;

if (isPlausableMillisDateTime) {
return applySections({
options,
recommended: [{ label: 'toDateTime()', args: ['ms'] }],
});
}

const nowSeconds = nowMillis / 1000;
const marginSeconds = marginMillis / 1000;
const isPlausableSecondsDateTime =
resolved > nowSeconds - marginSeconds && resolved < nowSeconds + marginSeconds;
if (isPlausableSecondsDateTime) {
return applySections({
options,
recommended: [{ label: 'toDateTime()', args: ['s'] }],
});
}

if (resolved === 0 || resolved === 1) {
return applySections({
options,
recommended: ['toBoolean()'],
});
}

return applySections({
options,
recommended: ONLY_INTEGER,
Expand All @@ -574,7 +648,7 @@ const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
const exclude = new Set(ONLY_INTEGER);
return applySections({
options: options.filter((option) => !exclude.has(option.label)),
recommended: ['round()', 'floor()', 'ceil()', 'toFixed()'],
recommended: ['round()', 'floor()', 'ceil()'],
});
}
};
Expand Down Expand Up @@ -775,15 +849,19 @@ const createLuxonAutocompleteOption = (
};
}

if (doc?.hidden && !includeHidden) {
if (!doc || (doc?.hidden && !includeHidden)) {
return null;
}

const option: Completion = {
label,
type,
section: doc?.section,
apply: applyCompletion(hasRequiredArgs(doc), transformLabel),
apply: applyCompletion({
hasArgs: hasRequiredArgs(doc),
defaultArgs: getDefaultArgs(doc),
transformLabel,
}),
};
option.info = createCompletionOption(
'DateTime',
Expand Down
Loading

0 comments on commit 5e84c2a

Please sign in to comment.