mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
* 🔥 Remove test extensions
* 🚧 Add test description
* 📘 Expand types
* ⚡ Export extensions
* ⚡ Export collection
* ⚡ Mark all proxies
* ✏️ Rename for clarity
* ⚡ Export from barrel
* ✨ Create datatype completions
* ⚡ Mount datatype completions
* 🧪 Adjust tests
* ⚡ Add `path` prop
* 🔥 Remove `()` from completion labels
* ⚡ Filter out completions for pseudo-proxies
* 🐛 Fix method error
* ⚡ Add metrics
* ✏️ Improve naming
* ✨ Start completion on empty resolvable
* ✨ Implement completion previews
* ⚡ Break out completion manager
* ⚡ Implement in expression editor modal
* ✏️ Improve naming
* ⚡ Filter out irrelevant completions
* ✨ Add preview hint
* ✏️ Improve comments
* 🎨 Style preview hint
* ⚡ Expand `hasNoParams`
* ⚡ Add spacing for readability
* ⚡ Add error codes
* ✏️ Add comment
* 🐛 Fix Esc behavior
* ⚡ Parse Unicode
* ⚡ Throw on invalid `DateTime`
* ⚡ Fix second root completion detection
* ⚡ Switch message at completable prefix position
* 🐛 Fix function names for non-dev build
* 🐛 Fix `json` handling
* 🔥 Comment out previews
* ♻️ Apply feedback
* 🔥 Remove extensions
* 🚚 Rename extensions
* ⚡ Adjust some implementations
* 🔥 Remove dummy extensions
* 🐛 Fix object regex
* ♻️ Apply feedback
* ✏️ Fix typos
* ✏️ Add `fn is not a function` message
* 🔥 Remove check
* ✨ Add `isNotEmpty` for objects
* 🚚 Rename `global` to `alpha`
* 🔥 Remove `encrypt`
* ⚡ Restore `is not a function` error
* ⚡ Support `week` on `extract()`
* 🧪 Fix tests
* ⚡ Add validation to some string extensions
* ⚡ Validate number arrays in some extensions
* 🧪 Fix tests
* ✏️ Improve error message
* ⏪ Revert extensions framework changes
* 🧹 Previews cleanup
* ⚡ Condense blank completions
* ⚡ Refactor dollar completions
* ⚡ Refactor non-dollar completions
* ⚡ Refactor Luxon completions
* ⚡ Refactor datatype completions
* ⚡ Use `DATETIMEUNIT_MAP`
* ✏️ Update test description
* ⏪ Revert "Use `DATETIMEUNIT_MAP`"
This reverts commit 472a77df5c
.
* 🧪 Add tests
* ♻️ Restore generic extensions
* 🔥 Remove logs
* 🧪 Expand tests
* ✨ Add `Math` completions
* ✏️ List breaking change
* ⚡ Add doc tooltips
* 🐛 Fix node selector regex
* 🐛 Fix `context` resolution
* 🐛 Allow dollar completions in args
* ⚡ Make numeric array methods context-dependent
* 📝 Adjust docs
* 🐛 Fix selector ref
* ⚡ Surface error for valid URL
* 🐛 Disallow whitespace in `isEmail` check
* 🧪 Fix test for `isUrl`
* ⚡ Add comma validator in `toFloat`
* ⚡ Add validation to `$jmespath()`
* ⏪ Revert valid URL error
* ⚡ Adjust `$jmespath()` validation
* 🧪 Adjust `isUrl` test
* ⚡ Remove `{}` and `[]` from compact
* ✏️ Update docs
* 🚚 Rename `stripTags` to `removeTags`
* ⚡ Do not inject whitespace inside resolvable
* ⚡ Make completions aware of `()`
* ✏️ Add note
* ⚡ Update sorting
* ⚡ Hide active node name from node selector
* 🔥 Remove `length()` and its aliases
* ⚡ Validate non-zero for `chunk`
* ✏️ Reword all error messages
* 🐛 Fix `$now` and `$today`
* ⚡ Simplify with `stripExcessParens`
* ⚡ Fold luxon into datatype
* 🧪 Clean up tests
* 🔥 Remove tests for removed methods
* 👕 Fix type
* ⬆️ Upgrade lang pack
* ⏪ Undo change to `vitest` command
* 🔥 Remove unused method
* ⚡ Separate `return` line
* ✏️ Improve description
* 🧪 Expand tests for initial-only completions
* 🧪 Add bracket-aware completions
* ⚡ Make check for `all()` stricter
* ✏️ Adjust explanatory comments
* 🔥 Remove unneded copy
* 🔥 Remove outdated comment
* ⚡ Make naming consistent
* ✏️ Update comments
* ⚡ Improve URL scheme check
* ✏️ Add comment
* 🚚 Move extension
* ✏️ Update `BREAKING-CHANGES.md`
* ✏️ Update upcoming version
* ✏️ Fix grammar
* ✏️ Shorten message
* 🐛 Fix `Esc` behavior
* 🐛 Fix `isNumeric`
* ✨ Using UTC to handle-dates on back-end
* ✅ Added more unit tests for date extensions
* ⚡ Not using `JSON.stringify` to render dates
* ⚡ Using `deep-equal` library instead of our `deepCompare` function
* ✅ Adding more tests to array extensions
* ⚡ Fixing `inBetween` extension function
* ✅ Added tests for `.inBetween()`
* ⚡ Updating `isEven` and `isOdd` to throw for floats
* ⚡ Updating `Array.merge()` so it works without arguments
* 🔀 Fixing leftover merge confilct
* ⚡ Updating `removeFieldsContaining` and `keepFieldsContaining` to throw on empty strings
* ⚡ Fixing `pluck()` so it returns only plucked values
* ⬆️ Updating pnpm lockfile
* 👕 Fixing lint errors
* ⚡ Using workflow timezone to display dates
* ✔️ Updating tests with workflow timezone
* ⚡ Not using system timezone when creating Luxon dates
* ⚡ Updating `merge()` and `pluck()` array functions
* 🔀 Sync with `master`: Removing code that was preserved during merge
* ⚡ Updating `.pluck()` to return full array if no arguments are passed
* ⚡ Updating `keepFieldsContaining` and `merge` object functions
* ⚡ Using week as default for `date.extract()`
* ✅ Adding more test cases for DT functions
* ⚡ Removing `Object.merge` extension function. Adding missing `deep-equal` dependency
* ⚡ Handling `toDate` case when time component is not specified
* ⚡ Using workflow's timezone to render dates in output panel, updated unit tests after removing `Object.merge` function
* ⚡ Not parsing numbers as dates
* 👕 Fixing lint errors
* ⚡ Fixing a typo
* ⚡ Making date detection more strict so only stringified dates are getting converted
* 👌 Addressing PR feedback
* 🔥 Removing leftover comment
---------
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
457 lines
11 KiB
TypeScript
457 lines
11 KiB
TypeScript
import { ExpressionError, ExpressionExtensionError } from '../ExpressionError';
|
||
import type { ExtensionMap } from './Extensions';
|
||
import { compact as oCompact } from './ObjectExtensions';
|
||
import deepEqual from 'deep-equal';
|
||
|
||
function first(value: unknown[]): unknown {
|
||
return value[0];
|
||
}
|
||
|
||
function isEmpty(value: unknown[]): boolean {
|
||
return value.length === 0;
|
||
}
|
||
|
||
function isNotEmpty(value: unknown[]): boolean {
|
||
return value.length > 0;
|
||
}
|
||
|
||
function last(value: unknown[]): unknown {
|
||
return value[value.length - 1];
|
||
}
|
||
|
||
function pluck(value: unknown[], extraArgs: unknown[]): unknown[] {
|
||
if (!Array.isArray(extraArgs)) {
|
||
throw new ExpressionError('arguments must be passed to pluck');
|
||
}
|
||
if (!extraArgs || extraArgs.length === 0) {
|
||
return value;
|
||
}
|
||
const plucked = value.reduce<unknown[]>((pluckedFromObject, current) => {
|
||
if (current && typeof current === 'object') {
|
||
const p: unknown[] = [];
|
||
Object.keys(current).forEach((k) => {
|
||
extraArgs.forEach((field: string) => {
|
||
if (current && field === k) {
|
||
p.push((current as { [key: string]: unknown })[k]);
|
||
}
|
||
});
|
||
});
|
||
if (p.length > 0) {
|
||
pluckedFromObject.push(p.length === 1 ? p[0] : p);
|
||
}
|
||
}
|
||
return pluckedFromObject;
|
||
}, new Array<unknown>());
|
||
return plucked;
|
||
}
|
||
|
||
function randomItem(value: unknown[]): unknown {
|
||
const len = value === undefined ? 0 : value.length;
|
||
return len ? value[Math.floor(Math.random() * len)] : undefined;
|
||
}
|
||
|
||
function unique(value: unknown[], extraArgs: string[]): unknown[] {
|
||
if (extraArgs.length) {
|
||
return value.reduce<unknown[]>((l, v) => {
|
||
if (typeof v === 'object' && v !== null && extraArgs.every((i) => i in v)) {
|
||
const alreadySeen = l.find((i) =>
|
||
extraArgs.every((j) =>
|
||
deepEqual(
|
||
(i as Record<string, unknown>)[j],
|
||
(v as Record<string, unknown>, { strict: true })[j],
|
||
{ strict: true },
|
||
),
|
||
),
|
||
);
|
||
if (!alreadySeen) {
|
||
l.push(v);
|
||
}
|
||
}
|
||
return l;
|
||
}, []);
|
||
}
|
||
return value.reduce<unknown[]>((l, v) => {
|
||
if (l.findIndex((i) => deepEqual(i, v, { strict: true })) === -1) {
|
||
l.push(v);
|
||
}
|
||
return l;
|
||
}, []);
|
||
}
|
||
|
||
const ensureNumberArray = (arr: unknown[], { fnName }: { fnName: string }) => {
|
||
if (arr.some((i) => typeof i !== 'number')) {
|
||
throw new ExpressionExtensionError(`${fnName}(): all array elements must be numbers`);
|
||
}
|
||
};
|
||
|
||
function sum(value: unknown[]): number {
|
||
ensureNumberArray(value, { fnName: 'sum' });
|
||
|
||
return value.reduce((p: number, c: unknown) => {
|
||
if (typeof c === 'string') {
|
||
return p + parseFloat(c);
|
||
}
|
||
if (typeof c !== 'number') {
|
||
return NaN;
|
||
}
|
||
return p + c;
|
||
}, 0);
|
||
}
|
||
|
||
function min(value: unknown[]): number {
|
||
ensureNumberArray(value, { fnName: 'min' });
|
||
|
||
return Math.min(
|
||
...value.map((v) => {
|
||
if (typeof v === 'string') {
|
||
return parseFloat(v);
|
||
}
|
||
if (typeof v !== 'number') {
|
||
return NaN;
|
||
}
|
||
return v;
|
||
}),
|
||
);
|
||
}
|
||
|
||
function max(value: unknown[]): number {
|
||
ensureNumberArray(value, { fnName: 'max' });
|
||
|
||
return Math.max(
|
||
...value.map((v) => {
|
||
if (typeof v === 'string') {
|
||
return parseFloat(v);
|
||
}
|
||
if (typeof v !== 'number') {
|
||
return NaN;
|
||
}
|
||
return v;
|
||
}),
|
||
);
|
||
}
|
||
|
||
export function average(value: unknown[]) {
|
||
ensureNumberArray(value, { fnName: 'average' });
|
||
|
||
// This would usually be NaN but I don't think users
|
||
// will expect that
|
||
if (value.length === 0) {
|
||
return 0;
|
||
}
|
||
return sum(value) / value.length;
|
||
}
|
||
|
||
function compact(value: unknown[]): unknown[] {
|
||
return value
|
||
.filter((v) => {
|
||
if (v && typeof v === 'object' && Object.keys(v).length === 0) return false;
|
||
|
||
return v !== null && v !== undefined && v !== 'nil' && v !== '';
|
||
})
|
||
.map((v) => {
|
||
if (typeof v === 'object' && v !== null) {
|
||
return oCompact(v);
|
||
}
|
||
return v;
|
||
});
|
||
}
|
||
|
||
function smartJoin(value: unknown[], extraArgs: string[]): object {
|
||
const [keyField, valueField] = extraArgs;
|
||
if (!keyField || !valueField || typeof keyField !== 'string' || typeof valueField !== 'string') {
|
||
throw new ExpressionExtensionError(
|
||
'smartJoin(): expected two string args, e.g. .smartJoin("name", "value")',
|
||
);
|
||
}
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
|
||
return value.reduce<any>((o, v) => {
|
||
if (typeof v === 'object' && v !== null && keyField in v && valueField in v) {
|
||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
|
||
o[(v as any)[keyField]] = (v as any)[valueField];
|
||
}
|
||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||
return o;
|
||
}, {});
|
||
}
|
||
|
||
function chunk(value: unknown[], extraArgs: number[]) {
|
||
const [chunkSize] = extraArgs;
|
||
if (typeof chunkSize !== 'number' || chunkSize === 0) {
|
||
throw new ExpressionExtensionError('chunk(): expected non-zero numeric arg, e.g. .chunk(5)');
|
||
}
|
||
const chunks: unknown[][] = [];
|
||
for (let i = 0; i < value.length; i += chunkSize) {
|
||
// I have no clue why eslint thinks 2 numbers could be anything but that but here we are
|
||
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
||
chunks.push(value.slice(i, i + chunkSize));
|
||
}
|
||
return chunks;
|
||
}
|
||
|
||
function renameKeys(value: unknown[], extraArgs: string[]): unknown[] {
|
||
if (extraArgs.length === 0 || extraArgs.length % 2 !== 0) {
|
||
throw new ExpressionExtensionError(
|
||
'renameKeys(): expected an even amount of args: from1, to1 [, from2, to2, ...]. e.g. .renameKeys("name", "title")',
|
||
);
|
||
}
|
||
return value.map((v) => {
|
||
if (typeof v !== 'object' || v === null) {
|
||
return v;
|
||
}
|
||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
|
||
const newObj = { ...(v as any) };
|
||
const chunkedArgs = chunk(extraArgs, [2]) as string[][];
|
||
chunkedArgs.forEach(([from, to]) => {
|
||
if (from in newObj) {
|
||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||
newObj[to] = newObj[from];
|
||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||
delete newObj[from];
|
||
}
|
||
});
|
||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||
return newObj;
|
||
});
|
||
}
|
||
|
||
function mergeObjects(value: Record<string, unknown>, extraArgs: unknown[]): unknown {
|
||
const [other] = extraArgs;
|
||
|
||
if (!other) {
|
||
return value;
|
||
}
|
||
|
||
if (typeof other !== 'object') {
|
||
throw new ExpressionExtensionError('merge(): expected object arg');
|
||
}
|
||
|
||
const newObject = { ...value };
|
||
for (const [key, val] of Object.entries(other)) {
|
||
if (!(key in newObject)) {
|
||
newObject[key] = val;
|
||
}
|
||
}
|
||
return newObject;
|
||
}
|
||
|
||
function merge(value: unknown[], extraArgs: unknown[][]): unknown {
|
||
const [others] = extraArgs;
|
||
|
||
if (others === undefined) {
|
||
// If there are no arguments passed, merge all objects within the array
|
||
const merged = value.reduce((combined, current) => {
|
||
if (current !== null && typeof current === 'object' && !Array.isArray(current)) {
|
||
combined = mergeObjects(combined as Record<string, unknown>, [current]);
|
||
}
|
||
return combined;
|
||
}, {});
|
||
return merged;
|
||
}
|
||
|
||
if (!Array.isArray(others)) {
|
||
throw new ExpressionExtensionError(
|
||
'merge(): expected array arg, e.g. .merge([{ id: 1, otherValue: 3 }])',
|
||
);
|
||
}
|
||
const listLength = value.length > others.length ? value.length : others.length;
|
||
let merged = {};
|
||
for (let i = 0; i < listLength; i++) {
|
||
if (value[i] !== undefined) {
|
||
if (typeof value[i] === 'object' && typeof others[i] === 'object') {
|
||
merged = Object.assign(
|
||
merged,
|
||
mergeObjects(value[i] as Record<string, unknown>, [others[i]]),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
function union(value: unknown[], extraArgs: unknown[][]): unknown[] {
|
||
const [others] = extraArgs;
|
||
if (!Array.isArray(others)) {
|
||
throw new ExpressionExtensionError('union(): expected array arg, e.g. .union([1, 2, 3, 4])');
|
||
}
|
||
const newArr: unknown[] = Array.from(value);
|
||
for (const v of others) {
|
||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||
if (newArr.findIndex((w) => deepEqual(w, v, { strict: true })) === -1) {
|
||
newArr.push(v);
|
||
}
|
||
}
|
||
return unique(newArr, []);
|
||
}
|
||
|
||
function difference(value: unknown[], extraArgs: unknown[][]): unknown[] {
|
||
const [others] = extraArgs;
|
||
if (!Array.isArray(others)) {
|
||
throw new ExpressionExtensionError(
|
||
'difference(): expected array arg, e.g. .difference([1, 2, 3, 4])',
|
||
);
|
||
}
|
||
const newArr: unknown[] = [];
|
||
for (const v of value) {
|
||
if (others.findIndex((w) => deepEqual(w, v, { strict: true })) === -1) {
|
||
newArr.push(v);
|
||
}
|
||
}
|
||
return unique(newArr, []);
|
||
}
|
||
|
||
function intersection(value: unknown[], extraArgs: unknown[][]): unknown[] {
|
||
const [others] = extraArgs;
|
||
if (!Array.isArray(others)) {
|
||
throw new ExpressionExtensionError(
|
||
'intersection(): expected array arg, e.g. .intersection([1, 2, 3, 4])',
|
||
);
|
||
}
|
||
const newArr: unknown[] = [];
|
||
for (const v of value) {
|
||
if (others.findIndex((w) => deepEqual(w, v, { strict: true })) !== -1) {
|
||
newArr.push(v);
|
||
}
|
||
}
|
||
for (const v of others) {
|
||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||
if (value.findIndex((w) => deepEqual(w, v, { strict: true })) !== -1) {
|
||
newArr.push(v);
|
||
}
|
||
}
|
||
return unique(newArr, []);
|
||
}
|
||
|
||
average.doc = {
|
||
name: 'average',
|
||
description: 'Returns the mean average of all values in the array',
|
||
returnType: 'number',
|
||
};
|
||
|
||
compact.doc = {
|
||
name: 'compact',
|
||
description: 'Removes all empty values from the array',
|
||
returnType: 'array',
|
||
};
|
||
|
||
isEmpty.doc = {
|
||
name: 'isEmpty',
|
||
description: 'Checks if the array doesn’t have any elements',
|
||
returnType: 'boolean',
|
||
};
|
||
|
||
isNotEmpty.doc = {
|
||
name: 'isNotEmpty',
|
||
description: 'Checks if the array has elements',
|
||
returnType: 'boolean',
|
||
};
|
||
|
||
first.doc = {
|
||
name: 'first',
|
||
description: 'Returns the first element of the array',
|
||
returnType: 'array item',
|
||
};
|
||
|
||
last.doc = {
|
||
name: 'last',
|
||
description: 'Returns the last element of the array',
|
||
returnType: 'array item',
|
||
};
|
||
|
||
max.doc = {
|
||
name: 'max',
|
||
description: 'Gets the maximum value from a number-only array',
|
||
returnType: 'number',
|
||
};
|
||
|
||
min.doc = {
|
||
name: 'min',
|
||
description: 'Gets the minimum value from a number-only array',
|
||
returnType: 'number',
|
||
};
|
||
|
||
randomItem.doc = {
|
||
name: 'randomItem',
|
||
description: 'Returns a random element from an array',
|
||
returnType: 'number',
|
||
};
|
||
|
||
sum.doc = {
|
||
name: 'sum',
|
||
description: 'Returns the total sum all the values in an array of parsable numbers',
|
||
returnType: 'number',
|
||
};
|
||
|
||
// @TODO_NEXT_PHASE: Surface extensions below which take args
|
||
|
||
chunk.doc = {
|
||
name: 'chunk',
|
||
returnType: 'array',
|
||
};
|
||
|
||
difference.doc = {
|
||
name: 'difference',
|
||
returnType: 'array',
|
||
};
|
||
|
||
intersection.doc = {
|
||
name: 'intersection',
|
||
returnType: 'array',
|
||
};
|
||
|
||
merge.doc = {
|
||
name: 'merge',
|
||
returnType: 'array',
|
||
};
|
||
|
||
pluck.doc = {
|
||
name: 'pluck',
|
||
returnType: 'array',
|
||
};
|
||
|
||
renameKeys.doc = {
|
||
name: 'renameKeys',
|
||
returnType: 'array',
|
||
};
|
||
|
||
smartJoin.doc = {
|
||
name: 'smartJoin',
|
||
returnType: 'array',
|
||
};
|
||
|
||
union.doc = {
|
||
name: 'union',
|
||
returnType: 'array',
|
||
};
|
||
|
||
unique.doc = {
|
||
name: 'unique',
|
||
returnType: 'array item',
|
||
aliases: ['removeDuplicates'],
|
||
};
|
||
|
||
export const arrayExtensions: ExtensionMap = {
|
||
typeName: 'Array',
|
||
functions: {
|
||
removeDuplicates: unique,
|
||
first,
|
||
last,
|
||
pluck,
|
||
unique,
|
||
randomItem,
|
||
sum,
|
||
min,
|
||
max,
|
||
average,
|
||
isNotEmpty,
|
||
isEmpty,
|
||
compact,
|
||
smartJoin,
|
||
chunk,
|
||
renameKeys,
|
||
merge,
|
||
union,
|
||
difference,
|
||
intersection,
|
||
},
|
||
};
|