From 43fb22103999f2986139280dc992926bc611cb3d Mon Sep 17 00:00:00 2001 From: mikesealey Date: Tue, 11 Feb 2025 16:08:05 +0000 Subject: [PATCH 01/29] wip --- packages/string-templates/src/manifest.json | 677 +++++--------------- 1 file changed, 145 insertions(+), 532 deletions(-) diff --git a/packages/string-templates/src/manifest.json b/packages/string-templates/src/manifest.json index 7931c60641..f02caa7692 100644 --- a/packages/string-templates/src/manifest.json +++ b/packages/string-templates/src/manifest.json @@ -1,144 +1,105 @@ { "math": { "abs": { - "args": [ - "a" - ], + "args": ["a"], "numArgs": 1, "example": "{{ abs 12012.1000 }} -> 12012.1", "description": "

Return the magnitude of a.

\n", "requiresBlock": false }, "add": { - "args": [ - "a", - "b" - ], + "args": ["a", "b"], "numArgs": 2, "example": "{{ add 1 2 }} -> 3", "description": "

Return the sum of a plus b.

\n", "requiresBlock": false }, "avg": { - "args": [ - "array" - ], + "args": ["array"], "numArgs": 1, "example": "{{ avg 1 2 3 4 5 }} -> 3", "description": "

Returns the average of all numbers in the given array.

\n", "requiresBlock": false }, "ceil": { - "args": [ - "value" - ], + "args": ["value"], "numArgs": 1, "example": "{{ ceil 1.2 }} -> 2", "description": "

Get the Math.ceil() of the given value.

\n", "requiresBlock": false }, "divide": { - "args": [ - "a", - "b" - ], + "args": ["a", "b"], "numArgs": 2, "example": "{{ divide 10 5 }} -> 2", "description": "

Divide a by b

\n", "requiresBlock": false }, "floor": { - "args": [ - "value" - ], + "args": ["value"], "numArgs": 1, "example": "{{ floor 1.2 }} -> 1", "description": "

Get the Math.floor() of the given value.

\n", "requiresBlock": false }, "minus": { - "args": [ - "a", - "b" - ], + "args": ["a", "b"], "numArgs": 2, "example": "{{ subtract 10 5 }} -> 5", "description": "

Return the product of a minus b.

\n", "requiresBlock": false }, "modulo": { - "args": [ - "a", - "b" - ], + "args": ["a", "b"], "numArgs": 2, "example": "{{ modulo 10 5 }} -> 0", "description": "

Get the remainder of a division operation.

\n", "requiresBlock": false }, "multiply": { - "args": [ - "a", - "b" - ], + "args": ["a", "b"], "numArgs": 2, "example": "{{ multiply 10 5 }} -> 50", "description": "

Multiply number a by number b.

\n", "requiresBlock": false }, "plus": { - "args": [ - "a", - "b" - ], + "args": ["a", "b"], "numArgs": 2, "example": "{{ plus 10 5 }} -> 15", "description": "

Add a by b.

\n", "requiresBlock": false }, "random": { - "args": [ - "min", - "max" - ], + "args": ["min", "max"], "numArgs": 2, "example": "{{ random 0 20 }} -> 10", "description": "

Generate a random number between two values

\n", "requiresBlock": false }, "remainder": { - "args": [ - "a", - "b" - ], + "args": ["a", "b"], "numArgs": 2, "example": "{{ remainder 10 6 }} -> 4", "description": "

Get the remainder when a is divided by b.

\n", "requiresBlock": false }, "round": { - "args": [ - "number" - ], + "args": ["number"], "numArgs": 1, "example": "{{ round 10.3 }} -> 10", "description": "

Round the given number.

\n", "requiresBlock": false }, "subtract": { - "args": [ - "a", - "b" - ], + "args": ["a", "b"], "numArgs": 2, "example": "{{ subtract 10 5 }} -> 5", "description": "

Return the product of a minus b.

\n", "requiresBlock": false }, "sum": { - "args": [ - "array" - ], + "args": ["array"], "numArgs": 1, "example": "{{ sum [1, 2, 3] }} -> 6", "description": "

Returns the sum of all numbers in the given array.

\n", @@ -147,285 +108,196 @@ }, "array": { "after": { - "args": [ - "array", - "n" - ], + "args": ["array", "n"], "numArgs": 2, "example": "{{ after ['a', 'b', 'c', 'd'] 2}} -> ['c', 'd']", "description": "

Returns all of the items in an array after the specified index. Opposite of before.

\n", "requiresBlock": false }, "arrayify": { - "args": [ - "value" - ], + "args": ["value"], "numArgs": 1, "example": "{{ arrayify 'foo' }} -> ['foo']", "description": "

Cast the given value to an array.

\n", "requiresBlock": false }, "before": { - "args": [ - "array", - "n" - ], + "args": ["array", "n"], "numArgs": 2, "example": "{{ before ['a', 'b', 'c', 'd'] 3}} -> ['a', 'b']", "description": "

Return all of the items in the collection before the specified count. Opposite of after.

\n", "requiresBlock": false }, "eachIndex": { - "args": [ - "array", - "options" - ], + "args": ["array", "options"], "numArgs": 2, "example": "{{#eachIndex [1, 2, 3]}} {{item}} is {{index}} {{/eachIndex}} -> ' 1 is 0 2 is 1 3 is 2 '", "description": "

Iterates the array, listing an item and the index of it.

\n", "requiresBlock": true }, "filter": { - "args": [ - "array", - "value", - "options" - ], + "args": ["array", "value", "options"], "numArgs": 3, "example": "{{#filter [1, 2, 3] 2}}2 Found{{else}}2 not found{{/filter}} -> 2 Found", "description": "

Block helper that filters the given array and renders the block for values that evaluate to true, otherwise the inverse block is returned.

\n", "requiresBlock": true }, "first": { - "args": [ - "array", - "n" - ], + "args": ["array", "n"], "numArgs": 2, "example": "{{first [1, 2, 3, 4] 2}} -> 1,2", "description": "

Returns the first item, or first n items of an array.

\n", "requiresBlock": false }, "forEach": { - "args": [ - "array", - "options" - ], + "args": ["array", "options"], "numArgs": 2, "example": "{{#forEach [{ 'name': 'John' }] }} {{ name }} {{/forEach}} -> ' John '", "description": "

Iterates over each item in an array and exposes the current item in the array as context to the inner block. In addition to the current array item, the helper exposes the following variables to the inner block: - index - total - isFirst - isLast Also, @index is exposed as a private variable, and additional private variables may be defined as hash arguments.

\n", "requiresBlock": true }, "inArray": { - "args": [ - "array", - "value", - "options" - ], + "args": ["array", "value", "options"], "numArgs": 3, "example": "{{#inArray [1, 2, 3] 2}} 2 exists {{else}} 2 does not exist {{/inArray}} -> ' 2 exists '", "description": "

Block helper that renders the block if an array has the given value. Optionally specify an inverse block to render when the array does not have the given value.

\n", "requiresBlock": true }, "isArray": { - "args": [ - "value" - ], + "args": ["value"], "numArgs": 1, "example": "{{isArray [1, 2]}} -> true", "description": "

Returns true if value is an es5 array.

\n", "requiresBlock": false }, "itemAt": { - "args": [ - "array", - "idx" - ], + "args": ["array", "idx"], "numArgs": 2, "example": "{{itemAt [1, 2, 3] 1}} -> 2", "description": "

Returns the item from array at index idx.

\n", "requiresBlock": false }, "join": { - "args": [ - "array", - "separator" - ], + "args": ["array", "separator"], "numArgs": 2, "example": "{{join [1, 2, 3]}} -> 1, 2, 3", "description": "

Join all elements of array into a string, optionally using a given separator.

\n", "requiresBlock": false }, "equalsLength": { - "args": [ - "value", - "length" - ], + "args": ["value", "length"], "numArgs": 2, "example": "{{equalsLength [1, 2, 3] 3}} -> true", "description": "

Returns true if the the length of the given value is equal to the given length. Can be used as a block or inline helper.

\n", "requiresBlock": false }, "last": { - "args": [ - "value", - "n" - ], + "args": ["value", "n"], "numArgs": 2, "example": "{{last [1, 2, 3]}} -> 3", "description": "

Returns the last item, or last n items of an array or string. Opposite of first.

\n", "requiresBlock": false }, "length": { - "args": [ - "value" - ], + "args": ["value"], "numArgs": 1, "example": "{{length [1, 2, 3]}} -> 3", "description": "

Returns the length of the given string or array.

\n", "requiresBlock": false }, "lengthEqual": { - "args": [ - "value", - "length" - ], + "args": ["value", "length"], "numArgs": 2, "example": "{{equalsLength [1, 2, 3] 3}} -> true", "description": "

Returns true if the the length of the given value is equal to the given length. Can be used as a block or inline helper.

\n", "requiresBlock": false }, "map": { - "args": [ - "array", - "fn" - ], + "args": ["array", "fn"], "numArgs": 2, "example": "{{map [1, 2, 3] double}} -> [2, 4, 6]", "description": "

Returns a new array, created by calling function on each element of the given array. For example,

\n", "requiresBlock": false }, "pluck": { - "args": [ - "collection", - "prop" - ], + "args": ["collection", "prop"], "numArgs": 2, "example": "{{pluck [{ 'name': 'Bob' }] 'name' }} -> ['Bob']", "description": "

Map over the given object or array or objects and create an array of values from the given prop. Dot-notation may be used (as a string) to get nested properties.

\n", "requiresBlock": false }, "reverse": { - "args": [ - "value" - ], + "args": ["value"], "numArgs": 1, "example": "{{reverse [1, 2, 3]}} -> [3, 2, 1]", "description": "

Reverse the elements in an array, or the characters in a string.

\n", "requiresBlock": false }, "some": { - "args": [ - "array", - "iter", - "provided" - ], + "args": ["array", "iter", "provided"], "numArgs": 3, "example": "{{#some [1, \"b\", 3] isString}} string found {{else}} No string found {{/some}} -> ' string found '", "description": "

Block helper that returns the block if the callback returns true for some value in the given array.

\n", "requiresBlock": true }, "sort": { - "args": [ - "array", - "key" - ], + "args": ["array", "key"], "numArgs": 2, "example": "{{ sort ['b', 'a', 'c'] }} -> ['a', 'b', 'c']", "description": "

Sort the given array. If an array of objects is passed, you may optionally pass a key to sort on as the second argument. You may alternatively pass a sorting function as the second argument.

\n", "requiresBlock": false }, "sortBy": { - "args": [ - "array", - "props" - ], + "args": ["array", "props"], "numArgs": 2, "example": "{{ sortBy [{'a': 'zzz'}, {'a': 'aaa'}] 'a' }} -> [{'a':'aaa'},{'a':'zzz'}]", "description": "

Sort an array. If an array of objects is passed, you may optionally pass a key to sort on as the second argument. You may alternatively pass a sorting function as the second argument.

\n", "requiresBlock": false }, "withAfter": { - "args": [ - "array", - "idx", - "options" - ], + "args": ["array", "idx", "options"], "numArgs": 3, "example": "{{#withAfter [1, 2, 3] 1 }} {{this}} {{/withAfter}} -> ' 2 3 '", "description": "

Use the items in the array after the specified index as context inside a block. Opposite of withBefore.

\n", "requiresBlock": true }, "withBefore": { - "args": [ - "array", - "idx", - "options" - ], + "args": ["array", "idx", "options"], "numArgs": 3, "example": "{{#withBefore [1, 2, 3] 2 }} {{this}} {{/withBefore}} -> ' 1 '", "description": "

Use the items in the array before the specified index as context inside a block. Opposite of withAfter.

\n", "requiresBlock": true }, "withFirst": { - "args": [ - "array", - "idx", - "options" - ], + "args": ["array", "idx", "options"], "numArgs": 3, "example": "{{#withFirst [1, 2, 3] }}{{this}}{{/withFirst}} -> 1", "description": "

Use the first item in a collection inside a handlebars block expression. Opposite of withLast.

\n", "requiresBlock": true }, "withGroup": { - "args": [ - "array", - "size", - "options" - ], + "args": ["array", "size", "options"], "numArgs": 3, "example": "{{#withGroup [1, 2, 3, 4] 2}}{{#each this}}{{.}}{{/each}}
{{/withGroup}} -> 12
34
", "description": "

Block helper that groups array elements by given group size.

\n", "requiresBlock": true }, "withLast": { - "args": [ - "array", - "idx", - "options" - ], + "args": ["array", "idx", "options"], "numArgs": 3, "example": "{{#withLast [1, 2, 3, 4]}}{{this}}{{/withLast}} -> 4", "description": "

Use the last item or n items in an array as context inside a block. Opposite of withFirst.

\n", "requiresBlock": true }, "withSort": { - "args": [ - "array", - "prop", - "options" - ], + "args": ["array", "prop", "options"], "numArgs": 3, "example": "{{#withSort ['b', 'a', 'c']}}{{this}}{{/withSort}} -> abc", "description": "

Block helper that sorts a collection and exposes the sorted collection as context inside the block.

\n", "requiresBlock": true }, "unique": { - "args": [ - "array", - "options" - ], + "args": ["array", "options"], "numArgs": 2, "example": "{{#each (unique ['a', 'a', 'c', 'b', 'e', 'e']) }}{{.}}{{/each}} -> acbe", "description": "

Block helper that return an array with all duplicate values removed. Best used along with a each helper.

\n", @@ -434,83 +306,61 @@ }, "number": { "bytes": { - "args": [ - "number" - ], + "args": ["number"], "numArgs": 1, "example": "{{ bytes 1386 1 }} -> 1.4 kB", "description": "

Format a number to it's equivalent in bytes. If a string is passed, it's length will be formatted and returned. Examples: - 'foo' => 3 B - 13661855 => 13.66 MB - 825399 => 825.39 kB - 1396 => 1.4 kB

\n", "requiresBlock": false }, "addCommas": { - "args": [ - "num" - ], + "args": ["num"], "numArgs": 1, "example": "{{ addCommas 1000000 }} -> 1,000,000", "description": "

Add commas to numbers

\n", "requiresBlock": false }, "phoneNumber": { - "args": [ - "num" - ], + "args": ["num"], "numArgs": 1, "example": "{{ phoneNumber 8005551212 }} -> (800) 555-1212", "description": "

Convert a string or number to a formatted phone number.

\n", "requiresBlock": false }, "toAbbr": { - "args": [ - "number", - "precision" - ], + "args": ["number", "precision"], "numArgs": 2, "example": "{{ toAbbr 10123 2 }} -> 10.12k", "description": "

Abbreviate numbers to the given number of precision. This for general numbers, not size in bytes.

\n", "requiresBlock": false }, "toExponential": { - "args": [ - "number", - "fractionDigits" - ], + "args": ["number", "fractionDigits"], "numArgs": 2, "example": "{{ toExponential 10123 2 }} -> 1.01e+4", "description": "

Returns a string representing the given number in exponential notation.

\n", "requiresBlock": false }, "toFixed": { - "args": [ - "number", - "digits" - ], + "args": ["number", "digits"], "numArgs": 2, "example": "{{ toFixed 1.1234 2 }} -> 1.12", "description": "

Formats the given number using fixed-point notation.

\n", "requiresBlock": false }, "toFloat": { - "args": [ - "number" - ], + "args": ["number"], "numArgs": 1, "description": "

Convert input to a float.

\n", "requiresBlock": false }, "toInt": { - "args": [ - "number" - ], + "args": ["number"], "numArgs": 1, "description": "

Convert input to an integer.

\n", "requiresBlock": false }, "toPrecision": { - "args": [ - "number", - "precision" - ], + "args": ["number", "precision"], "numArgs": 2, "example": "{{toPrecision '1.1234' 2}} -> 1.1", "description": "

Returns a string representing the Number object to the specified precision.

\n", @@ -519,64 +369,49 @@ }, "url": { "encodeURI": { - "args": [ - "str" - ], + "args": ["str"], "numArgs": 1, "example": "{{ encodeURI 'https://myurl?Hello There' }} -> https%3A%2F%2Fmyurl%3FHello%20There", "description": "

Encodes a Uniform Resource Identifier (URI) component by replacing each instance of certain characters by one, two, three, or four escape sequences representing the UTF-8 encoding of the character.

\n", "requiresBlock": false }, "escape": { - "args": [ - "str" - ], + "args": ["str"], "numArgs": 1, "example": "{{ escape 'https://myurl?Hello+There' }} -> https%3A%2F%2Fmyurl%3FHello%2BThere", "description": "

Escape the given string by replacing characters with escape sequences. Useful for allowing the string to be used in a URL, etc.

\n", "requiresBlock": false }, "decodeURI": { - "args": [ - "str" - ], + "args": ["str"], "numArgs": 1, "example": "{{ decodeURI 'https://myurl?Hello%20There' }} -> https://myurl?Hello There", "description": "

Decode a Uniform Resource Identifier (URI) component.

\n", "requiresBlock": false }, "urlResolve": { - "args": [ - "base", - "href" - ], + "args": ["base", "href"], "numArgs": 2, "example": "{{ urlResolve 'https://myurl' '/api/test' }} -> https://myurl/api/test", "description": "

Take a base URL, and a href URL, and resolve them as a browser would for an anchor tag.

\n", "requiresBlock": false }, "urlParse": { - "args": [ - "str" - ], + "args": ["str"], "numArgs": 1, "example": "{{ urlParse 'https://myurl/api/test' }}", "description": "

Parses a url string into an object.

\n", "requiresBlock": false }, "stripQuerystring": { - "args": [ - "url" - ], + "args": ["url"], "numArgs": 1, "example": "{{ stripQuerystring 'https://myurl/api/test?foo=bar' }} -> 'https://myurl/api/test'", "description": "

Strip the query string from the given url.

\n", "requiresBlock": false }, "stripProtocol": { - "args": [ - "str" - ], + "args": ["str"], "numArgs": 1, "example": "{{ stripProtocol 'https://myurl/api/test' }} -> '//myurl/api/test'", "description": "

Strip protocol from a url. Useful for displaying media that may have an 'http' protocol on secure connections.

\n", @@ -585,604 +420,429 @@ }, "string": { "append": { - "args": [ - "str", - "suffix" - ], + "args": ["str", "suffix"], "numArgs": 2, "example": "{{append 'index' '.html'}} -> index.html", "description": "

Append the specified suffix to the given string.

\n", "requiresBlock": false }, "camelcase": { - "args": [ - "string" - ], + "args": ["string"], "numArgs": 1, "example": "{{camelcase 'foo bar baz'}} -> fooBarBaz", "description": "

camelCase the characters in the given string.

\n", "requiresBlock": false }, "capitalize": { - "args": [ - "str" - ], + "args": ["str"], "numArgs": 1, "example": "{{capitalize 'foo bar baz'}} -> Foo bar baz", "description": "

Capitalize the first word in a sentence.

\n", "requiresBlock": false }, "capitalizeAll": { - "args": [ - "str" - ], + "args": ["str"], "numArgs": 1, "example": "{{ capitalizeAll 'foo bar baz'}} -> Foo Bar Baz", "description": "

Capitalize all words in a string.

\n", "requiresBlock": false }, "center": { - "args": [ - "str", - "spaces" - ], + "args": ["str", "spaces"], "numArgs": 2, "example": "{{ center 'test' 1}} -> ' test '", "description": "

Center a string using non-breaking spaces

\n", "requiresBlock": false }, "chop": { - "args": [ - "string" - ], + "args": ["string"], "numArgs": 1, "example": "{{ chop ' ABC '}} -> ABC", "description": "

Like trim, but removes both extraneous whitespace and non-word characters from the beginning and end of a string.

\n", "requiresBlock": false }, "dashcase": { - "args": [ - "string" - ], + "args": ["string"], "numArgs": 1, "example": "{{dashcase 'a-b-c d_e'}} -> a-b-c-d-e", "description": "

dash-case the characters in string. Replaces non-word characters and periods with hyphens.

\n", "requiresBlock": false }, "dotcase": { - "args": [ - "string" - ], + "args": ["string"], "numArgs": 1, "example": "{{dotcase 'a-b-c d_e'}} -> a.b.c.d.e", "description": "

dot.case the characters in string.

\n", "requiresBlock": false }, "downcase": { - "args": [ - "string" - ], + "args": ["string"], "numArgs": 1, "example": "{{downcase 'aBcDeF'}} -> abcdef", "description": "

Lowercase all of the characters in the given string. Alias for lowercase.

\n", "requiresBlock": false }, "ellipsis": { - "args": [ - "str", - "length" - ], + "args": ["str", "length"], "numArgs": 2, "example": "{{ellipsis 'foo bar baz' 7}} -> foo bar…", "description": "

Truncates a string to the specified length, and appends it with an elipsis, .

\n", "requiresBlock": false }, "hyphenate": { - "args": [ - "str" - ], + "args": ["str"], "numArgs": 1, "example": "{{hyphenate 'foo bar baz qux'}} -> foo-bar-baz-qux", "description": "

Replace spaces in a string with hyphens.

\n", "requiresBlock": false }, "isString": { - "args": [ - "value" - ], + "args": ["value"], "numArgs": 1, "example": "{{isString 'foo'}} -> true", "description": "

Return true if value is a string.

\n", "requiresBlock": false }, "lowercase": { - "args": [ - "str" - ], + "args": ["str"], "numArgs": 1, "example": "{{lowercase 'Foo BAR baZ'}} -> foo bar baz", "description": "

Lowercase all characters in the given string.

\n", "requiresBlock": false }, "occurrences": { - "args": [ - "str", - "substring" - ], + "args": ["str", "substring"], "numArgs": 2, "example": "{{occurrences 'foo bar foo bar baz' 'foo'}} -> 2", "description": "

Return the number of occurrences of substring within the given string.

\n", "requiresBlock": false }, "pascalcase": { - "args": [ - "string" - ], + "args": ["string"], "numArgs": 1, "example": "{{pascalcase 'foo bar baz'}} -> FooBarBaz", "description": "

PascalCase the characters in string.

\n", "requiresBlock": false }, "pathcase": { - "args": [ - "string" - ], + "args": ["string"], "numArgs": 1, "example": "{{pathcase 'a-b-c d_e'}} -> a/b/c/d/e", "description": "

path/case the characters in string.

\n", "requiresBlock": false }, "plusify": { - "args": [ - "str" - ], + "args": ["str"], "numArgs": 1, "example": "{{plusify 'foo bar baz'}} -> foo+bar+baz", "description": "

Replace spaces in the given string with pluses.

\n", "requiresBlock": false }, "prepend": { - "args": [ - "str", - "prefix" - ], + "args": ["str", "prefix"], "numArgs": 2, "example": "{{prepend 'bar' 'foo-'}} -> foo-bar", "description": "

Prepends the given string with the specified prefix.

\n", "requiresBlock": false }, "remove": { - "args": [ - "str", - "substring" - ], + "args": ["str", "substring"], "numArgs": 2, "example": "{{remove 'a b a b a b' 'a '}} -> b b b", "description": "

Remove all occurrences of substring from the given str.

\n", "requiresBlock": false }, "removeFirst": { - "args": [ - "str", - "substring" - ], + "args": ["str", "substring"], "numArgs": 2, "example": "{{removeFirst 'a b a b a b' 'a'}} -> ' b a b a b'", "description": "

Remove the first occurrence of substring from the given str.

\n", "requiresBlock": false }, "replace": { - "args": [ - "str", - "a", - "b" - ], + "args": ["str", "a", "b"], "numArgs": 3, "example": "{{replace 'a b a b a b' 'a' 'z'}} -> z b z b z b", "description": "

Replace all occurrences of substring a with substring b.

\n", "requiresBlock": false }, "replaceFirst": { - "args": [ - "str", - "a", - "b" - ], + "args": ["str", "a", "b"], "numArgs": 3, "example": "{{replaceFirst 'a b a b a b' 'a' 'z'}} -> z b a b a b", "description": "

Replace the first occurrence of substring a with substring b.

\n", "requiresBlock": false }, "sentence": { - "args": [ - "str" - ], + "args": ["str"], "numArgs": 1, "example": "{{sentence 'hello world. goodbye world.'}} -> Hello world. Goodbye world.", "description": "

Sentence case the given string

\n", "requiresBlock": false }, "snakecase": { - "args": [ - "string" - ], + "args": ["string"], "numArgs": 1, "example": "{{snakecase 'a-b-c d_e'}} -> a_b_c_d_e", "description": "

snake_case the characters in the given string.

\n", "requiresBlock": false }, "split": { - "args": [ - "string" - ], + "args": ["string"], "numArgs": 1, "example": "{{split 'a,b,c'}} -> ['a', 'b', 'c']", "description": "

Split string by the given character.

\n", "requiresBlock": false }, "startsWith": { - "args": [ - "prefix", - "testString", - "options" - ], + "args": ["prefix", "testString", "options"], "numArgs": 3, "example": "{{#startsWith 'Goodbye' 'Hello, world!'}}Yep{{else}}Nope{{/startsWith}} -> Nope", "description": "

Tests whether a string begins with the given prefix.

\n", "requiresBlock": true }, "titleize": { - "args": [ - "str" - ], + "args": ["str"], "numArgs": 1, "example": "{{titleize 'this is title case' }} -> This Is Title Case", "description": "

Title case the given string.

\n", "requiresBlock": false }, "trim": { - "args": [ - "string" - ], + "args": ["string"], "numArgs": 1, "example": "{{trim ' ABC ' }} -> ABC", "description": "

Removes extraneous whitespace from the beginning and end of a string.

\n", "requiresBlock": false }, "trimLeft": { - "args": [ - "string" - ], + "args": ["string"], "numArgs": 1, "example": "{{trimLeft ' ABC ' }} -> 'ABC '", "description": "

Removes extraneous whitespace from the beginning of a string.

\n", "requiresBlock": false }, "trimRight": { - "args": [ - "string" - ], + "args": ["string"], "numArgs": 1, "example": "{{trimRight ' ABC ' }} -> ' ABC'", "description": "

Removes extraneous whitespace from the end of a string.

\n", "requiresBlock": false }, "truncate": { - "args": [ - "str", - "limit", - "suffix" - ], + "args": ["str", "limit", "suffix"], "numArgs": 3, "example": "{{truncate 'foo bar baz' 7 }} -> foo bar", "description": "

Truncate a string to the specified length. Also see ellipsis.

\n", "requiresBlock": false }, "truncateWords": { - "args": [ - "str", - "limit", - "suffix" - ], + "args": ["str", "limit", "suffix"], "numArgs": 3, "example": "{{truncateWords 'foo bar baz' 1 }} -> foo…", "description": "

Truncate a string to have the specified number of words. Also see truncate.

\n", "requiresBlock": false }, "upcase": { - "args": [ - "string" - ], + "args": ["string"], "numArgs": 1, "example": "{{upcase 'aBcDef'}} -> ABCDEF", "description": "

Uppercase all of the characters in the given string. Alias for uppercase.

\n", "requiresBlock": false }, "uppercase": { - "args": [ - "str", - "options" - ], + "args": ["str", "options"], "numArgs": 2, "example": "{{uppercase 'aBcDef'}} -> ABCDEF", "description": "

Uppercase all of the characters in the given string. If used as a block helper it will uppercase the entire block. This helper does not support inverse blocks.

\n", "requiresBlock": false }, "lorem": { - "args": [ - "num" - ], + "args": ["num"], "numArgs": 1, "example": "{{lorem 11}} -> Lorem ipsum", "description": "

Takes a number and returns that many charaters of Lorem Ipsum

\n", "requiresBlock": false + }, + "decodeId": { + "args": ["string"], + "numArgs": 1, + "example": "{{ decodeId %5B42%5D -> 42 }}", + "description": "

Takes an encoded ID from SQL and returns the unencoded version

", + "requiresBlock": false } }, "comparison": { "and": { - "args": [ - "a", - "b", - "options" - ], + "args": ["a", "b", "options"], "numArgs": 3, "example": "{{#and great magnificent}}both{{else}}no{{/and}} -> no", "description": "

Helper that renders the block if both of the given values are truthy. If an inverse block is specified it will be rendered when falsy. Works as a block helper, inline helper or subexpression.

\n", "requiresBlock": true }, "compare": { - "args": [ - "a", - "operator", - "b", - "options" - ], + "args": ["a", "operator", "b", "options"], "numArgs": 4, "example": "{{compare 10 '<' 5 }} -> false", "description": "

Render a block when a comparison of the first and third arguments returns true. The second argument is the [arithemetic operator][operators] to use. You may also optionally specify an inverse block to render when falsy.

\n", "requiresBlock": false }, "contains": { - "args": [ - "collection", - "value", - "[startIndex=0]", - "options" - ], + "args": ["collection", "value", "[startIndex=0]", "options"], "numArgs": 4, "example": "{{#contains ['a', 'b', 'c'] 'd'}} This will not be rendered. {{else}} This will be rendered. {{/contains}} -> ' This will be rendered. '", "description": "

Block helper that renders the block if collection has the given value, using strict equality (===) for comparison, otherwise the inverse block is rendered (if specified). If a startIndex is specified and is negative, it is used as the offset from the end of the collection.

\n", "requiresBlock": true }, "default": { - "args": [ - "value", - "defaultValue" - ], + "args": ["value", "defaultValue"], "numArgs": 2, "example": "{{default null null 'default'}} -> default", "description": "

Returns the first value that is not undefined, otherwise the 'default' value is returned.

\n", "requiresBlock": false }, "eq": { - "args": [ - "a", - "b", - "options" - ], + "args": ["a", "b", "options"], "numArgs": 3, "example": "{{#eq 3 3}}equal{{else}}not equal{{/eq}} -> equal", "description": "

Block helper that renders a block if a is equal to b. If an inverse block is specified it will be rendered when falsy. You may optionally use the compare='' hash argument for the second value.

\n", "requiresBlock": true }, "gt": { - "args": [ - "a", - "b", - "options" - ], + "args": ["a", "b", "options"], "numArgs": 3, "example": "{{#gt 4 3}} greater than{{else}} not greater than{{/gt}} -> ' greater than'", "description": "

Block helper that renders a block if a is greater than b. If an inverse block is specified it will be rendered when falsy. You may optionally use the compare='' hash argument for the second value.

\n", "requiresBlock": true }, "gte": { - "args": [ - "a", - "b", - "options" - ], + "args": ["a", "b", "options"], "numArgs": 3, "example": "{{#gte 4 3}} greater than or equal{{else}} not greater than{{/gte}} -> ' greater than or equal'", "description": "

Block helper that renders a block if a is greater than or equal to b. If an inverse block is specified it will be rendered when falsy. You may optionally use the compare='' hash argument for the second value.

\n", "requiresBlock": true }, "has": { - "args": [ - "val", - "pattern", - "options" - ], + "args": ["val", "pattern", "options"], "numArgs": 3, "example": "{{#has 'foobar' 'foo'}}has it{{else}}doesn't{{/has}} -> has it", "description": "

Block helper that renders a block if value has pattern. If an inverse block is specified it will be rendered when falsy.

\n", "requiresBlock": true }, "isFalsey": { - "args": [ - "val", - "options" - ], + "args": ["val", "options"], "numArgs": 2, "example": "{{isFalsey '' }} -> true", "description": "

Returns true if the given value is falsey. Uses the [falsey][] library for comparisons. Please see that library for more information or to report bugs with this helper.

\n", "requiresBlock": false }, "isTruthy": { - "args": [ - "val", - "options" - ], + "args": ["val", "options"], "numArgs": 2, "example": "{{isTruthy '12' }} -> true", "description": "

Returns true if the given value is truthy. Uses the [falsey][] library for comparisons. Please see that library for more information or to report bugs with this helper.

\n", "requiresBlock": false }, "ifEven": { - "args": [ - "number", - "options" - ], + "args": ["number", "options"], "numArgs": 2, "example": "{{#ifEven 2}} even {{else}} odd {{/ifEven}} -> ' even '", "description": "

Return true if the given value is an even number.

\n", "requiresBlock": true }, "ifNth": { - "args": [ - "a", - "b", - "options" - ], + "args": ["a", "b", "options"], "numArgs": 3, "example": "{{#ifNth 2 10}}remainder{{else}}no remainder{{/ifNth}} -> remainder", "description": "

Conditionally renders a block if the remainder is zero when b operand is divided by a. If an inverse block is specified it will be rendered when the remainder is not zero.

\n", "requiresBlock": true }, "ifOdd": { - "args": [ - "value", - "options" - ], + "args": ["value", "options"], "numArgs": 2, "example": "{{#ifOdd 3}}odd{{else}}even{{/ifOdd}} -> odd", "description": "

Block helper that renders a block if value is an odd number. If an inverse block is specified it will be rendered when falsy.

\n", "requiresBlock": true }, "is": { - "args": [ - "a", - "b", - "options" - ], + "args": ["a", "b", "options"], "numArgs": 3, "example": "{{#is 3 3}} is {{else}} is not {{/is}} -> ' is '", "description": "

Block helper that renders a block if a is equal to b. If an inverse block is specified it will be rendered when falsy. Similar to eq but does not do strict equality.

\n", "requiresBlock": true }, "isnt": { - "args": [ - "a", - "b", - "options" - ], + "args": ["a", "b", "options"], "numArgs": 3, "example": "{{#isnt 3 3}} isnt {{else}} is {{/isnt}} -> ' is '", "description": "

Block helper that renders a block if a is not equal to b. If an inverse block is specified it will be rendered when falsy. Similar to unlessEq but does not use strict equality for comparisons.

\n", "requiresBlock": true }, "lt": { - "args": [ - "context", - "options" - ], + "args": ["context", "options"], "numArgs": 2, "example": "{{#lt 2 3}} less than {{else}} more than or equal {{/lt}} -> ' less than '", "description": "

Block helper that renders a block if a is less than b. If an inverse block is specified it will be rendered when falsy. You may optionally use the compare='' hash argument for the second value.

\n", "requiresBlock": true }, "lte": { - "args": [ - "a", - "b", - "options" - ], + "args": ["a", "b", "options"], "numArgs": 3, "example": "{{#lte 2 3}} less than or equal {{else}} more than {{/lte}} -> ' less than or equal '", "description": "

Block helper that renders a block if a is less than or equal to b. If an inverse block is specified it will be rendered when falsy. You may optionally use the compare='' hash argument for the second value.

\n", "requiresBlock": true }, "neither": { - "args": [ - "a", - "b", - "options" - ], + "args": ["a", "b", "options"], "numArgs": 3, "example": "{{#neither null null}}both falsey{{else}}both not falsey{{/neither}} -> both falsey", "description": "

Block helper that renders a block if neither of the given values are truthy. If an inverse block is specified it will be rendered when falsy.

\n", "requiresBlock": true }, "not": { - "args": [ - "val", - "options" - ], + "args": ["val", "options"], "numArgs": 2, "example": "{{#not undefined }}falsey{{else}}not falsey{{/not}} -> falsey", "description": "

Returns true if val is falsey. Works as a block or inline helper.

\n", "requiresBlock": true }, "or": { - "args": [ - "arguments", - "options" - ], + "args": ["arguments", "options"], "numArgs": 2, "example": "{{#or 1 2 undefined }} at least one truthy {{else}} all falsey {{/or}} -> ' at least one truthy '", "description": "

Block helper that renders a block if any of the given values is truthy. If an inverse block is specified it will be rendered when falsy.

\n", "requiresBlock": true }, "unlessEq": { - "args": [ - "a", - "b", - "options" - ], + "args": ["a", "b", "options"], "numArgs": 3, "example": "{{#unlessEq 2 1 }} not equal {{else}} equal {{/unlessEq}} -> ' not equal '", "description": "

Block helper that always renders the inverse block unless a is equal to b.

\n", "requiresBlock": true }, "unlessGt": { - "args": [ - "a", - "b", - "options" - ], + "args": ["a", "b", "options"], "numArgs": 3, "example": "{{#unlessGt 20 1 }} not greater than {{else}} greater than {{/unlessGt}} -> ' greater than '", "description": "

Block helper that always renders the inverse block unless a is greater than b.

\n", "requiresBlock": true }, "unlessLt": { - "args": [ - "a", - "b", - "options" - ], + "args": ["a", "b", "options"], "numArgs": 3, "example": "{{#unlessLt 20 1 }}greater than or equal{{else}}less than{{/unlessLt}} -> greater than or equal", "description": "

Block helper that always renders the inverse block unless a is less than b.

\n", "requiresBlock": true }, "unlessGteq": { - "args": [ - "a", - "b", - "options" - ], + "args": ["a", "b", "options"], "numArgs": 3, "example": "{{#unlessGteq 20 1 }} less than {{else}}greater than or equal to{{/unlessGteq}} -> greater than or equal to", "description": "

Block helper that always renders the inverse block unless a is greater than or equal to b.

\n", "requiresBlock": true }, "unlessLteq": { - "args": [ - "a", - "b", - "options" - ], + "args": ["a", "b", "options"], "numArgs": 3, "example": "{{#unlessLteq 20 1 }} greater than {{else}} less than or equal to {{/unlessLteq}} -> ' greater than '", "description": "

Block helper that always renders the inverse block unless a is less than or equal to b.

\n", @@ -1191,122 +851,85 @@ }, "object": { "extend": { - "args": [ - "objects" - ], + "args": ["objects"], "numArgs": 1, "description": "

Extend the context with the properties of other objects. A shallow merge is performed to avoid mutating the context.

\n", "requiresBlock": false }, "forIn": { - "args": [ - "context", - "options" - ], + "args": ["context", "options"], "numArgs": 2, "description": "

Block helper that iterates over the properties of an object, exposing each key and value on the context.

\n", "requiresBlock": true }, "forOwn": { - "args": [ - "obj", - "options" - ], + "args": ["obj", "options"], "numArgs": 2, "description": "

Block helper that iterates over the own properties of an object, exposing each key and value on the context.

\n", "requiresBlock": true }, "toPath": { - "args": [ - "prop" - ], + "args": ["prop"], "numArgs": 1, "description": "

Take arguments and, if they are string or number, convert them to a dot-delineated object property path.

\n", "requiresBlock": false }, "get": { - "args": [ - "prop", - "context", - "options" - ], + "args": ["prop", "context", "options"], "numArgs": 3, "description": "

Use property paths (a.b.c) to get a value or nested value from the context. Works as a regular helper or block helper.

\n", "requiresBlock": true }, "getObject": { - "args": [ - "prop", - "context" - ], + "args": ["prop", "context"], "numArgs": 2, "description": "

Use property paths (a.b.c) to get an object from the context. Differs from the get helper in that this helper will return the actual object, including the given property key. Also, this helper does not work as a block helper.

\n", "requiresBlock": false }, "hasOwn": { - "args": [ - "key", - "context" - ], + "args": ["key", "context"], "numArgs": 2, "description": "

Return true if key is an own, enumerable property of the given context object.

\n", "requiresBlock": false }, "isObject": { - "args": [ - "value" - ], + "args": ["value"], "numArgs": 1, "description": "

Return true if value is an object.

\n", "requiresBlock": false }, "JSONparse": { - "args": [ - "string" - ], + "args": ["string"], "numArgs": 1, "description": "

Parses the given string using JSON.parse.

\n", "requiresBlock": true }, "JSONstringify": { - "args": [ - "obj" - ], + "args": ["obj"], "numArgs": 1, "description": "

Stringify an object using JSON.stringify.

\n", "requiresBlock": false }, "merge": { - "args": [ - "object", - "objects" - ], + "args": ["object", "objects"], "numArgs": 2, "description": "

Deeply merge the properties of the given objects with the context object.

\n", "requiresBlock": false }, "parseJSON": { - "args": [ - "string" - ], + "args": ["string"], "numArgs": 1, "description": "

Parses the given string using JSON.parse.

\n", "requiresBlock": true }, "pick": { - "args": [ - "properties", - "context", - "options" - ], + "args": ["properties", "context", "options"], "numArgs": 3, "description": "

Pick properties from the context object.

\n", "requiresBlock": true }, "stringify": { - "args": [ - "obj" - ], + "args": ["obj"], "numArgs": 1, "description": "

Stringify an object using JSON.stringify.

\n", "requiresBlock": false @@ -1314,18 +937,14 @@ }, "regex": { "toRegex": { - "args": [ - "str" - ], + "args": ["str"], "numArgs": 1, "example": "{{toRegex 'foo'}} -> /foo/", "description": "

Convert the given string to a regular expression.

\n", "requiresBlock": false }, "test": { - "args": [ - "str" - ], + "args": ["str"], "numArgs": 1, "example": "{{test 'foobar' (toRegex 'foo')}} -> true", "description": "

Returns true if the given str matches the given regex. A regex can be passed on the context, or using the toRegex helper as a subexpression.

\n", @@ -1343,22 +962,16 @@ }, "date": { "date": { - "args": [ - "datetime", - "format" - ], + "args": ["datetime", "format"], "numArgs": 2, "example": "{{date now \"DD-MM-YYYY\" \"America/New_York\" }} -> 21-01-2021", "description": "

Format a date using moment.js date formatting - the timezone is optional and uses the tz database.

\n" }, "duration": { - "args": [ - "time", - "durationType" - ], + "args": ["time", "durationType"], "numArgs": 2, "example": "{{duration 8 \"seconds\"}} -> a few seconds", "description": "

Produce a humanized duration left/until given an amount of time and the type of time measurement.

\n" } } -} \ No newline at end of file +} From a434973e6adcb90fd11a1b71b38a79a70441487c Mon Sep 17 00:00:00 2001 From: mikesealey Date: Wed, 12 Feb 2025 10:09:16 +0000 Subject: [PATCH 02/29] wip --- .../string-templates/src/helpers/constants.ts | 1 + .../string-templates/src/helpers/index.ts | 3 + packages/string-templates/src/manifest.json | 677 ++++++++++++++---- 3 files changed, 536 insertions(+), 145 deletions(-) diff --git a/packages/string-templates/src/helpers/constants.ts b/packages/string-templates/src/helpers/constants.ts index ee84a1dc47..fb6bf4e4f5 100644 --- a/packages/string-templates/src/helpers/constants.ts +++ b/packages/string-templates/src/helpers/constants.ts @@ -36,6 +36,7 @@ export const HelperFunctionNames = { ALL: "all", LITERAL: "literal", JS: "js", + DECODE_ID: "decodeId", } export const LITERAL_MARKER = "%LITERAL%" diff --git a/packages/string-templates/src/helpers/index.ts b/packages/string-templates/src/helpers/index.ts index fe74a6d711..8cd2de429f 100644 --- a/packages/string-templates/src/helpers/index.ts +++ b/packages/string-templates/src/helpers/index.ts @@ -32,6 +32,9 @@ const HELPERS = [ }), // javascript helper new Helper(HelperFunctionNames.JS, processJS, false), + new Helper(HelperFunctionNames.DECODE_ID, value => { + return value + }), // this help is applied to all statements new Helper( HelperFunctionNames.ALL, diff --git a/packages/string-templates/src/manifest.json b/packages/string-templates/src/manifest.json index f02caa7692..7931c60641 100644 --- a/packages/string-templates/src/manifest.json +++ b/packages/string-templates/src/manifest.json @@ -1,105 +1,144 @@ { "math": { "abs": { - "args": ["a"], + "args": [ + "a" + ], "numArgs": 1, "example": "{{ abs 12012.1000 }} -> 12012.1", "description": "

Return the magnitude of a.

\n", "requiresBlock": false }, "add": { - "args": ["a", "b"], + "args": [ + "a", + "b" + ], "numArgs": 2, "example": "{{ add 1 2 }} -> 3", "description": "

Return the sum of a plus b.

\n", "requiresBlock": false }, "avg": { - "args": ["array"], + "args": [ + "array" + ], "numArgs": 1, "example": "{{ avg 1 2 3 4 5 }} -> 3", "description": "

Returns the average of all numbers in the given array.

\n", "requiresBlock": false }, "ceil": { - "args": ["value"], + "args": [ + "value" + ], "numArgs": 1, "example": "{{ ceil 1.2 }} -> 2", "description": "

Get the Math.ceil() of the given value.

\n", "requiresBlock": false }, "divide": { - "args": ["a", "b"], + "args": [ + "a", + "b" + ], "numArgs": 2, "example": "{{ divide 10 5 }} -> 2", "description": "

Divide a by b

\n", "requiresBlock": false }, "floor": { - "args": ["value"], + "args": [ + "value" + ], "numArgs": 1, "example": "{{ floor 1.2 }} -> 1", "description": "

Get the Math.floor() of the given value.

\n", "requiresBlock": false }, "minus": { - "args": ["a", "b"], + "args": [ + "a", + "b" + ], "numArgs": 2, "example": "{{ subtract 10 5 }} -> 5", "description": "

Return the product of a minus b.

\n", "requiresBlock": false }, "modulo": { - "args": ["a", "b"], + "args": [ + "a", + "b" + ], "numArgs": 2, "example": "{{ modulo 10 5 }} -> 0", "description": "

Get the remainder of a division operation.

\n", "requiresBlock": false }, "multiply": { - "args": ["a", "b"], + "args": [ + "a", + "b" + ], "numArgs": 2, "example": "{{ multiply 10 5 }} -> 50", "description": "

Multiply number a by number b.

\n", "requiresBlock": false }, "plus": { - "args": ["a", "b"], + "args": [ + "a", + "b" + ], "numArgs": 2, "example": "{{ plus 10 5 }} -> 15", "description": "

Add a by b.

\n", "requiresBlock": false }, "random": { - "args": ["min", "max"], + "args": [ + "min", + "max" + ], "numArgs": 2, "example": "{{ random 0 20 }} -> 10", "description": "

Generate a random number between two values

\n", "requiresBlock": false }, "remainder": { - "args": ["a", "b"], + "args": [ + "a", + "b" + ], "numArgs": 2, "example": "{{ remainder 10 6 }} -> 4", "description": "

Get the remainder when a is divided by b.

\n", "requiresBlock": false }, "round": { - "args": ["number"], + "args": [ + "number" + ], "numArgs": 1, "example": "{{ round 10.3 }} -> 10", "description": "

Round the given number.

\n", "requiresBlock": false }, "subtract": { - "args": ["a", "b"], + "args": [ + "a", + "b" + ], "numArgs": 2, "example": "{{ subtract 10 5 }} -> 5", "description": "

Return the product of a minus b.

\n", "requiresBlock": false }, "sum": { - "args": ["array"], + "args": [ + "array" + ], "numArgs": 1, "example": "{{ sum [1, 2, 3] }} -> 6", "description": "

Returns the sum of all numbers in the given array.

\n", @@ -108,196 +147,285 @@ }, "array": { "after": { - "args": ["array", "n"], + "args": [ + "array", + "n" + ], "numArgs": 2, "example": "{{ after ['a', 'b', 'c', 'd'] 2}} -> ['c', 'd']", "description": "

Returns all of the items in an array after the specified index. Opposite of before.

\n", "requiresBlock": false }, "arrayify": { - "args": ["value"], + "args": [ + "value" + ], "numArgs": 1, "example": "{{ arrayify 'foo' }} -> ['foo']", "description": "

Cast the given value to an array.

\n", "requiresBlock": false }, "before": { - "args": ["array", "n"], + "args": [ + "array", + "n" + ], "numArgs": 2, "example": "{{ before ['a', 'b', 'c', 'd'] 3}} -> ['a', 'b']", "description": "

Return all of the items in the collection before the specified count. Opposite of after.

\n", "requiresBlock": false }, "eachIndex": { - "args": ["array", "options"], + "args": [ + "array", + "options" + ], "numArgs": 2, "example": "{{#eachIndex [1, 2, 3]}} {{item}} is {{index}} {{/eachIndex}} -> ' 1 is 0 2 is 1 3 is 2 '", "description": "

Iterates the array, listing an item and the index of it.

\n", "requiresBlock": true }, "filter": { - "args": ["array", "value", "options"], + "args": [ + "array", + "value", + "options" + ], "numArgs": 3, "example": "{{#filter [1, 2, 3] 2}}2 Found{{else}}2 not found{{/filter}} -> 2 Found", "description": "

Block helper that filters the given array and renders the block for values that evaluate to true, otherwise the inverse block is returned.

\n", "requiresBlock": true }, "first": { - "args": ["array", "n"], + "args": [ + "array", + "n" + ], "numArgs": 2, "example": "{{first [1, 2, 3, 4] 2}} -> 1,2", "description": "

Returns the first item, or first n items of an array.

\n", "requiresBlock": false }, "forEach": { - "args": ["array", "options"], + "args": [ + "array", + "options" + ], "numArgs": 2, "example": "{{#forEach [{ 'name': 'John' }] }} {{ name }} {{/forEach}} -> ' John '", "description": "

Iterates over each item in an array and exposes the current item in the array as context to the inner block. In addition to the current array item, the helper exposes the following variables to the inner block: - index - total - isFirst - isLast Also, @index is exposed as a private variable, and additional private variables may be defined as hash arguments.

\n", "requiresBlock": true }, "inArray": { - "args": ["array", "value", "options"], + "args": [ + "array", + "value", + "options" + ], "numArgs": 3, "example": "{{#inArray [1, 2, 3] 2}} 2 exists {{else}} 2 does not exist {{/inArray}} -> ' 2 exists '", "description": "

Block helper that renders the block if an array has the given value. Optionally specify an inverse block to render when the array does not have the given value.

\n", "requiresBlock": true }, "isArray": { - "args": ["value"], + "args": [ + "value" + ], "numArgs": 1, "example": "{{isArray [1, 2]}} -> true", "description": "

Returns true if value is an es5 array.

\n", "requiresBlock": false }, "itemAt": { - "args": ["array", "idx"], + "args": [ + "array", + "idx" + ], "numArgs": 2, "example": "{{itemAt [1, 2, 3] 1}} -> 2", "description": "

Returns the item from array at index idx.

\n", "requiresBlock": false }, "join": { - "args": ["array", "separator"], + "args": [ + "array", + "separator" + ], "numArgs": 2, "example": "{{join [1, 2, 3]}} -> 1, 2, 3", "description": "

Join all elements of array into a string, optionally using a given separator.

\n", "requiresBlock": false }, "equalsLength": { - "args": ["value", "length"], + "args": [ + "value", + "length" + ], "numArgs": 2, "example": "{{equalsLength [1, 2, 3] 3}} -> true", "description": "

Returns true if the the length of the given value is equal to the given length. Can be used as a block or inline helper.

\n", "requiresBlock": false }, "last": { - "args": ["value", "n"], + "args": [ + "value", + "n" + ], "numArgs": 2, "example": "{{last [1, 2, 3]}} -> 3", "description": "

Returns the last item, or last n items of an array or string. Opposite of first.

\n", "requiresBlock": false }, "length": { - "args": ["value"], + "args": [ + "value" + ], "numArgs": 1, "example": "{{length [1, 2, 3]}} -> 3", "description": "

Returns the length of the given string or array.

\n", "requiresBlock": false }, "lengthEqual": { - "args": ["value", "length"], + "args": [ + "value", + "length" + ], "numArgs": 2, "example": "{{equalsLength [1, 2, 3] 3}} -> true", "description": "

Returns true if the the length of the given value is equal to the given length. Can be used as a block or inline helper.

\n", "requiresBlock": false }, "map": { - "args": ["array", "fn"], + "args": [ + "array", + "fn" + ], "numArgs": 2, "example": "{{map [1, 2, 3] double}} -> [2, 4, 6]", "description": "

Returns a new array, created by calling function on each element of the given array. For example,

\n", "requiresBlock": false }, "pluck": { - "args": ["collection", "prop"], + "args": [ + "collection", + "prop" + ], "numArgs": 2, "example": "{{pluck [{ 'name': 'Bob' }] 'name' }} -> ['Bob']", "description": "

Map over the given object or array or objects and create an array of values from the given prop. Dot-notation may be used (as a string) to get nested properties.

\n", "requiresBlock": false }, "reverse": { - "args": ["value"], + "args": [ + "value" + ], "numArgs": 1, "example": "{{reverse [1, 2, 3]}} -> [3, 2, 1]", "description": "

Reverse the elements in an array, or the characters in a string.

\n", "requiresBlock": false }, "some": { - "args": ["array", "iter", "provided"], + "args": [ + "array", + "iter", + "provided" + ], "numArgs": 3, "example": "{{#some [1, \"b\", 3] isString}} string found {{else}} No string found {{/some}} -> ' string found '", "description": "

Block helper that returns the block if the callback returns true for some value in the given array.

\n", "requiresBlock": true }, "sort": { - "args": ["array", "key"], + "args": [ + "array", + "key" + ], "numArgs": 2, "example": "{{ sort ['b', 'a', 'c'] }} -> ['a', 'b', 'c']", "description": "

Sort the given array. If an array of objects is passed, you may optionally pass a key to sort on as the second argument. You may alternatively pass a sorting function as the second argument.

\n", "requiresBlock": false }, "sortBy": { - "args": ["array", "props"], + "args": [ + "array", + "props" + ], "numArgs": 2, "example": "{{ sortBy [{'a': 'zzz'}, {'a': 'aaa'}] 'a' }} -> [{'a':'aaa'},{'a':'zzz'}]", "description": "

Sort an array. If an array of objects is passed, you may optionally pass a key to sort on as the second argument. You may alternatively pass a sorting function as the second argument.

\n", "requiresBlock": false }, "withAfter": { - "args": ["array", "idx", "options"], + "args": [ + "array", + "idx", + "options" + ], "numArgs": 3, "example": "{{#withAfter [1, 2, 3] 1 }} {{this}} {{/withAfter}} -> ' 2 3 '", "description": "

Use the items in the array after the specified index as context inside a block. Opposite of withBefore.

\n", "requiresBlock": true }, "withBefore": { - "args": ["array", "idx", "options"], + "args": [ + "array", + "idx", + "options" + ], "numArgs": 3, "example": "{{#withBefore [1, 2, 3] 2 }} {{this}} {{/withBefore}} -> ' 1 '", "description": "

Use the items in the array before the specified index as context inside a block. Opposite of withAfter.

\n", "requiresBlock": true }, "withFirst": { - "args": ["array", "idx", "options"], + "args": [ + "array", + "idx", + "options" + ], "numArgs": 3, "example": "{{#withFirst [1, 2, 3] }}{{this}}{{/withFirst}} -> 1", "description": "

Use the first item in a collection inside a handlebars block expression. Opposite of withLast.

\n", "requiresBlock": true }, "withGroup": { - "args": ["array", "size", "options"], + "args": [ + "array", + "size", + "options" + ], "numArgs": 3, "example": "{{#withGroup [1, 2, 3, 4] 2}}{{#each this}}{{.}}{{/each}}
{{/withGroup}} -> 12
34
", "description": "

Block helper that groups array elements by given group size.

\n", "requiresBlock": true }, "withLast": { - "args": ["array", "idx", "options"], + "args": [ + "array", + "idx", + "options" + ], "numArgs": 3, "example": "{{#withLast [1, 2, 3, 4]}}{{this}}{{/withLast}} -> 4", "description": "

Use the last item or n items in an array as context inside a block. Opposite of withFirst.

\n", "requiresBlock": true }, "withSort": { - "args": ["array", "prop", "options"], + "args": [ + "array", + "prop", + "options" + ], "numArgs": 3, "example": "{{#withSort ['b', 'a', 'c']}}{{this}}{{/withSort}} -> abc", "description": "

Block helper that sorts a collection and exposes the sorted collection as context inside the block.

\n", "requiresBlock": true }, "unique": { - "args": ["array", "options"], + "args": [ + "array", + "options" + ], "numArgs": 2, "example": "{{#each (unique ['a', 'a', 'c', 'b', 'e', 'e']) }}{{.}}{{/each}} -> acbe", "description": "

Block helper that return an array with all duplicate values removed. Best used along with a each helper.

\n", @@ -306,61 +434,83 @@ }, "number": { "bytes": { - "args": ["number"], + "args": [ + "number" + ], "numArgs": 1, "example": "{{ bytes 1386 1 }} -> 1.4 kB", "description": "

Format a number to it's equivalent in bytes. If a string is passed, it's length will be formatted and returned. Examples: - 'foo' => 3 B - 13661855 => 13.66 MB - 825399 => 825.39 kB - 1396 => 1.4 kB

\n", "requiresBlock": false }, "addCommas": { - "args": ["num"], + "args": [ + "num" + ], "numArgs": 1, "example": "{{ addCommas 1000000 }} -> 1,000,000", "description": "

Add commas to numbers

\n", "requiresBlock": false }, "phoneNumber": { - "args": ["num"], + "args": [ + "num" + ], "numArgs": 1, "example": "{{ phoneNumber 8005551212 }} -> (800) 555-1212", "description": "

Convert a string or number to a formatted phone number.

\n", "requiresBlock": false }, "toAbbr": { - "args": ["number", "precision"], + "args": [ + "number", + "precision" + ], "numArgs": 2, "example": "{{ toAbbr 10123 2 }} -> 10.12k", "description": "

Abbreviate numbers to the given number of precision. This for general numbers, not size in bytes.

\n", "requiresBlock": false }, "toExponential": { - "args": ["number", "fractionDigits"], + "args": [ + "number", + "fractionDigits" + ], "numArgs": 2, "example": "{{ toExponential 10123 2 }} -> 1.01e+4", "description": "

Returns a string representing the given number in exponential notation.

\n", "requiresBlock": false }, "toFixed": { - "args": ["number", "digits"], + "args": [ + "number", + "digits" + ], "numArgs": 2, "example": "{{ toFixed 1.1234 2 }} -> 1.12", "description": "

Formats the given number using fixed-point notation.

\n", "requiresBlock": false }, "toFloat": { - "args": ["number"], + "args": [ + "number" + ], "numArgs": 1, "description": "

Convert input to a float.

\n", "requiresBlock": false }, "toInt": { - "args": ["number"], + "args": [ + "number" + ], "numArgs": 1, "description": "

Convert input to an integer.

\n", "requiresBlock": false }, "toPrecision": { - "args": ["number", "precision"], + "args": [ + "number", + "precision" + ], "numArgs": 2, "example": "{{toPrecision '1.1234' 2}} -> 1.1", "description": "

Returns a string representing the Number object to the specified precision.

\n", @@ -369,49 +519,64 @@ }, "url": { "encodeURI": { - "args": ["str"], + "args": [ + "str" + ], "numArgs": 1, "example": "{{ encodeURI 'https://myurl?Hello There' }} -> https%3A%2F%2Fmyurl%3FHello%20There", "description": "

Encodes a Uniform Resource Identifier (URI) component by replacing each instance of certain characters by one, two, three, or four escape sequences representing the UTF-8 encoding of the character.

\n", "requiresBlock": false }, "escape": { - "args": ["str"], + "args": [ + "str" + ], "numArgs": 1, "example": "{{ escape 'https://myurl?Hello+There' }} -> https%3A%2F%2Fmyurl%3FHello%2BThere", "description": "

Escape the given string by replacing characters with escape sequences. Useful for allowing the string to be used in a URL, etc.

\n", "requiresBlock": false }, "decodeURI": { - "args": ["str"], + "args": [ + "str" + ], "numArgs": 1, "example": "{{ decodeURI 'https://myurl?Hello%20There' }} -> https://myurl?Hello There", "description": "

Decode a Uniform Resource Identifier (URI) component.

\n", "requiresBlock": false }, "urlResolve": { - "args": ["base", "href"], + "args": [ + "base", + "href" + ], "numArgs": 2, "example": "{{ urlResolve 'https://myurl' '/api/test' }} -> https://myurl/api/test", "description": "

Take a base URL, and a href URL, and resolve them as a browser would for an anchor tag.

\n", "requiresBlock": false }, "urlParse": { - "args": ["str"], + "args": [ + "str" + ], "numArgs": 1, "example": "{{ urlParse 'https://myurl/api/test' }}", "description": "

Parses a url string into an object.

\n", "requiresBlock": false }, "stripQuerystring": { - "args": ["url"], + "args": [ + "url" + ], "numArgs": 1, "example": "{{ stripQuerystring 'https://myurl/api/test?foo=bar' }} -> 'https://myurl/api/test'", "description": "

Strip the query string from the given url.

\n", "requiresBlock": false }, "stripProtocol": { - "args": ["str"], + "args": [ + "str" + ], "numArgs": 1, "example": "{{ stripProtocol 'https://myurl/api/test' }} -> '//myurl/api/test'", "description": "

Strip protocol from a url. Useful for displaying media that may have an 'http' protocol on secure connections.

\n", @@ -420,429 +585,604 @@ }, "string": { "append": { - "args": ["str", "suffix"], + "args": [ + "str", + "suffix" + ], "numArgs": 2, "example": "{{append 'index' '.html'}} -> index.html", "description": "

Append the specified suffix to the given string.

\n", "requiresBlock": false }, "camelcase": { - "args": ["string"], + "args": [ + "string" + ], "numArgs": 1, "example": "{{camelcase 'foo bar baz'}} -> fooBarBaz", "description": "

camelCase the characters in the given string.

\n", "requiresBlock": false }, "capitalize": { - "args": ["str"], + "args": [ + "str" + ], "numArgs": 1, "example": "{{capitalize 'foo bar baz'}} -> Foo bar baz", "description": "

Capitalize the first word in a sentence.

\n", "requiresBlock": false }, "capitalizeAll": { - "args": ["str"], + "args": [ + "str" + ], "numArgs": 1, "example": "{{ capitalizeAll 'foo bar baz'}} -> Foo Bar Baz", "description": "

Capitalize all words in a string.

\n", "requiresBlock": false }, "center": { - "args": ["str", "spaces"], + "args": [ + "str", + "spaces" + ], "numArgs": 2, "example": "{{ center 'test' 1}} -> ' test '", "description": "

Center a string using non-breaking spaces

\n", "requiresBlock": false }, "chop": { - "args": ["string"], + "args": [ + "string" + ], "numArgs": 1, "example": "{{ chop ' ABC '}} -> ABC", "description": "

Like trim, but removes both extraneous whitespace and non-word characters from the beginning and end of a string.

\n", "requiresBlock": false }, "dashcase": { - "args": ["string"], + "args": [ + "string" + ], "numArgs": 1, "example": "{{dashcase 'a-b-c d_e'}} -> a-b-c-d-e", "description": "

dash-case the characters in string. Replaces non-word characters and periods with hyphens.

\n", "requiresBlock": false }, "dotcase": { - "args": ["string"], + "args": [ + "string" + ], "numArgs": 1, "example": "{{dotcase 'a-b-c d_e'}} -> a.b.c.d.e", "description": "

dot.case the characters in string.

\n", "requiresBlock": false }, "downcase": { - "args": ["string"], + "args": [ + "string" + ], "numArgs": 1, "example": "{{downcase 'aBcDeF'}} -> abcdef", "description": "

Lowercase all of the characters in the given string. Alias for lowercase.

\n", "requiresBlock": false }, "ellipsis": { - "args": ["str", "length"], + "args": [ + "str", + "length" + ], "numArgs": 2, "example": "{{ellipsis 'foo bar baz' 7}} -> foo bar…", "description": "

Truncates a string to the specified length, and appends it with an elipsis, .

\n", "requiresBlock": false }, "hyphenate": { - "args": ["str"], + "args": [ + "str" + ], "numArgs": 1, "example": "{{hyphenate 'foo bar baz qux'}} -> foo-bar-baz-qux", "description": "

Replace spaces in a string with hyphens.

\n", "requiresBlock": false }, "isString": { - "args": ["value"], + "args": [ + "value" + ], "numArgs": 1, "example": "{{isString 'foo'}} -> true", "description": "

Return true if value is a string.

\n", "requiresBlock": false }, "lowercase": { - "args": ["str"], + "args": [ + "str" + ], "numArgs": 1, "example": "{{lowercase 'Foo BAR baZ'}} -> foo bar baz", "description": "

Lowercase all characters in the given string.

\n", "requiresBlock": false }, "occurrences": { - "args": ["str", "substring"], + "args": [ + "str", + "substring" + ], "numArgs": 2, "example": "{{occurrences 'foo bar foo bar baz' 'foo'}} -> 2", "description": "

Return the number of occurrences of substring within the given string.

\n", "requiresBlock": false }, "pascalcase": { - "args": ["string"], + "args": [ + "string" + ], "numArgs": 1, "example": "{{pascalcase 'foo bar baz'}} -> FooBarBaz", "description": "

PascalCase the characters in string.

\n", "requiresBlock": false }, "pathcase": { - "args": ["string"], + "args": [ + "string" + ], "numArgs": 1, "example": "{{pathcase 'a-b-c d_e'}} -> a/b/c/d/e", "description": "

path/case the characters in string.

\n", "requiresBlock": false }, "plusify": { - "args": ["str"], + "args": [ + "str" + ], "numArgs": 1, "example": "{{plusify 'foo bar baz'}} -> foo+bar+baz", "description": "

Replace spaces in the given string with pluses.

\n", "requiresBlock": false }, "prepend": { - "args": ["str", "prefix"], + "args": [ + "str", + "prefix" + ], "numArgs": 2, "example": "{{prepend 'bar' 'foo-'}} -> foo-bar", "description": "

Prepends the given string with the specified prefix.

\n", "requiresBlock": false }, "remove": { - "args": ["str", "substring"], + "args": [ + "str", + "substring" + ], "numArgs": 2, "example": "{{remove 'a b a b a b' 'a '}} -> b b b", "description": "

Remove all occurrences of substring from the given str.

\n", "requiresBlock": false }, "removeFirst": { - "args": ["str", "substring"], + "args": [ + "str", + "substring" + ], "numArgs": 2, "example": "{{removeFirst 'a b a b a b' 'a'}} -> ' b a b a b'", "description": "

Remove the first occurrence of substring from the given str.

\n", "requiresBlock": false }, "replace": { - "args": ["str", "a", "b"], + "args": [ + "str", + "a", + "b" + ], "numArgs": 3, "example": "{{replace 'a b a b a b' 'a' 'z'}} -> z b z b z b", "description": "

Replace all occurrences of substring a with substring b.

\n", "requiresBlock": false }, "replaceFirst": { - "args": ["str", "a", "b"], + "args": [ + "str", + "a", + "b" + ], "numArgs": 3, "example": "{{replaceFirst 'a b a b a b' 'a' 'z'}} -> z b a b a b", "description": "

Replace the first occurrence of substring a with substring b.

\n", "requiresBlock": false }, "sentence": { - "args": ["str"], + "args": [ + "str" + ], "numArgs": 1, "example": "{{sentence 'hello world. goodbye world.'}} -> Hello world. Goodbye world.", "description": "

Sentence case the given string

\n", "requiresBlock": false }, "snakecase": { - "args": ["string"], + "args": [ + "string" + ], "numArgs": 1, "example": "{{snakecase 'a-b-c d_e'}} -> a_b_c_d_e", "description": "

snake_case the characters in the given string.

\n", "requiresBlock": false }, "split": { - "args": ["string"], + "args": [ + "string" + ], "numArgs": 1, "example": "{{split 'a,b,c'}} -> ['a', 'b', 'c']", "description": "

Split string by the given character.

\n", "requiresBlock": false }, "startsWith": { - "args": ["prefix", "testString", "options"], + "args": [ + "prefix", + "testString", + "options" + ], "numArgs": 3, "example": "{{#startsWith 'Goodbye' 'Hello, world!'}}Yep{{else}}Nope{{/startsWith}} -> Nope", "description": "

Tests whether a string begins with the given prefix.

\n", "requiresBlock": true }, "titleize": { - "args": ["str"], + "args": [ + "str" + ], "numArgs": 1, "example": "{{titleize 'this is title case' }} -> This Is Title Case", "description": "

Title case the given string.

\n", "requiresBlock": false }, "trim": { - "args": ["string"], + "args": [ + "string" + ], "numArgs": 1, "example": "{{trim ' ABC ' }} -> ABC", "description": "

Removes extraneous whitespace from the beginning and end of a string.

\n", "requiresBlock": false }, "trimLeft": { - "args": ["string"], + "args": [ + "string" + ], "numArgs": 1, "example": "{{trimLeft ' ABC ' }} -> 'ABC '", "description": "

Removes extraneous whitespace from the beginning of a string.

\n", "requiresBlock": false }, "trimRight": { - "args": ["string"], + "args": [ + "string" + ], "numArgs": 1, "example": "{{trimRight ' ABC ' }} -> ' ABC'", "description": "

Removes extraneous whitespace from the end of a string.

\n", "requiresBlock": false }, "truncate": { - "args": ["str", "limit", "suffix"], + "args": [ + "str", + "limit", + "suffix" + ], "numArgs": 3, "example": "{{truncate 'foo bar baz' 7 }} -> foo bar", "description": "

Truncate a string to the specified length. Also see ellipsis.

\n", "requiresBlock": false }, "truncateWords": { - "args": ["str", "limit", "suffix"], + "args": [ + "str", + "limit", + "suffix" + ], "numArgs": 3, "example": "{{truncateWords 'foo bar baz' 1 }} -> foo…", "description": "

Truncate a string to have the specified number of words. Also see truncate.

\n", "requiresBlock": false }, "upcase": { - "args": ["string"], + "args": [ + "string" + ], "numArgs": 1, "example": "{{upcase 'aBcDef'}} -> ABCDEF", "description": "

Uppercase all of the characters in the given string. Alias for uppercase.

\n", "requiresBlock": false }, "uppercase": { - "args": ["str", "options"], + "args": [ + "str", + "options" + ], "numArgs": 2, "example": "{{uppercase 'aBcDef'}} -> ABCDEF", "description": "

Uppercase all of the characters in the given string. If used as a block helper it will uppercase the entire block. This helper does not support inverse blocks.

\n", "requiresBlock": false }, "lorem": { - "args": ["num"], + "args": [ + "num" + ], "numArgs": 1, "example": "{{lorem 11}} -> Lorem ipsum", "description": "

Takes a number and returns that many charaters of Lorem Ipsum

\n", "requiresBlock": false - }, - "decodeId": { - "args": ["string"], - "numArgs": 1, - "example": "{{ decodeId %5B42%5D -> 42 }}", - "description": "

Takes an encoded ID from SQL and returns the unencoded version

", - "requiresBlock": false } }, "comparison": { "and": { - "args": ["a", "b", "options"], + "args": [ + "a", + "b", + "options" + ], "numArgs": 3, "example": "{{#and great magnificent}}both{{else}}no{{/and}} -> no", "description": "

Helper that renders the block if both of the given values are truthy. If an inverse block is specified it will be rendered when falsy. Works as a block helper, inline helper or subexpression.

\n", "requiresBlock": true }, "compare": { - "args": ["a", "operator", "b", "options"], + "args": [ + "a", + "operator", + "b", + "options" + ], "numArgs": 4, "example": "{{compare 10 '<' 5 }} -> false", "description": "

Render a block when a comparison of the first and third arguments returns true. The second argument is the [arithemetic operator][operators] to use. You may also optionally specify an inverse block to render when falsy.

\n", "requiresBlock": false }, "contains": { - "args": ["collection", "value", "[startIndex=0]", "options"], + "args": [ + "collection", + "value", + "[startIndex=0]", + "options" + ], "numArgs": 4, "example": "{{#contains ['a', 'b', 'c'] 'd'}} This will not be rendered. {{else}} This will be rendered. {{/contains}} -> ' This will be rendered. '", "description": "

Block helper that renders the block if collection has the given value, using strict equality (===) for comparison, otherwise the inverse block is rendered (if specified). If a startIndex is specified and is negative, it is used as the offset from the end of the collection.

\n", "requiresBlock": true }, "default": { - "args": ["value", "defaultValue"], + "args": [ + "value", + "defaultValue" + ], "numArgs": 2, "example": "{{default null null 'default'}} -> default", "description": "

Returns the first value that is not undefined, otherwise the 'default' value is returned.

\n", "requiresBlock": false }, "eq": { - "args": ["a", "b", "options"], + "args": [ + "a", + "b", + "options" + ], "numArgs": 3, "example": "{{#eq 3 3}}equal{{else}}not equal{{/eq}} -> equal", "description": "

Block helper that renders a block if a is equal to b. If an inverse block is specified it will be rendered when falsy. You may optionally use the compare='' hash argument for the second value.

\n", "requiresBlock": true }, "gt": { - "args": ["a", "b", "options"], + "args": [ + "a", + "b", + "options" + ], "numArgs": 3, "example": "{{#gt 4 3}} greater than{{else}} not greater than{{/gt}} -> ' greater than'", "description": "

Block helper that renders a block if a is greater than b. If an inverse block is specified it will be rendered when falsy. You may optionally use the compare='' hash argument for the second value.

\n", "requiresBlock": true }, "gte": { - "args": ["a", "b", "options"], + "args": [ + "a", + "b", + "options" + ], "numArgs": 3, "example": "{{#gte 4 3}} greater than or equal{{else}} not greater than{{/gte}} -> ' greater than or equal'", "description": "

Block helper that renders a block if a is greater than or equal to b. If an inverse block is specified it will be rendered when falsy. You may optionally use the compare='' hash argument for the second value.

\n", "requiresBlock": true }, "has": { - "args": ["val", "pattern", "options"], + "args": [ + "val", + "pattern", + "options" + ], "numArgs": 3, "example": "{{#has 'foobar' 'foo'}}has it{{else}}doesn't{{/has}} -> has it", "description": "

Block helper that renders a block if value has pattern. If an inverse block is specified it will be rendered when falsy.

\n", "requiresBlock": true }, "isFalsey": { - "args": ["val", "options"], + "args": [ + "val", + "options" + ], "numArgs": 2, "example": "{{isFalsey '' }} -> true", "description": "

Returns true if the given value is falsey. Uses the [falsey][] library for comparisons. Please see that library for more information or to report bugs with this helper.

\n", "requiresBlock": false }, "isTruthy": { - "args": ["val", "options"], + "args": [ + "val", + "options" + ], "numArgs": 2, "example": "{{isTruthy '12' }} -> true", "description": "

Returns true if the given value is truthy. Uses the [falsey][] library for comparisons. Please see that library for more information or to report bugs with this helper.

\n", "requiresBlock": false }, "ifEven": { - "args": ["number", "options"], + "args": [ + "number", + "options" + ], "numArgs": 2, "example": "{{#ifEven 2}} even {{else}} odd {{/ifEven}} -> ' even '", "description": "

Return true if the given value is an even number.

\n", "requiresBlock": true }, "ifNth": { - "args": ["a", "b", "options"], + "args": [ + "a", + "b", + "options" + ], "numArgs": 3, "example": "{{#ifNth 2 10}}remainder{{else}}no remainder{{/ifNth}} -> remainder", "description": "

Conditionally renders a block if the remainder is zero when b operand is divided by a. If an inverse block is specified it will be rendered when the remainder is not zero.

\n", "requiresBlock": true }, "ifOdd": { - "args": ["value", "options"], + "args": [ + "value", + "options" + ], "numArgs": 2, "example": "{{#ifOdd 3}}odd{{else}}even{{/ifOdd}} -> odd", "description": "

Block helper that renders a block if value is an odd number. If an inverse block is specified it will be rendered when falsy.

\n", "requiresBlock": true }, "is": { - "args": ["a", "b", "options"], + "args": [ + "a", + "b", + "options" + ], "numArgs": 3, "example": "{{#is 3 3}} is {{else}} is not {{/is}} -> ' is '", "description": "

Block helper that renders a block if a is equal to b. If an inverse block is specified it will be rendered when falsy. Similar to eq but does not do strict equality.

\n", "requiresBlock": true }, "isnt": { - "args": ["a", "b", "options"], + "args": [ + "a", + "b", + "options" + ], "numArgs": 3, "example": "{{#isnt 3 3}} isnt {{else}} is {{/isnt}} -> ' is '", "description": "

Block helper that renders a block if a is not equal to b. If an inverse block is specified it will be rendered when falsy. Similar to unlessEq but does not use strict equality for comparisons.

\n", "requiresBlock": true }, "lt": { - "args": ["context", "options"], + "args": [ + "context", + "options" + ], "numArgs": 2, "example": "{{#lt 2 3}} less than {{else}} more than or equal {{/lt}} -> ' less than '", "description": "

Block helper that renders a block if a is less than b. If an inverse block is specified it will be rendered when falsy. You may optionally use the compare='' hash argument for the second value.

\n", "requiresBlock": true }, "lte": { - "args": ["a", "b", "options"], + "args": [ + "a", + "b", + "options" + ], "numArgs": 3, "example": "{{#lte 2 3}} less than or equal {{else}} more than {{/lte}} -> ' less than or equal '", "description": "

Block helper that renders a block if a is less than or equal to b. If an inverse block is specified it will be rendered when falsy. You may optionally use the compare='' hash argument for the second value.

\n", "requiresBlock": true }, "neither": { - "args": ["a", "b", "options"], + "args": [ + "a", + "b", + "options" + ], "numArgs": 3, "example": "{{#neither null null}}both falsey{{else}}both not falsey{{/neither}} -> both falsey", "description": "

Block helper that renders a block if neither of the given values are truthy. If an inverse block is specified it will be rendered when falsy.

\n", "requiresBlock": true }, "not": { - "args": ["val", "options"], + "args": [ + "val", + "options" + ], "numArgs": 2, "example": "{{#not undefined }}falsey{{else}}not falsey{{/not}} -> falsey", "description": "

Returns true if val is falsey. Works as a block or inline helper.

\n", "requiresBlock": true }, "or": { - "args": ["arguments", "options"], + "args": [ + "arguments", + "options" + ], "numArgs": 2, "example": "{{#or 1 2 undefined }} at least one truthy {{else}} all falsey {{/or}} -> ' at least one truthy '", "description": "

Block helper that renders a block if any of the given values is truthy. If an inverse block is specified it will be rendered when falsy.

\n", "requiresBlock": true }, "unlessEq": { - "args": ["a", "b", "options"], + "args": [ + "a", + "b", + "options" + ], "numArgs": 3, "example": "{{#unlessEq 2 1 }} not equal {{else}} equal {{/unlessEq}} -> ' not equal '", "description": "

Block helper that always renders the inverse block unless a is equal to b.

\n", "requiresBlock": true }, "unlessGt": { - "args": ["a", "b", "options"], + "args": [ + "a", + "b", + "options" + ], "numArgs": 3, "example": "{{#unlessGt 20 1 }} not greater than {{else}} greater than {{/unlessGt}} -> ' greater than '", "description": "

Block helper that always renders the inverse block unless a is greater than b.

\n", "requiresBlock": true }, "unlessLt": { - "args": ["a", "b", "options"], + "args": [ + "a", + "b", + "options" + ], "numArgs": 3, "example": "{{#unlessLt 20 1 }}greater than or equal{{else}}less than{{/unlessLt}} -> greater than or equal", "description": "

Block helper that always renders the inverse block unless a is less than b.

\n", "requiresBlock": true }, "unlessGteq": { - "args": ["a", "b", "options"], + "args": [ + "a", + "b", + "options" + ], "numArgs": 3, "example": "{{#unlessGteq 20 1 }} less than {{else}}greater than or equal to{{/unlessGteq}} -> greater than or equal to", "description": "

Block helper that always renders the inverse block unless a is greater than or equal to b.

\n", "requiresBlock": true }, "unlessLteq": { - "args": ["a", "b", "options"], + "args": [ + "a", + "b", + "options" + ], "numArgs": 3, "example": "{{#unlessLteq 20 1 }} greater than {{else}} less than or equal to {{/unlessLteq}} -> ' greater than '", "description": "

Block helper that always renders the inverse block unless a is less than or equal to b.

\n", @@ -851,85 +1191,122 @@ }, "object": { "extend": { - "args": ["objects"], + "args": [ + "objects" + ], "numArgs": 1, "description": "

Extend the context with the properties of other objects. A shallow merge is performed to avoid mutating the context.

\n", "requiresBlock": false }, "forIn": { - "args": ["context", "options"], + "args": [ + "context", + "options" + ], "numArgs": 2, "description": "

Block helper that iterates over the properties of an object, exposing each key and value on the context.

\n", "requiresBlock": true }, "forOwn": { - "args": ["obj", "options"], + "args": [ + "obj", + "options" + ], "numArgs": 2, "description": "

Block helper that iterates over the own properties of an object, exposing each key and value on the context.

\n", "requiresBlock": true }, "toPath": { - "args": ["prop"], + "args": [ + "prop" + ], "numArgs": 1, "description": "

Take arguments and, if they are string or number, convert them to a dot-delineated object property path.

\n", "requiresBlock": false }, "get": { - "args": ["prop", "context", "options"], + "args": [ + "prop", + "context", + "options" + ], "numArgs": 3, "description": "

Use property paths (a.b.c) to get a value or nested value from the context. Works as a regular helper or block helper.

\n", "requiresBlock": true }, "getObject": { - "args": ["prop", "context"], + "args": [ + "prop", + "context" + ], "numArgs": 2, "description": "

Use property paths (a.b.c) to get an object from the context. Differs from the get helper in that this helper will return the actual object, including the given property key. Also, this helper does not work as a block helper.

\n", "requiresBlock": false }, "hasOwn": { - "args": ["key", "context"], + "args": [ + "key", + "context" + ], "numArgs": 2, "description": "

Return true if key is an own, enumerable property of the given context object.

\n", "requiresBlock": false }, "isObject": { - "args": ["value"], + "args": [ + "value" + ], "numArgs": 1, "description": "

Return true if value is an object.

\n", "requiresBlock": false }, "JSONparse": { - "args": ["string"], + "args": [ + "string" + ], "numArgs": 1, "description": "

Parses the given string using JSON.parse.

\n", "requiresBlock": true }, "JSONstringify": { - "args": ["obj"], + "args": [ + "obj" + ], "numArgs": 1, "description": "

Stringify an object using JSON.stringify.

\n", "requiresBlock": false }, "merge": { - "args": ["object", "objects"], + "args": [ + "object", + "objects" + ], "numArgs": 2, "description": "

Deeply merge the properties of the given objects with the context object.

\n", "requiresBlock": false }, "parseJSON": { - "args": ["string"], + "args": [ + "string" + ], "numArgs": 1, "description": "

Parses the given string using JSON.parse.

\n", "requiresBlock": true }, "pick": { - "args": ["properties", "context", "options"], + "args": [ + "properties", + "context", + "options" + ], "numArgs": 3, "description": "

Pick properties from the context object.

\n", "requiresBlock": true }, "stringify": { - "args": ["obj"], + "args": [ + "obj" + ], "numArgs": 1, "description": "

Stringify an object using JSON.stringify.

\n", "requiresBlock": false @@ -937,14 +1314,18 @@ }, "regex": { "toRegex": { - "args": ["str"], + "args": [ + "str" + ], "numArgs": 1, "example": "{{toRegex 'foo'}} -> /foo/", "description": "

Convert the given string to a regular expression.

\n", "requiresBlock": false }, "test": { - "args": ["str"], + "args": [ + "str" + ], "numArgs": 1, "example": "{{test 'foobar' (toRegex 'foo')}} -> true", "description": "

Returns true if the given str matches the given regex. A regex can be passed on the context, or using the toRegex helper as a subexpression.

\n", @@ -962,16 +1343,22 @@ }, "date": { "date": { - "args": ["datetime", "format"], + "args": [ + "datetime", + "format" + ], "numArgs": 2, "example": "{{date now \"DD-MM-YYYY\" \"America/New_York\" }} -> 21-01-2021", "description": "

Format a date using moment.js date formatting - the timezone is optional and uses the tz database.

\n" }, "duration": { - "args": ["time", "durationType"], + "args": [ + "time", + "durationType" + ], "numArgs": 2, "example": "{{duration 8 \"seconds\"}} -> a few seconds", "description": "

Produce a humanized duration left/until given an amount of time and the type of time measurement.

\n" } } -} +} \ No newline at end of file From 25489c25d4f0ae394bda98dd16825440bcacc195 Mon Sep 17 00:00:00 2001 From: mikesealey Date: Thu, 13 Feb 2025 14:31:39 +0000 Subject: [PATCH 03/29] handles encoded array of 1 or many IDs --- packages/string-templates/src/helpers/index.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/string-templates/src/helpers/index.ts b/packages/string-templates/src/helpers/index.ts index 8cd2de429f..d5627b5ce1 100644 --- a/packages/string-templates/src/helpers/index.ts +++ b/packages/string-templates/src/helpers/index.ts @@ -32,8 +32,21 @@ const HELPERS = [ }), // javascript helper new Helper(HelperFunctionNames.JS, processJS, false), - new Helper(HelperFunctionNames.DECODE_ID, value => { - return value + new Helper(HelperFunctionNames.DECODE_ID, (_id: string | { _id: string }) => { + if (!_id) { + return [] + } + // have to replace on the way back as we swapped out the double quotes + // when encoding, but JSON can't handle the single quotes + const id = typeof _id === "string" ? _id : _id._id + const decoded: string = decodeURIComponent(id).replace(/'/g, '"') + try { + const parsed = JSON.parse(decoded) + return Array.isArray(parsed) ? parsed : [parsed] + } catch (err) { + // wasn't json - likely was handlebars for a many to many + return [_id] + } }), // this help is applied to all statements new Helper( From 5c4874d52682e074a8a1b2afe99a05bc487e3304 Mon Sep 17 00:00:00 2001 From: mikesealey Date: Mon, 24 Feb 2025 13:12:25 +0000 Subject: [PATCH 04/29] wip --- packages/string-templates/src/helpers/index.ts | 2 +- packages/string-templates/test/basic.spec.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/string-templates/src/helpers/index.ts b/packages/string-templates/src/helpers/index.ts index d5627b5ce1..ea09c2c545 100644 --- a/packages/string-templates/src/helpers/index.ts +++ b/packages/string-templates/src/helpers/index.ts @@ -25,7 +25,7 @@ function isObject(value: string | any[]) { ) } -const HELPERS = [ +export const HELPERS = [ // external helpers new Helper(HelperFunctionNames.OBJECT, (value: any) => { return new Handlebars.SafeString(JSON.stringify(value)) diff --git a/packages/string-templates/test/basic.spec.ts b/packages/string-templates/test/basic.spec.ts index 24a19131f4..57e2402eeb 100644 --- a/packages/string-templates/test/basic.spec.ts +++ b/packages/string-templates/test/basic.spec.ts @@ -10,6 +10,8 @@ import { findHBSBlocks, } from "../src/index" +import { HELPERS } from "../src/helpers/index" + describe("Test that the string processing works correctly", () => { it("should process a basic template string", async () => { const output = await processString("templating is {{ adjective }}", { @@ -338,3 +340,10 @@ describe("check multiple space behaviour", () => { expect(output).toEqual("test string") }) }) + +describe.only("MikeTest1", () => { + it("miktest111", async () => { + const output = "%5B41%5D" + expect(output).toEqual(HELPERS) + }) +}) From b0c1d7ee6dcf693dc0925995cf735c3ae1baf749 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 3 Mar 2025 17:10:20 +0000 Subject: [PATCH 05/29] wip --- packages/server/datasource-sha.env | 3 +- packages/server/src/integrations/dynamodb.ts | 6 +- .../src/integrations/tests/dynamodb.spec.ts | 276 ++++++++---------- .../src/integrations/tests/utils/dynamodb.ts | 41 +++ .../src/integrations/tests/utils/images.ts | 1 + .../src/integrations/tests/utils/index.ts | 3 + 6 files changed, 179 insertions(+), 151 deletions(-) create mode 100644 packages/server/src/integrations/tests/utils/dynamodb.ts diff --git a/packages/server/datasource-sha.env b/packages/server/datasource-sha.env index 69750793ce..13413dcb59 100644 --- a/packages/server/datasource-sha.env +++ b/packages/server/datasource-sha.env @@ -3,4 +3,5 @@ MYSQL_SHA=sha256:9de9d54fecee6253130e65154b930978b1fcc336bcc86dfd06e89b72a2588eb POSTGRES_SHA=sha256:bd0d8e485d1aca439d39e5ea99b931160bd28d862e74c786f7508e9d0053090e MONGODB_SHA=sha256:afa36bca12295b5f9dae68a493c706113922bdab520e901bd5d6c9d7247a1d8d MARIADB_SHA=sha256:e59ba8783bf7bc02a4779f103bb0d8751ac0e10f9471089709608377eded7aa8 -ELASTICSEARCH_SHA=sha256:9a6443f55243f6acbfeb4a112d15eb3b9aac74bf25e0e39fa19b3ddd3a6879d0 \ No newline at end of file +ELASTICSEARCH_SHA=sha256:9a6443f55243f6acbfeb4a112d15eb3b9aac74bf25e0e39fa19b3ddd3a6879d0 +DYNAMODB_SHA=sha256:cf8cebd061f988628c02daff10fdb950a54478feff9c52f6ddf84710fe3c3906 \ No newline at end of file diff --git a/packages/server/src/integrations/dynamodb.ts b/packages/server/src/integrations/dynamodb.ts index 96941ebb0e..6d8ae1c1c2 100644 --- a/packages/server/src/integrations/dynamodb.ts +++ b/packages/server/src/integrations/dynamodb.ts @@ -17,7 +17,7 @@ import { import { DynamoDB } from "@aws-sdk/client-dynamodb" import { AWS_REGION } from "../constants" -interface DynamoDBConfig { +export interface DynamoDBConfig { region: string accessKeyId: string secretAccessKey: string @@ -138,9 +138,9 @@ const SCHEMA: Integration = { }, } -class DynamoDBIntegration implements IntegrationBase { +export class DynamoDBIntegration implements IntegrationBase { private config: DynamoDBConfig - private client + private client: DynamoDBDocument constructor(config: DynamoDBConfig) { this.config = config diff --git a/packages/server/src/integrations/tests/dynamodb.spec.ts b/packages/server/src/integrations/tests/dynamodb.spec.ts index 75fb84ae60..49863c9912 100644 --- a/packages/server/src/integrations/tests/dynamodb.spec.ts +++ b/packages/server/src/integrations/tests/dynamodb.spec.ts @@ -1,167 +1,149 @@ -jest.mock("@aws-sdk/lib-dynamodb", () => ({ - DynamoDBDocument: { - from: jest.fn(() => ({ - update: jest.fn(), - put: jest.fn(), - query: jest.fn(() => ({ - Items: [], - })), - scan: jest.fn(() => ({ - Items: [], - })), - delete: jest.fn(), - get: jest.fn(), - })), - }, -})) -jest.mock("@aws-sdk/client-dynamodb") -import { default as DynamoDBIntegration } from "../dynamodb" +import { Datasource } from "@budibase/types" +import { DynamoDBConfig, DynamoDBIntegration } from "../dynamodb" +import { DatabaseName, datasourceDescribe } from "./utils" -class TestConfiguration { - integration: any +const describes = datasourceDescribe({ only: [DatabaseName.DYNAMODB] }) - constructor(config: any = {}) { - this.integration = new DynamoDBIntegration.integration(config) - } -} +if (describes.length > 0) { + describe.each(describes)("DynamoDB Integration", ({ dsProvider }) => { + let tableName = "Users" + let rawDatasource: Datasource + let dynamodb: DynamoDBIntegration -describe("DynamoDB Integration", () => { - let config: any - let tableName = "Users" - - beforeEach(() => { - config = new TestConfiguration() - }) - - it("calls the create method with the correct params", async () => { - await config.integration.create({ - table: tableName, - json: { - Name: "John", - }, + beforeEach(async () => { + const ds = await dsProvider() + rawDatasource = ds.rawDatasource! + dynamodb = new DynamoDBIntegration( + rawDatasource.config! as DynamoDBConfig + ) }) - expect(config.integration.client.put).toHaveBeenCalledWith({ - TableName: tableName, - Name: "John", - }) - }) - it("calls the read method with the correct params", async () => { - const indexName = "Test" - - const response = await config.integration.read({ - table: tableName, - index: indexName, - json: {}, + it.only("calls the create method with the correct params", async () => { + await dynamodb.create({ + table: tableName, + json: { + Name: "John", + }, + }) }) - expect(config.integration.client.query).toHaveBeenCalledWith({ - TableName: tableName, - IndexName: indexName, - }) - expect(response).toEqual([]) - }) - it("calls the scan method with the correct params", async () => { - const indexName = "Test" + it("calls the read method with the correct params", async () => { + const indexName = "Test" - const response = await config.integration.scan({ - table: tableName, - index: indexName, - json: {}, + const response = await dynamodb.read({ + table: tableName, + index: indexName, + json: {}, + }) + expect(config.integration.client.query).toHaveBeenCalledWith({ + TableName: tableName, + IndexName: indexName, + }) + expect(response).toEqual([]) }) - expect(config.integration.client.scan).toHaveBeenCalledWith({ - TableName: tableName, - IndexName: indexName, - }) - expect(response).toEqual([]) - }) - it("calls the get method with the correct params", async () => { - await config.integration.get({ - table: tableName, - json: { + it("calls the scan method with the correct params", async () => { + const indexName = "Test" + + const response = await dynamodb.scan({ + table: tableName, + index: indexName, + json: {}, + }) + expect(config.integration.client.scan).toHaveBeenCalledWith({ + TableName: tableName, + IndexName: indexName, + }) + expect(response).toEqual([]) + }) + + it("calls the get method with the correct params", async () => { + await dynamodb.get({ + table: tableName, + json: { + Id: 123, + }, + }) + + expect(config.integration.client.get).toHaveBeenCalledWith({ + TableName: tableName, Id: 123, - }, + }) }) - expect(config.integration.client.get).toHaveBeenCalledWith({ - TableName: tableName, - Id: 123, - }) - }) - - it("calls the update method with the correct params", async () => { - await config.integration.update({ - table: tableName, - json: { + it("calls the update method with the correct params", async () => { + await dynamodb.update({ + table: tableName, + json: { + Name: "John", + }, + }) + expect(config.integration.client.update).toHaveBeenCalledWith({ + TableName: tableName, Name: "John", - }, + }) }) - expect(config.integration.client.update).toHaveBeenCalledWith({ - TableName: tableName, - Name: "John", - }) - }) - it("calls the delete method with the correct params", async () => { - await config.integration.delete({ - table: tableName, - json: { + it("calls the delete method with the correct params", async () => { + await dynamodb.delete({ + table: tableName, + json: { + Name: "John", + }, + }) + expect(config.integration.client.delete).toHaveBeenCalledWith({ + TableName: tableName, Name: "John", - }, + }) }) - expect(config.integration.client.delete).toHaveBeenCalledWith({ - TableName: tableName, - Name: "John", + + it("configures the dynamoDB constructor based on an empty endpoint parameter", async () => { + const config = { + region: "us-east-1", + accessKeyId: "test", + secretAccessKey: "test", + } + + const integration: any = new DynamoDBIntegration.integration(config) + + expect(integration.config).toEqual({ + currentClockSkew: true, + ...config, + }) + }) + + it("configures the dynamoDB constructor based on a localhost endpoint parameter", async () => { + const config = { + region: "us-east-1", + accessKeyId: "test", + secretAccessKey: "test", + endpoint: "localhost:8080", + } + + const integration: any = new DynamoDBIntegration.integration(config) + + expect(integration.config).toEqual({ + region: "us-east-1", + currentClockSkew: true, + endpoint: "localhost:8080", + }) + }) + + it("configures the dynamoDB constructor based on a remote endpoint parameter", async () => { + const config = { + region: "us-east-1", + accessKeyId: "test", + secretAccessKey: "test", + endpoint: "dynamodb.aws.foo.net", + } + + const integration = new DynamoDBIntegration.integration(config) + + // @ts-ignore + expect(integration.config).toEqual({ + currentClockSkew: true, + ...config, + }) }) }) - - it("configures the dynamoDB constructor based on an empty endpoint parameter", async () => { - const config = { - region: "us-east-1", - accessKeyId: "test", - secretAccessKey: "test", - } - - const integration: any = new DynamoDBIntegration.integration(config) - - expect(integration.config).toEqual({ - currentClockSkew: true, - ...config, - }) - }) - - it("configures the dynamoDB constructor based on a localhost endpoint parameter", async () => { - const config = { - region: "us-east-1", - accessKeyId: "test", - secretAccessKey: "test", - endpoint: "localhost:8080", - } - - const integration: any = new DynamoDBIntegration.integration(config) - - expect(integration.config).toEqual({ - region: "us-east-1", - currentClockSkew: true, - endpoint: "localhost:8080", - }) - }) - - it("configures the dynamoDB constructor based on a remote endpoint parameter", async () => { - const config = { - region: "us-east-1", - accessKeyId: "test", - secretAccessKey: "test", - endpoint: "dynamodb.aws.foo.net", - } - - const integration = new DynamoDBIntegration.integration(config) - - // @ts-ignore - expect(integration.config).toEqual({ - currentClockSkew: true, - ...config, - }) - }) -}) +} diff --git a/packages/server/src/integrations/tests/utils/dynamodb.ts b/packages/server/src/integrations/tests/utils/dynamodb.ts new file mode 100644 index 0000000000..aae6358ece --- /dev/null +++ b/packages/server/src/integrations/tests/utils/dynamodb.ts @@ -0,0 +1,41 @@ +import { Datasource, SourceName } from "@budibase/types" +import { GenericContainer, Wait } from "testcontainers" +import { testContainerUtils } from "@budibase/backend-core/tests" +import { startContainer } from "." +import { DYNAMODB_IMAGE } from "./images" +import { DynamoDBConfig } from "../../dynamodb" + +let ports: Promise + +export async function getDatasource(): Promise { + if (!ports) { + ports = startContainer( + new GenericContainer(DYNAMODB_IMAGE) + .withExposedPorts(8000) + .withWaitStrategy( + Wait.forSuccessfulCommand( + // https://stackoverflow.com/a/77373799 + `if [ "$(curl -s -o /dev/null -I -w ''%{http_code}'' http://localhost:8000)" == "400" ]; then exit 0; else exit 1; fi` + ).withStartupTimeout(60000) + ) + ) + } + + const port = (await ports).find(x => x.container === 8000)?.host + if (!port) { + throw new Error("DynamoDB port not found") + } + + const config: DynamoDBConfig = { + accessKeyId: "test", + secretAccessKey: "test", + region: "us-east-1", + endpoint: `http://127.0.0.1:${port}`, + } + + return { + type: "datasource", + source: SourceName.DYNAMODB, + config, + } +} diff --git a/packages/server/src/integrations/tests/utils/images.ts b/packages/server/src/integrations/tests/utils/images.ts index c09b130ea5..68d219ac7d 100644 --- a/packages/server/src/integrations/tests/utils/images.ts +++ b/packages/server/src/integrations/tests/utils/images.ts @@ -13,3 +13,4 @@ export const POSTGRES_LEGACY_IMAGE = `postgres:9.5.25` export const MONGODB_IMAGE = `mongo@${process.env.MONGODB_SHA}` export const MARIADB_IMAGE = `mariadb@${process.env.MARIADB_SHA}` export const ELASTICSEARCH_IMAGE = `elasticsearch@${process.env.ELASTICSEARCH_SHA}` +export const DYNAMODB_IMAGE = `amazon/dynamodb-local@${process.env.DYNAMODB_SHA}` diff --git a/packages/server/src/integrations/tests/utils/index.ts b/packages/server/src/integrations/tests/utils/index.ts index 08777cab89..138861a9e6 100644 --- a/packages/server/src/integrations/tests/utils/index.ts +++ b/packages/server/src/integrations/tests/utils/index.ts @@ -7,6 +7,7 @@ import * as mssql from "./mssql" import * as mariadb from "./mariadb" import * as oracle from "./oracle" import * as elasticsearch from "./elasticsearch" +import * as dynamodb from "./dynamodb" import { testContainerUtils } from "@budibase/backend-core/tests" import { Knex } from "knex" import TestConfiguration from "../../../tests/utilities/TestConfiguration" @@ -25,6 +26,7 @@ export enum DatabaseName { ORACLE = "oracle", SQS = "sqs", ELASTICSEARCH = "elasticsearch", + DYNAMODB = "dynamodb", } const DATASOURCE_PLUS = [ @@ -50,6 +52,7 @@ const providers: Record = { // rest [DatabaseName.ELASTICSEARCH]: elasticsearch.getDatasource, [DatabaseName.MONGODB]: mongodb.getDatasource, + [DatabaseName.DYNAMODB]: dynamodb.getDatasource, } export interface DatasourceDescribeReturnPromise { From 1040830a644d9789efc247232b2fb86bf7934f7b Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 3 Mar 2025 17:43:09 +0000 Subject: [PATCH 06/29] De-mock DynamoDB tests. --- .github/workflows/budibase_ci.yml | 3 + .../src/integrations/tests/dynamodb.spec.ts | 170 ++++++------------ 2 files changed, 61 insertions(+), 112 deletions(-) diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index 2e7851b338..c8bdfe9655 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -165,6 +165,7 @@ jobs: oracle, sqs, elasticsearch, + dynamodb, none, ] steps: @@ -205,6 +206,8 @@ jobs: docker pull postgres:9.5.25 elif [ "${{ matrix.datasource }}" == "elasticsearch" ]; then docker pull elasticsearch@${{ steps.dotenv.outputs.ELASTICSEARCH_SHA }} + elif [ "${{ matrix.datasource }}" == "dynamodb" ]; then + docker pull amazon/dynamodb-local@${{ steps.dotenv.outputs.DYNAMODB_SHA }} fi docker pull minio/minio & docker pull redis & diff --git a/packages/server/src/integrations/tests/dynamodb.spec.ts b/packages/server/src/integrations/tests/dynamodb.spec.ts index 49863c9912..f7aafc6932 100644 --- a/packages/server/src/integrations/tests/dynamodb.spec.ts +++ b/packages/server/src/integrations/tests/dynamodb.spec.ts @@ -1,149 +1,95 @@ import { Datasource } from "@budibase/types" import { DynamoDBConfig, DynamoDBIntegration } from "../dynamodb" import { DatabaseName, datasourceDescribe } from "./utils" +import { CreateTableCommandInput, DynamoDB } from "@aws-sdk/client-dynamodb" const describes = datasourceDescribe({ only: [DatabaseName.DYNAMODB] }) +async function createTable(client: DynamoDB, req: CreateTableCommandInput) { + try { + await client.deleteTable({ TableName: req.TableName }) + } catch (e: any) { + if (e.name !== "ResourceNotFoundException") { + throw e + } + } + + return await client.createTable(req) +} + if (describes.length > 0) { describe.each(describes)("DynamoDB Integration", ({ dsProvider }) => { - let tableName = "Users" + let table = "Users" let rawDatasource: Datasource let dynamodb: DynamoDBIntegration + function item(json: Record) { + return { table, json: { Item: json } } + } + + function key(json: Record) { + return { table, json: { Key: json } } + } + beforeEach(async () => { const ds = await dsProvider() rawDatasource = ds.rawDatasource! dynamodb = new DynamoDBIntegration( rawDatasource.config! as DynamoDBConfig ) - }) - it.only("calls the create method with the correct params", async () => { - await dynamodb.create({ - table: tableName, - json: { - Name: "John", - }, + const client = new DynamoDB(rawDatasource.config as DynamoDBConfig) + await createTable(client, { + TableName: table, + KeySchema: [{ AttributeName: "Id", KeyType: "HASH" }], + AttributeDefinitions: [{ AttributeName: "Id", AttributeType: "N" }], + ProvisionedThroughput: { ReadCapacityUnits: 1, WriteCapacityUnits: 1 }, }) }) - it("calls the read method with the correct params", async () => { - const indexName = "Test" + it("can create and read a record", async () => { + await dynamodb.create(item({ Id: 1, Name: "John" })) - const response = await dynamodb.read({ - table: tableName, - index: indexName, - json: {}, - }) - expect(config.integration.client.query).toHaveBeenCalledWith({ - TableName: tableName, - IndexName: indexName, - }) - expect(response).toEqual([]) + const resp = await dynamodb.get(key({ Id: 1 })) + expect(resp.Item).toEqual({ Id: 1, Name: "John" }) }) - it("calls the scan method with the correct params", async () => { - const indexName = "Test" + it("can scan", async () => { + await dynamodb.create(item({ Id: 1, Name: "John" })) + await dynamodb.create(item({ Id: 2, Name: "Jane" })) + await dynamodb.create(item({ Id: 3, Name: "Jack" })) - const response = await dynamodb.scan({ - table: tableName, - index: indexName, - json: {}, - }) - expect(config.integration.client.scan).toHaveBeenCalledWith({ - TableName: tableName, - IndexName: indexName, - }) - expect(response).toEqual([]) + const resp = await dynamodb.scan({ table, json: {}, index: null }) + expect(resp).toEqual( + expect.arrayContaining([ + { Id: 1, Name: "John" }, + { Id: 2, Name: "Jane" }, + { Id: 3, Name: "Jack" }, + ]) + ) }) - it("calls the get method with the correct params", async () => { - await dynamodb.get({ - table: tableName, - json: { - Id: 123, - }, - }) - - expect(config.integration.client.get).toHaveBeenCalledWith({ - TableName: tableName, - Id: 123, - }) - }) - - it("calls the update method with the correct params", async () => { + it("can update", async () => { + await dynamodb.create(item({ Id: 1, Foo: "John" })) await dynamodb.update({ - table: tableName, + table, json: { - Name: "John", + Key: { Id: 1 }, + UpdateExpression: "SET Foo = :foo", + ExpressionAttributeValues: { ":foo": "Jane" }, }, }) - expect(config.integration.client.update).toHaveBeenCalledWith({ - TableName: tableName, - Name: "John", - }) + + const updatedRecord = await dynamodb.get(key({ Id: 1 })) + expect(updatedRecord.Item).toEqual({ Id: 1, Foo: "Jane" }) }) - it("calls the delete method with the correct params", async () => { - await dynamodb.delete({ - table: tableName, - json: { - Name: "John", - }, - }) - expect(config.integration.client.delete).toHaveBeenCalledWith({ - TableName: tableName, - Name: "John", - }) - }) + it("can delete", async () => { + await dynamodb.create(item({ Id: 1, Name: "John" })) + await dynamodb.delete(key({ Id: 1 })) - it("configures the dynamoDB constructor based on an empty endpoint parameter", async () => { - const config = { - region: "us-east-1", - accessKeyId: "test", - secretAccessKey: "test", - } - - const integration: any = new DynamoDBIntegration.integration(config) - - expect(integration.config).toEqual({ - currentClockSkew: true, - ...config, - }) - }) - - it("configures the dynamoDB constructor based on a localhost endpoint parameter", async () => { - const config = { - region: "us-east-1", - accessKeyId: "test", - secretAccessKey: "test", - endpoint: "localhost:8080", - } - - const integration: any = new DynamoDBIntegration.integration(config) - - expect(integration.config).toEqual({ - region: "us-east-1", - currentClockSkew: true, - endpoint: "localhost:8080", - }) - }) - - it("configures the dynamoDB constructor based on a remote endpoint parameter", async () => { - const config = { - region: "us-east-1", - accessKeyId: "test", - secretAccessKey: "test", - endpoint: "dynamodb.aws.foo.net", - } - - const integration = new DynamoDBIntegration.integration(config) - - // @ts-ignore - expect(integration.config).toEqual({ - currentClockSkew: true, - ...config, - }) + const deletedRecord = await dynamodb.get(key({ Id: 1 })) + expect(deletedRecord.Item).toBeUndefined() }) }) } From 39a7234d2e3c1714d65f310c4290ce401bb85bfe Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 3 Mar 2025 18:02:30 +0000 Subject: [PATCH 07/29] Prevent accidental credential misuse during tests. --- packages/server/src/integrations/dynamodb.ts | 19 ++++++------------- .../src/integrations/tests/dynamodb.spec.ts | 17 +++++++++++++++-- packages/server/src/tests/jestSetup.ts | 6 ++++++ packages/worker/src/tests/jestSetup.ts | 9 +++++++-- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/server/src/integrations/dynamodb.ts b/packages/server/src/integrations/dynamodb.ts index 6d8ae1c1c2..cfaa851cea 100644 --- a/packages/server/src/integrations/dynamodb.ts +++ b/packages/server/src/integrations/dynamodb.ts @@ -14,7 +14,7 @@ import { UpdateCommandInput, DeleteCommandInput, } from "@aws-sdk/lib-dynamodb" -import { DynamoDB } from "@aws-sdk/client-dynamodb" +import { DynamoDB, DynamoDBClientConfig } from "@aws-sdk/client-dynamodb" import { AWS_REGION } from "../constants" export interface DynamoDBConfig { @@ -22,7 +22,6 @@ export interface DynamoDBConfig { accessKeyId: string secretAccessKey: string endpoint?: string - currentClockSkew?: boolean } const SCHEMA: Integration = { @@ -139,21 +138,15 @@ const SCHEMA: Integration = { } export class DynamoDBIntegration implements IntegrationBase { - private config: DynamoDBConfig + private config: DynamoDBClientConfig private client: DynamoDBDocument constructor(config: DynamoDBConfig) { - this.config = config - - // User is using a local dynamoDB endpoint, don't auth with remote - if (this.config?.endpoint?.includes("localhost")) { - // @ts-ignore - this.config = {} - } - this.config = { - ...this.config, - currentClockSkew: true, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, region: config.region || AWS_REGION, endpoint: config.endpoint || undefined, } diff --git a/packages/server/src/integrations/tests/dynamodb.spec.ts b/packages/server/src/integrations/tests/dynamodb.spec.ts index f7aafc6932..e6b1ed405c 100644 --- a/packages/server/src/integrations/tests/dynamodb.spec.ts +++ b/packages/server/src/integrations/tests/dynamodb.spec.ts @@ -1,7 +1,11 @@ import { Datasource } from "@budibase/types" import { DynamoDBConfig, DynamoDBIntegration } from "../dynamodb" import { DatabaseName, datasourceDescribe } from "./utils" -import { CreateTableCommandInput, DynamoDB } from "@aws-sdk/client-dynamodb" +import { + CreateTableCommandInput, + DynamoDB, + DynamoDBClientConfig, +} from "@aws-sdk/client-dynamodb" const describes = datasourceDescribe({ only: [DatabaseName.DYNAMODB] }) @@ -38,7 +42,16 @@ if (describes.length > 0) { rawDatasource.config! as DynamoDBConfig ) - const client = new DynamoDB(rawDatasource.config as DynamoDBConfig) + const config: DynamoDBClientConfig = { + credentials: { + accessKeyId: "test", + secretAccessKey: "test", + }, + region: "us-east-1", + endpoint: rawDatasource.config!.endpoint, + } + + const client = new DynamoDB(config) await createTable(client, { TableName: table, KeySchema: [{ AttributeName: "Id", KeyType: "HASH" }], diff --git a/packages/server/src/tests/jestSetup.ts b/packages/server/src/tests/jestSetup.ts index 60cf96cb51..663f0482ab 100644 --- a/packages/server/src/tests/jestSetup.ts +++ b/packages/server/src/tests/jestSetup.ts @@ -3,6 +3,12 @@ import * as matchers from "jest-extended" import { env as coreEnv, timers } from "@budibase/backend-core" import { testContainerUtils } from "@budibase/backend-core/tests" import nock from "nock" +import AWS from "aws-sdk" + +// Prevent accidental use of real AWS credentials +AWS.config.update({ + credentialProvider: new AWS.CredentialProviderChain([]), +}) expect.extend(matchers) if (!process.env.CI) { diff --git a/packages/worker/src/tests/jestSetup.ts b/packages/worker/src/tests/jestSetup.ts index 6a98031d34..682b92e1c3 100644 --- a/packages/worker/src/tests/jestSetup.ts +++ b/packages/worker/src/tests/jestSetup.ts @@ -2,18 +2,23 @@ import { mocks, testContainerUtils } from "@budibase/backend-core/tests" import env from "../environment" import { env as coreEnv, timers } from "@budibase/backend-core" import nock from "nock" +import AWS from "aws-sdk" // mock all dates to 2020-01-01T00:00:00.000Z // use tk.reset() to use real dates in individual tests import tk from "timekeeper" +// Prevent accidental use of real AWS credentials +AWS.config.update({ + credentialProvider: new AWS.CredentialProviderChain([]), +}) + nock.disableNetConnect() nock.enableNetConnect(host => { return ( host.includes("localhost") || host.includes("127.0.0.1") || - host.includes("::1") || - host.includes("ethereal.email") // used in realEmail.spec.ts + host.includes("::1") ) }) From 9cbef4651220cd02026881d78310ad52156a6909 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 4 Mar 2025 10:26:53 +0000 Subject: [PATCH 08/29] Remove global credentials attempt, didn't work. --- packages/server/src/tests/jestSetup.ts | 6 ------ packages/worker/src/tests/jestSetup.ts | 6 ------ 2 files changed, 12 deletions(-) diff --git a/packages/server/src/tests/jestSetup.ts b/packages/server/src/tests/jestSetup.ts index 663f0482ab..60cf96cb51 100644 --- a/packages/server/src/tests/jestSetup.ts +++ b/packages/server/src/tests/jestSetup.ts @@ -3,12 +3,6 @@ import * as matchers from "jest-extended" import { env as coreEnv, timers } from "@budibase/backend-core" import { testContainerUtils } from "@budibase/backend-core/tests" import nock from "nock" -import AWS from "aws-sdk" - -// Prevent accidental use of real AWS credentials -AWS.config.update({ - credentialProvider: new AWS.CredentialProviderChain([]), -}) expect.extend(matchers) if (!process.env.CI) { diff --git a/packages/worker/src/tests/jestSetup.ts b/packages/worker/src/tests/jestSetup.ts index 682b92e1c3..77501b2f06 100644 --- a/packages/worker/src/tests/jestSetup.ts +++ b/packages/worker/src/tests/jestSetup.ts @@ -2,17 +2,11 @@ import { mocks, testContainerUtils } from "@budibase/backend-core/tests" import env from "../environment" import { env as coreEnv, timers } from "@budibase/backend-core" import nock from "nock" -import AWS from "aws-sdk" // mock all dates to 2020-01-01T00:00:00.000Z // use tk.reset() to use real dates in individual tests import tk from "timekeeper" -// Prevent accidental use of real AWS credentials -AWS.config.update({ - credentialProvider: new AWS.CredentialProviderChain([]), -}) - nock.disableNetConnect() nock.enableNetConnect(host => { return ( From 3234af89983e0ff2e8873ef87ab341b7630702e2 Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 4 Mar 2025 11:22:18 +0100 Subject: [PATCH 09/29] Use reactivity --- .../src/components/app/forms/RelationshipField.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/client/src/components/app/forms/RelationshipField.svelte b/packages/client/src/components/app/forms/RelationshipField.svelte index 831ada9a2d..7d2b399b83 100644 --- a/packages/client/src/components/app/forms/RelationshipField.svelte +++ b/packages/client/src/components/app/forms/RelationshipField.svelte @@ -112,13 +112,13 @@ return undefined } const datasource = - datasourceType === "table" + dsType === "table" ? { - type: datasourceType, + type: dsType, tableId: fieldSchema?.tableId!, } : { - type: datasourceType, + type: dsType, tableId: InternalTable.USER_METADATA, } return fetchData({ From bc125b3c22f2f862eaef78b887ad298e1adc6b48 Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 4 Mar 2025 11:25:45 +0100 Subject: [PATCH 10/29] Fix BBReferenceField search --- .../client/src/components/app/forms/RelationshipField.svelte | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/client/src/components/app/forms/RelationshipField.svelte b/packages/client/src/components/app/forms/RelationshipField.svelte index 7d2b399b83..d70cb7b194 100644 --- a/packages/client/src/components/app/forms/RelationshipField.svelte +++ b/packages/client/src/components/app/forms/RelationshipField.svelte @@ -108,14 +108,11 @@ filter: SearchFilter[], linkedTableId?: string ) => { - if (!linkedTableId) { - return undefined - } const datasource = dsType === "table" ? { type: dsType, - tableId: fieldSchema?.tableId!, + tableId: linkedTableId!, } : { type: dsType, From 7f168852f009cc9715bc9e6011338782e5c601c4 Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 4 Mar 2025 11:54:42 +0100 Subject: [PATCH 11/29] Fix searching user on BBReference --- packages/shared-core/src/utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/shared-core/src/utils.ts b/packages/shared-core/src/utils.ts index fac8fa61ee..6172895af0 100644 --- a/packages/shared-core/src/utils.ts +++ b/packages/shared-core/src/utils.ts @@ -117,7 +117,8 @@ export function isSupportedUserSearch( { op: BasicOperator.EQUAL, key: "_id" }, { op: ArrayOperator.ONE_OF, key: "_id" }, ] - for (const [key, operation] of Object.entries(query)) { + const { allOr, onEmptyFilter, ...filters } = query + for (const [key, operation] of Object.entries(filters)) { if (typeof operation !== "object") { return false } From c336fa0f89254838db917657dcf765b665a82efb Mon Sep 17 00:00:00 2001 From: mikesealey Date: Tue, 4 Mar 2025 13:17:23 +0000 Subject: [PATCH 12/29] adds tests --- packages/string-templates/test/basic.spec.ts | 7 ---- .../string-templates/test/helpers.spec.ts | 41 +++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/packages/string-templates/test/basic.spec.ts b/packages/string-templates/test/basic.spec.ts index 57e2402eeb..aeffa7e912 100644 --- a/packages/string-templates/test/basic.spec.ts +++ b/packages/string-templates/test/basic.spec.ts @@ -340,10 +340,3 @@ describe("check multiple space behaviour", () => { expect(output).toEqual("test string") }) }) - -describe.only("MikeTest1", () => { - it("miktest111", async () => { - const output = "%5B41%5D" - expect(output).toEqual(HELPERS) - }) -}) diff --git a/packages/string-templates/test/helpers.spec.ts b/packages/string-templates/test/helpers.spec.ts index 12de4f1c29..7ef09cb2a4 100644 --- a/packages/string-templates/test/helpers.spec.ts +++ b/packages/string-templates/test/helpers.spec.ts @@ -517,3 +517,44 @@ describe("helper overlap", () => { expect(output).toEqual("a") }) }) + +describe("Test the decodeId helper", () => { + it("should decode a valid encoded ID", async () => { + const encodedId = encodeURIComponent("[42]") // "%5B42%5D" + const output = await processString("{{ decodeId id }}", { id: encodedId }) + expect(output).toBe("42") + }) + + it("Should return an unchanged string if the string isn't encoded", async () => { + const unencodedId = "forty-two" + const output = await processString("{{ decodeId id }}", { id: unencodedId }) + expect(output).toBe("forty-two") + }) + + it("Should return a string of comma-separated IDs when passed multiple IDs in a URI encoded array", async () => { + const encodedIds = encodeURIComponent("[1,2,3]") // "%5B1%2C2%2C3%5D" + const output = await processString("{{ decodeId id }}", { id: encodedIds }) + expect(output).toBe("1,2,3") + }) + + it("Handles empty array gracefully", async () => { + const output = await processString("{{ decodeId value }}", { + value: [], + }) + expect(output).toBe("[[]]") + }) + + it("Handles undefined gracefully", async () => { + const output = await processString("{{ decodeId value }}", { + value: undefined, + }) + expect(output).toBe("") + }) + + it("Handles null gracefully", async () => { + const output = await processString("{{ decodeId value }}", { + value: undefined, + }) + expect(output).toBe("") + }) +}) From 083e8f1987a133d9c3cccfba0a43bd9f0851421f Mon Sep 17 00:00:00 2001 From: mikesealey Date: Tue, 4 Mar 2025 13:32:09 +0000 Subject: [PATCH 13/29] removes unused vars for lint --- packages/string-templates/test/basic.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/string-templates/test/basic.spec.ts b/packages/string-templates/test/basic.spec.ts index aeffa7e912..24a19131f4 100644 --- a/packages/string-templates/test/basic.spec.ts +++ b/packages/string-templates/test/basic.spec.ts @@ -10,8 +10,6 @@ import { findHBSBlocks, } from "../src/index" -import { HELPERS } from "../src/helpers/index" - describe("Test that the string processing works correctly", () => { it("should process a basic template string", async () => { const output = await processString("templating is {{ adjective }}", { From daa4b1e397f836cc797993e04d0cb05bc0b77af4 Mon Sep 17 00:00:00 2001 From: Budibase Staging Release Bot <> Date: Tue, 4 Mar 2025 15:00:57 +0000 Subject: [PATCH 14/29] Bump version to 3.4.23 --- lerna.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lerna.json b/lerna.json index d20097b6e5..bd7c91a67b 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "3.4.22", + "version": "3.4.23", "npmClient": "yarn", "concurrency": 20, "command": { From 4abf0e8431145c579154082a264f80f535d19824 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 4 Mar 2025 15:42:48 +0000 Subject: [PATCH 15/29] Speed up server tests. --- globalSetup.ts | 10 + .../backend-core/src/queue/inMemoryQueue.ts | 6 +- packages/pro | 2 +- .../server/src/api/routes/tests/row.spec.ts | 558 +++++++++--------- .../src/automations/tests/steps/loop.spec.ts | 10 +- .../automations/tests/steps/openai.spec.ts | 12 +- .../server/src/definitions/automations.ts | 9 +- .../server/src/events/AutomationEmitter.ts | 2 + packages/server/src/tests/jestSetup.ts | 7 +- .../server/src/tests/utilities/api/base.ts | 4 +- packages/server/src/threads/automation.ts | 74 +-- 11 files changed, 363 insertions(+), 331 deletions(-) diff --git a/globalSetup.ts b/globalSetup.ts index 0b0e276b49..7396540936 100644 --- a/globalSetup.ts +++ b/globalSetup.ts @@ -88,6 +88,16 @@ export default async function setup() { content: ` [log] level = warn + + [httpd] + socket_options = [{nodelay, true}] + + [couchdb] + single_node = true + + [cluster] + n = 1 + q = 1 `, target: "/opt/couchdb/etc/local.d/test-couchdb.ini", }, diff --git a/packages/backend-core/src/queue/inMemoryQueue.ts b/packages/backend-core/src/queue/inMemoryQueue.ts index 842d3243bc..2ad95e64d0 100644 --- a/packages/backend-core/src/queue/inMemoryQueue.ts +++ b/packages/backend-core/src/queue/inMemoryQueue.ts @@ -3,7 +3,6 @@ import { newid } from "../utils" import { Queue, QueueOptions, JobOptions } from "./queue" import { helpers } from "@budibase/shared-core" import { Job, JobId, JobInformation } from "bull" -import { cloneDeep } from "lodash" function jobToJobInformation(job: Job): JobInformation { let cron = "" @@ -89,7 +88,7 @@ export class InMemoryQueue implements Partial> { async process(concurrencyOrFunc: number | any, func?: any) { func = typeof concurrencyOrFunc === "number" ? func : concurrencyOrFunc this._emitter.on("message", async msg => { - const message = cloneDeep(msg) + const message = msg // For the purpose of testing, don't trigger cron jobs immediately. // Require the test to trigger them manually with timestamps. @@ -165,6 +164,9 @@ export class InMemoryQueue implements Partial> { opts, } this._messages.push(message) + if (this._messages.length > 1000) { + this._messages.shift() + } this._addCount++ this._emitter.emit("message", message) } diff --git a/packages/pro b/packages/pro index b28dbd5492..f8af563b6a 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit b28dbd549284cf450be7f25ad85aadf614d08f0b +Subproject commit f8af563b6a78391d6e19fd0c94fc78724c27ee83 diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 3a732cc662..6698d6e69d 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -166,17 +166,17 @@ if (descriptions.length) { ) } - const resetRowUsage = async () => { - await config.doInContext( - undefined, - async () => - await quotas.setUsage( - 0, - StaticQuotaName.ROWS, - QuotaUsageType.STATIC - ) - ) - } + // const resetRowUsage = async () => { + // await config.doInContext( + // undefined, + // async () => + // await quotas.setUsage( + // 0, + // StaticQuotaName.ROWS, + // QuotaUsageType.STATIC + // ) + // ) + // } const getRowUsage = async () => { const { total } = await config.doInContext(undefined, () => @@ -188,19 +188,29 @@ if (descriptions.length) { return total } - const assertRowUsage = async (expected: number) => { - const usage = await getRowUsage() + async function expectRowUsage(expected: number, f: () => Promise) { + return await quotas.withEnabled(async () => { + const before = await getRowUsage() + await f() + const after = await getRowUsage() + const usage = after - before - // Because our quota tracking is not perfect, we allow a 10% margin of - // error. This is to account for the fact that parallel writes can result - // in some quota updates getting lost. We don't have any need to solve this - // right now, so we just allow for some error. - if (expected === 0) { - expect(usage).toEqual(0) - return - } - expect(usage).toBeGreaterThan(expected * 0.9) - expect(usage).toBeLessThan(expected * 1.1) + // Because our quota tracking is not perfect, we allow a 10% margin of + // error. This is to account for the fact that parallel writes can + // result in some quota updates getting lost. We don't have any need + // to solve this right now, so we just allow for some error. + if (expected === 0) { + expect(usage).toEqual(0) + return + } + if (usage < 0) { + expect(usage).toBeGreaterThan(expected * 1.1) + expect(usage).toBeLessThan(expected * 0.9) + } else { + expect(usage).toBeGreaterThan(expected * 0.9) + expect(usage).toBeLessThan(expected * 1.1) + } + }) } const defaultRowFields = isInternal @@ -216,90 +226,89 @@ if (descriptions.length) { }) beforeEach(async () => { - await resetRowUsage() + // await resetRowUsage() }) describe("create", () => { it("creates a new row successfully", async () => { - const rowUsage = await getRowUsage() - const row = await config.api.row.save(table._id!, { - name: "Test Contact", + await expectRowUsage(isInternal ? 1 : 0, async () => { + const row = await config.api.row.save(table._id!, { + name: "Test Contact", + }) + expect(row.name).toEqual("Test Contact") + expect(row._rev).toBeDefined() }) - expect(row.name).toEqual("Test Contact") - expect(row._rev).toBeDefined() - await assertRowUsage(isInternal ? rowUsage + 1 : rowUsage) }) it("fails to create a row for a table that does not exist", async () => { - const rowUsage = await getRowUsage() - await config.api.row.save("1234567", {}, { status: 404 }) - await assertRowUsage(rowUsage) + await expectRowUsage(0, async () => { + await config.api.row.save("1234567", {}, { status: 404 }) + }) }) it("fails to create a row if required fields are missing", async () => { - const rowUsage = await getRowUsage() - const table = await config.api.table.save( - saveTableRequest({ - schema: { - required: { - type: FieldType.STRING, - name: "required", - constraints: { - type: "string", - presence: true, - }, - }, - }, - }) - ) - await config.api.row.save( - table._id!, - {}, - { - status: 500, - body: { - validationErrors: { - required: ["can't be blank"], - }, - }, - } - ) - await assertRowUsage(rowUsage) - }) - - isInternal && - it("increment row autoId per create row request", async () => { - const rowUsage = await getRowUsage() - - const newTable = await config.api.table.save( + await expectRowUsage(0, async () => { + const table = await config.api.table.save( saveTableRequest({ schema: { - "Row ID": { - name: "Row ID", - type: FieldType.NUMBER, - subtype: AutoFieldSubType.AUTO_ID, - icon: "ri-magic-line", - autocolumn: true, + required: { + type: FieldType.STRING, + name: "required", constraints: { - type: "number", + type: "string", presence: true, - numericality: { - greaterThanOrEqualTo: "", - lessThanOrEqualTo: "", - }, }, }, }, }) ) + await config.api.row.save( + table._id!, + {}, + { + status: 500, + body: { + validationErrors: { + required: ["can't be blank"], + }, + }, + } + ) + }) + }) - let previousId = 0 - for (let i = 0; i < 10; i++) { - const row = await config.api.row.save(newTable._id!, {}) - expect(row["Row ID"]).toBeGreaterThan(previousId) - previousId = row["Row ID"] - } - await assertRowUsage(isInternal ? rowUsage + 10 : rowUsage) + isInternal && + it("increment row autoId per create row request", async () => { + await expectRowUsage(isInternal ? 10 : 0, async () => { + const newTable = await config.api.table.save( + saveTableRequest({ + schema: { + "Row ID": { + name: "Row ID", + type: FieldType.NUMBER, + subtype: AutoFieldSubType.AUTO_ID, + icon: "ri-magic-line", + autocolumn: true, + constraints: { + type: "number", + presence: true, + numericality: { + greaterThanOrEqualTo: "", + lessThanOrEqualTo: "", + }, + }, + }, + }, + }) + ) + + let previousId = 0 + for (let i = 0; i < 10; i++) { + const row = await config.api.row.save(newTable._id!, {}) + expect(row["Row ID"]).toBeGreaterThan(previousId) + previousId = row["Row ID"] + } + }) }) isInternal && @@ -985,16 +994,16 @@ if (descriptions.length) { describe("update", () => { it("updates an existing row successfully", async () => { const existing = await config.api.row.save(table._id!, {}) - const rowUsage = await getRowUsage() - const res = await config.api.row.save(table._id!, { - _id: existing._id, - _rev: existing._rev, - name: "Updated Name", + await expectRowUsage(0, async () => { + const res = await config.api.row.save(table._id!, { + _id: existing._id, + _rev: existing._rev, + name: "Updated Name", + }) + + expect(res.name).toEqual("Updated Name") }) - - expect(res.name).toEqual("Updated Name") - await assertRowUsage(rowUsage) }) !isInternal && @@ -1177,23 +1186,22 @@ if (descriptions.length) { it("should update only the fields that are supplied", async () => { const existing = await config.api.row.save(table._id!, {}) - const rowUsage = await getRowUsage() + await expectRowUsage(0, async () => { + const row = await config.api.row.patch(table._id!, { + _id: existing._id!, + _rev: existing._rev!, + tableId: table._id!, + name: "Updated Name", + }) - const row = await config.api.row.patch(table._id!, { - _id: existing._id!, - _rev: existing._rev!, - tableId: table._id!, - name: "Updated Name", + expect(row.name).toEqual("Updated Name") + expect(row.description).toEqual(existing.description) + + const savedRow = await config.api.row.get(table._id!, row._id!) + + expect(savedRow.description).toEqual(existing.description) + expect(savedRow.name).toEqual("Updated Name") }) - - expect(row.name).toEqual("Updated Name") - expect(row.description).toEqual(existing.description) - - const savedRow = await config.api.row.get(table._id!, row._id!) - - expect(savedRow.description).toEqual(existing.description) - expect(savedRow.name).toEqual("Updated Name") - await assertRowUsage(rowUsage) }) it("should update only the fields that are supplied and emit the correct oldRow", async () => { @@ -1224,20 +1232,19 @@ if (descriptions.length) { it("should throw an error when given improper types", async () => { const existing = await config.api.row.save(table._id!, {}) - const rowUsage = await getRowUsage() - await config.api.row.patch( - table._id!, - { - _id: existing._id!, - _rev: existing._rev!, - tableId: table._id!, - name: 1, - }, - { status: 400 } - ) - - await assertRowUsage(rowUsage) + await expectRowUsage(0, async () => { + await config.api.row.patch( + table._id!, + { + _id: existing._id!, + _rev: existing._rev!, + tableId: table._id!, + name: 1, + }, + { status: 400 } + ) + }) }) it("should not overwrite links if those links are not set", async () => { @@ -1452,25 +1459,25 @@ if (descriptions.length) { it("should be able to delete a row", async () => { const createdRow = await config.api.row.save(table._id!, {}) - const rowUsage = await getRowUsage() - const res = await config.api.row.bulkDelete(table._id!, { - rows: [createdRow], + await expectRowUsage(isInternal ? -1 : 0, async () => { + const res = await config.api.row.bulkDelete(table._id!, { + rows: [createdRow], + }) + expect(res[0]._id).toEqual(createdRow._id) }) - expect(res[0]._id).toEqual(createdRow._id) - await assertRowUsage(isInternal ? rowUsage - 1 : rowUsage) }) it("should be able to delete a row with ID only", async () => { const createdRow = await config.api.row.save(table._id!, {}) - const rowUsage = await getRowUsage() - const res = await config.api.row.bulkDelete(table._id!, { - rows: [createdRow._id!], + await expectRowUsage(isInternal ? -1 : 0, async () => { + const res = await config.api.row.bulkDelete(table._id!, { + rows: [createdRow._id!], + }) + expect(res[0]._id).toEqual(createdRow._id) + expect(res[0].tableId).toEqual(table._id!) }) - expect(res[0]._id).toEqual(createdRow._id) - expect(res[0].tableId).toEqual(table._id!) - await assertRowUsage(isInternal ? rowUsage - 1 : rowUsage) }) it("should be able to bulk delete rows, including a row that doesn't exist", async () => { @@ -1560,31 +1567,29 @@ if (descriptions.length) { }) it("should return no errors on valid row", async () => { - const rowUsage = await getRowUsage() + await expectRowUsage(0, async () => { + const res = await config.api.row.validate(table._id!, { + name: "ivan", + }) - const res = await config.api.row.validate(table._id!, { - name: "ivan", + expect(res.valid).toBe(true) + expect(Object.keys(res.errors)).toEqual([]) }) - - expect(res.valid).toBe(true) - expect(Object.keys(res.errors)).toEqual([]) - await assertRowUsage(rowUsage) }) it("should errors on invalid row", async () => { - const rowUsage = await getRowUsage() + await expectRowUsage(0, async () => { + const res = await config.api.row.validate(table._id!, { name: 1 }) - const res = await config.api.row.validate(table._id!, { name: 1 }) - - if (isInternal) { - expect(res.valid).toBe(false) - expect(Object.keys(res.errors)).toEqual(["name"]) - } else { - // Validation for external is not implemented, so it will always return valid - expect(res.valid).toBe(true) - expect(Object.keys(res.errors)).toEqual([]) - } - await assertRowUsage(rowUsage) + if (isInternal) { + expect(res.valid).toBe(false) + expect(Object.keys(res.errors)).toEqual(["name"]) + } else { + // Validation for external is not implemented, so it will always return valid + expect(res.valid).toBe(true) + expect(Object.keys(res.errors)).toEqual([]) + } + }) }) }) @@ -1596,15 +1601,15 @@ if (descriptions.length) { it("should be able to delete a bulk set of rows", async () => { const row1 = await config.api.row.save(table._id!, {}) const row2 = await config.api.row.save(table._id!, {}) - const rowUsage = await getRowUsage() - const res = await config.api.row.bulkDelete(table._id!, { - rows: [row1, row2], + await expectRowUsage(isInternal ? -2 : 0, async () => { + const res = await config.api.row.bulkDelete(table._id!, { + rows: [row1, row2], + }) + + expect(res.length).toEqual(2) + await config.api.row.get(table._id!, row1._id!, { status: 404 }) }) - - expect(res.length).toEqual(2) - await config.api.row.get(table._id!, row1._id!, { status: 404 }) - await assertRowUsage(isInternal ? rowUsage - 2 : rowUsage) }) it("should be able to delete a variety of row set types", async () => { @@ -1613,41 +1618,42 @@ if (descriptions.length) { config.api.row.save(table._id!, {}), config.api.row.save(table._id!, {}), ]) - const rowUsage = await getRowUsage() - const res = await config.api.row.bulkDelete(table._id!, { - rows: [row1, row2._id!, { _id: row3._id }], + await expectRowUsage(isInternal ? -3 : 0, async () => { + const res = await config.api.row.bulkDelete(table._id!, { + rows: [row1, row2._id!, { _id: row3._id }], + }) + + expect(res.length).toEqual(3) + await config.api.row.get(table._id!, row1._id!, { status: 404 }) }) - - expect(res.length).toEqual(3) - await config.api.row.get(table._id!, row1._id!, { status: 404 }) - await assertRowUsage(isInternal ? rowUsage - 3 : rowUsage) }) it("should accept a valid row object and delete the row", async () => { const row1 = await config.api.row.save(table._id!, {}) - const rowUsage = await getRowUsage() - const res = await config.api.row.delete(table._id!, row1 as DeleteRow) + await expectRowUsage(isInternal ? -1 : 0, async () => { + const res = await config.api.row.delete( + table._id!, + row1 as DeleteRow + ) - expect(res.id).toEqual(row1._id) - await config.api.row.get(table._id!, row1._id!, { status: 404 }) - await assertRowUsage(isInternal ? rowUsage - 1 : rowUsage) + expect(res.id).toEqual(row1._id) + await config.api.row.get(table._id!, row1._id!, { status: 404 }) + }) }) it.each([{ not: "valid" }, { rows: 123 }, "invalid"])( "should ignore malformed/invalid delete request: %s", async (request: any) => { - const rowUsage = await getRowUsage() - - await config.api.row.delete(table._id!, request, { - status: 400, - body: { - message: "Invalid delete rows request", - }, + await expectRowUsage(0, async () => { + await config.api.row.delete(table._id!, request, { + status: 400, + body: { + message: "Invalid delete rows request", + }, + }) }) - - await assertRowUsage(rowUsage) } ) }) @@ -1733,31 +1739,29 @@ if (descriptions.length) { }) ) - const rowUsage = await getRowUsage() + await expectRowUsage(isInternal ? 2 : 0, async () => { + await config.api.row.bulkImport(table._id!, { + rows: [ + { + name: "Row 1", + description: "Row 1 description", + }, + { + name: "Row 2", + description: "Row 2 description", + }, + ], + }) - await config.api.row.bulkImport(table._id!, { - rows: [ - { - name: "Row 1", - description: "Row 1 description", - }, - { - name: "Row 2", - description: "Row 2 description", - }, - ], + const rows = await config.api.row.fetch(table._id!) + expect(rows.length).toEqual(2) + + rows.sort((a, b) => a.name.localeCompare(b.name)) + expect(rows[0].name).toEqual("Row 1") + expect(rows[0].description).toEqual("Row 1 description") + expect(rows[1].name).toEqual("Row 2") + expect(rows[1].description).toEqual("Row 2 description") }) - - const rows = await config.api.row.fetch(table._id!) - expect(rows.length).toEqual(2) - - rows.sort((a, b) => a.name.localeCompare(b.name)) - expect(rows[0].name).toEqual("Row 1") - expect(rows[0].description).toEqual("Row 1 description") - expect(rows[1].name).toEqual("Row 2") - expect(rows[1].description).toEqual("Row 2 description") - - await assertRowUsage(isInternal ? rowUsage + 2 : rowUsage) }) isInternal && @@ -1782,35 +1786,33 @@ if (descriptions.length) { description: "Existing description", }) - const rowUsage = await getRowUsage() + await expectRowUsage(2, async () => { + await config.api.row.bulkImport(table._id!, { + rows: [ + { + name: "Row 1", + description: "Row 1 description", + }, + { ...existingRow, name: "Updated existing row" }, + { + name: "Row 2", + description: "Row 2 description", + }, + ], + identifierFields: ["_id"], + }) - await config.api.row.bulkImport(table._id!, { - rows: [ - { - name: "Row 1", - description: "Row 1 description", - }, - { ...existingRow, name: "Updated existing row" }, - { - name: "Row 2", - description: "Row 2 description", - }, - ], - identifierFields: ["_id"], + const rows = await config.api.row.fetch(table._id!) + expect(rows.length).toEqual(3) + + rows.sort((a, b) => a.name.localeCompare(b.name)) + expect(rows[0].name).toEqual("Row 1") + expect(rows[0].description).toEqual("Row 1 description") + expect(rows[1].name).toEqual("Row 2") + expect(rows[1].description).toEqual("Row 2 description") + expect(rows[2].name).toEqual("Updated existing row") + expect(rows[2].description).toEqual("Existing description") }) - - const rows = await config.api.row.fetch(table._id!) - expect(rows.length).toEqual(3) - - rows.sort((a, b) => a.name.localeCompare(b.name)) - expect(rows[0].name).toEqual("Row 1") - expect(rows[0].description).toEqual("Row 1 description") - expect(rows[1].name).toEqual("Row 2") - expect(rows[1].description).toEqual("Row 2 description") - expect(rows[2].name).toEqual("Updated existing row") - expect(rows[2].description).toEqual("Existing description") - - await assertRowUsage(rowUsage + 2) }) isInternal && @@ -1835,36 +1837,34 @@ if (descriptions.length) { description: "Existing description", }) - const rowUsage = await getRowUsage() + await expectRowUsage(3, async () => { + await config.api.row.bulkImport(table._id!, { + rows: [ + { + name: "Row 1", + description: "Row 1 description", + }, + { ...existingRow, name: "Updated existing row" }, + { + name: "Row 2", + description: "Row 2 description", + }, + ], + }) - await config.api.row.bulkImport(table._id!, { - rows: [ - { - name: "Row 1", - description: "Row 1 description", - }, - { ...existingRow, name: "Updated existing row" }, - { - name: "Row 2", - description: "Row 2 description", - }, - ], + const rows = await config.api.row.fetch(table._id!) + expect(rows.length).toEqual(4) + + rows.sort((a, b) => a.name.localeCompare(b.name)) + expect(rows[0].name).toEqual("Existing row") + expect(rows[0].description).toEqual("Existing description") + expect(rows[1].name).toEqual("Row 1") + expect(rows[1].description).toEqual("Row 1 description") + expect(rows[2].name).toEqual("Row 2") + expect(rows[2].description).toEqual("Row 2 description") + expect(rows[3].name).toEqual("Updated existing row") + expect(rows[3].description).toEqual("Existing description") }) - - const rows = await config.api.row.fetch(table._id!) - expect(rows.length).toEqual(4) - - rows.sort((a, b) => a.name.localeCompare(b.name)) - expect(rows[0].name).toEqual("Existing row") - expect(rows[0].description).toEqual("Existing description") - expect(rows[1].name).toEqual("Row 1") - expect(rows[1].description).toEqual("Row 1 description") - expect(rows[2].name).toEqual("Row 2") - expect(rows[2].description).toEqual("Row 2 description") - expect(rows[3].name).toEqual("Updated existing row") - expect(rows[3].description).toEqual("Existing description") - - await assertRowUsage(rowUsage + 3) }) // Upserting isn't yet supported in MSSQL / Oracle, see: @@ -2187,29 +2187,29 @@ if (descriptions.length) { return { linkedTable, firstRow, secondRow } } ) - const rowUsage = await getRowUsage() - // test basic enrichment - const resBasic = await config.api.row.get( - linkedTable._id!, - secondRow._id! - ) - expect(resBasic.link.length).toBe(1) - expect(resBasic.link[0]).toEqual({ - _id: firstRow._id, - primaryDisplay: firstRow.name, + await expectRowUsage(0, async () => { + // test basic enrichment + const resBasic = await config.api.row.get( + linkedTable._id!, + secondRow._id! + ) + expect(resBasic.link.length).toBe(1) + expect(resBasic.link[0]).toEqual({ + _id: firstRow._id, + primaryDisplay: firstRow.name, + }) + + // test full enrichment + const resEnriched = await config.api.row.getEnriched( + linkedTable._id!, + secondRow._id! + ) + expect(resEnriched.link.length).toBe(1) + expect(resEnriched.link[0]._id).toBe(firstRow._id) + expect(resEnriched.link[0].name).toBe("Test Contact") + expect(resEnriched.link[0].description).toBe("original description") }) - - // test full enrichment - const resEnriched = await config.api.row.getEnriched( - linkedTable._id!, - secondRow._id! - ) - expect(resEnriched.link.length).toBe(1) - expect(resEnriched.link[0]._id).toBe(firstRow._id) - expect(resEnriched.link[0].name).toBe("Test Contact") - expect(resEnriched.link[0].description).toBe("original description") - await assertRowUsage(rowUsage) }) }) diff --git a/packages/server/src/automations/tests/steps/loop.spec.ts b/packages/server/src/automations/tests/steps/loop.spec.ts index 34fc175c71..2169c6157b 100644 --- a/packages/server/src/automations/tests/steps/loop.spec.ts +++ b/packages/server/src/automations/tests/steps/loop.spec.ts @@ -13,24 +13,26 @@ import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" import TestConfiguration from "../../../tests/utilities/TestConfiguration" describe("Attempt to run a basic loop automation", () => { - const config = new TestConfiguration() + let config: TestConfiguration let table: Table beforeAll(async () => { - await config.init() await automation.init() }) beforeEach(async () => { - await config.api.automation.deleteAll() + config = new TestConfiguration() + await config.init() table = await config.api.table.save(basicTable()) await config.api.row.save(table._id!, {}) }) + afterEach(async () => { + config.end() + }) afterAll(async () => { await automation.shutdown() - config.end() }) it("attempt to run a basic loop", async () => { diff --git a/packages/server/src/automations/tests/steps/openai.spec.ts b/packages/server/src/automations/tests/steps/openai.spec.ts index a06c633e5e..b370c9b71c 100644 --- a/packages/server/src/automations/tests/steps/openai.spec.ts +++ b/packages/server/src/automations/tests/steps/openai.spec.ts @@ -44,11 +44,13 @@ describe("test the openai action", () => { } const expectAIUsage = async (expected: number, f: () => Promise) => { - const before = await getAIUsage() - const result = await f() - const after = await getAIUsage() - expect(after - before).toEqual(expected) - return result + return await quotas.withEnabled(async () => { + const before = await getAIUsage() + const result = await f() + const after = await getAIUsage() + expect(after - before).toEqual(expected) + return result + }) } it("should be able to receive a response from ChatGPT given a prompt", async () => { diff --git a/packages/server/src/definitions/automations.ts b/packages/server/src/definitions/automations.ts index a04b960ca5..5287498e80 100644 --- a/packages/server/src/definitions/automations.ts +++ b/packages/server/src/definitions/automations.ts @@ -20,9 +20,12 @@ export interface TriggerOutput { export interface AutomationContext { trigger: AutomationTriggerResultOutputs - steps: [AutomationTriggerResultOutputs, ...AutomationStepResultOutputs[]] - stepsById: Record + steps: Record< + string, + AutomationStepResultOutputs | AutomationTriggerResultOutputs + > stepsByName: Record + stepsById: Record env?: Record user?: UserBindings settings?: { @@ -31,4 +34,6 @@ export interface AutomationContext { company?: string } loop?: { currentItem: any } + _stepIndex: number + _error: boolean } diff --git a/packages/server/src/events/AutomationEmitter.ts b/packages/server/src/events/AutomationEmitter.ts index a95acd0877..2f0e3d5abb 100644 --- a/packages/server/src/events/AutomationEmitter.ts +++ b/packages/server/src/events/AutomationEmitter.ts @@ -32,6 +32,8 @@ class AutomationEmitter implements ContextEmitter { if (chainAutomations === true) { return MAX_AUTOMATIONS_ALLOWED + } else if (env.isTest()) { + return 0 } else if (chainAutomations === undefined && env.SELF_HOSTED) { return MAX_AUTOMATIONS_ALLOWED } else { diff --git a/packages/server/src/tests/jestSetup.ts b/packages/server/src/tests/jestSetup.ts index 60cf96cb51..6c03dec8d4 100644 --- a/packages/server/src/tests/jestSetup.ts +++ b/packages/server/src/tests/jestSetup.ts @@ -3,6 +3,7 @@ import * as matchers from "jest-extended" import { env as coreEnv, timers } from "@budibase/backend-core" import { testContainerUtils } from "@budibase/backend-core/tests" import nock from "nock" +import { quotas } from "@budibase/pro" expect.extend(matchers) if (!process.env.CI) { @@ -23,6 +24,10 @@ nock.enableNetConnect(host => { testContainerUtils.setupEnv(env, coreEnv) -afterAll(() => { +beforeAll(async () => { + quotas.disable() +}) + +afterAll(async () => { timers.cleanup() }) diff --git a/packages/server/src/tests/utilities/api/base.ts b/packages/server/src/tests/utilities/api/base.ts index 9b47cfb820..18a9fbc195 100644 --- a/packages/server/src/tests/utilities/api/base.ts +++ b/packages/server/src/tests/utilities/api/base.ts @@ -146,8 +146,9 @@ export abstract class TestAPI { } } + let resp: Response | undefined = undefined try { - return await req + resp = await req } catch (e: any) { // We've found that occasionally the connection between supertest and the // server supertest starts gets reset. Not sure why, but retrying it @@ -161,6 +162,7 @@ export abstract class TestAPI { } throw e } + return resp } protected async getHeaders( diff --git a/packages/server/src/threads/automation.ts b/packages/server/src/threads/automation.ts index def2ab4201..675009d74a 100644 --- a/packages/server/src/threads/automation.ts +++ b/packages/server/src/threads/automation.ts @@ -143,7 +143,6 @@ async function branchMatches( branch: Readonly ): Promise { const toFilter: Record = {} - const preparedCtx = prepareContext(ctx) // Because we allow bindings on both the left and right of each condition in // automation branches, we can't pass the BranchSearchFilters directly to @@ -160,9 +159,9 @@ async function branchMatches( filter.conditions = filter.conditions.map(evaluateBindings) } else { for (const [field, value] of Object.entries(filter)) { - toFilter[field] = processStringSync(field, preparedCtx) + toFilter[field] = processStringSync(field, ctx) if (typeof value === "string" && findHBSBlocks(value).length > 0) { - filter[field] = processStringSync(value, preparedCtx) + filter[field] = processStringSync(value, ctx) } } } @@ -178,17 +177,6 @@ async function branchMatches( return result.length > 0 } -function prepareContext(context: AutomationContext) { - return { - ...context, - steps: { - ...context.steps, - ...context.stepsById, - ...context.stepsByName, - }, - } -} - async function enrichBaseContext(context: AutomationContext) { context.env = await sdkUtils.getEnvironmentVariables() @@ -304,41 +292,44 @@ class Orchestrator { } hasErrored(context: AutomationContext): boolean { - const [_trigger, ...steps] = context.steps - for (const step of steps) { - if (step.success === false) { - return true - } - } - return false + return context._error === true + // const [_trigger, ...steps] = context.steps + // for (const step of steps) { + // if (step.success === false) { + // return true + // } + // } + // return false } async execute(): Promise { return await tracer.trace("execute", async span => { span.addTags({ appId: this.appId, automationId: this.automation._id }) - const job = cloneDeep(this.job) - delete job.data.event.appId - delete job.data.event.metadata + const data = cloneDeep(this.job.data) + delete data.event.appId + delete data.event.metadata - if (this.isCron() && !job.data.event.timestamp) { - job.data.event.timestamp = Date.now() + if (this.isCron() && !data.event.timestamp) { + data.event.timestamp = Date.now() } const trigger: AutomationTriggerResult = { - id: job.data.automation.definition.trigger.id, - stepId: job.data.automation.definition.trigger.stepId, + id: data.automation.definition.trigger.id, + stepId: data.automation.definition.trigger.stepId, inputs: null, - outputs: job.data.event, + outputs: data.event, } const result: AutomationResults = { trigger, steps: [trigger] } const ctx: AutomationContext = { trigger: trigger.outputs, - steps: [trigger.outputs], - stepsById: {}, + steps: { "0": trigger.outputs }, stepsByName: {}, + stepsById: {}, user: trigger.outputs.user, + _error: false, + _stepIndex: 1, } await enrichBaseContext(ctx) @@ -348,7 +339,7 @@ class Orchestrator { try { await helpers.withTimeout(timeout, async () => { const [stepOutputs, executionTime] = await utils.time(() => - this.executeSteps(ctx, job.data.automation.definition.steps) + this.executeSteps(ctx, data.automation.definition.steps) ) result.steps.push(...stepOutputs) @@ -400,9 +391,20 @@ class Orchestrator { step: AutomationStep, result: AutomationStepResult ) { - ctx.steps.push(result.outputs) + ctx.steps[step.id] = result.outputs + ctx.steps[step.name || step.id] = result.outputs + ctx.stepsById[step.id] = result.outputs ctx.stepsByName[step.name || step.id] = result.outputs + + ctx._stepIndex ||= 0 + ctx.steps[ctx._stepIndex] = result.outputs + ctx._stepIndex++ + + if (result.outputs.success === false) { + ctx._error = true + } + results.push(result) } @@ -449,7 +451,7 @@ class Orchestrator { stepToLoop: AutomationStep ): Promise { return await tracer.trace("executeLoopStep", async span => { - await processObject(step.inputs, prepareContext(ctx)) + await processObject(step.inputs, ctx) const maxIterations = getLoopMaxIterations(step) const items: Record[] = [] @@ -558,7 +560,7 @@ class Orchestrator { } const inputs = automationUtils.cleanInputValues( - await processObject(cloneDeep(step.inputs), prepareContext(ctx)), + await processObject(cloneDeep(step.inputs), ctx), step.schema.inputs.properties ) @@ -566,7 +568,7 @@ class Orchestrator { inputs, appId: this.appId, emitter: this.emitter, - context: prepareContext(ctx), + context: ctx, }) if ( From b077e5f24b3d138f80c27815f77377d90a91fb33 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 4 Mar 2025 16:09:53 +0000 Subject: [PATCH 16/29] Fix automation.spec.ts. --- .../server/src/api/routes/tests/automation.spec.ts | 3 +-- .../server/src/automations/tests/steps/loop.spec.ts | 13 +++---------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/server/src/api/routes/tests/automation.spec.ts b/packages/server/src/api/routes/tests/automation.spec.ts index 1591412735..c9bc940ff3 100644 --- a/packages/server/src/api/routes/tests/automation.spec.ts +++ b/packages/server/src/api/routes/tests/automation.spec.ts @@ -290,8 +290,7 @@ describe("/automations", () => { await setup.delay(500) let elements = await getAllTableRows(config) // don't test it unless there are values to test - if (elements.length > 1) { - expect(elements.length).toBeGreaterThanOrEqual(MAX_RETRIES) + if (elements.length >= 1) { expect(elements[0].name).toEqual("Test") expect(elements[0].description).toEqual("TEST") return diff --git a/packages/server/src/automations/tests/steps/loop.spec.ts b/packages/server/src/automations/tests/steps/loop.spec.ts index 2169c6157b..e23aabef49 100644 --- a/packages/server/src/automations/tests/steps/loop.spec.ts +++ b/packages/server/src/automations/tests/steps/loop.spec.ts @@ -1,4 +1,3 @@ -import * as automation from "../../index" import { basicTable } from "../../../tests/utilities/structures" import { Table, @@ -13,26 +12,20 @@ import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" import TestConfiguration from "../../../tests/utilities/TestConfiguration" describe("Attempt to run a basic loop automation", () => { - let config: TestConfiguration + const config = new TestConfiguration() let table: Table beforeAll(async () => { - await automation.init() + await config.init() }) beforeEach(async () => { - config = new TestConfiguration() - await config.init() - table = await config.api.table.save(basicTable()) await config.api.row.save(table._id!, {}) }) - afterEach(async () => { - config.end() - }) afterAll(async () => { - await automation.shutdown() + config.end() }) it("attempt to run a basic loop", async () => { From 8276a9ede70eaad97b372e7563ae21b9e18b4372 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 4 Mar 2025 16:24:21 +0000 Subject: [PATCH 17/29] Fix plugin.spec.ts. --- packages/backend-core/src/queue/inMemoryQueue.ts | 4 +--- packages/server/src/api/routes/tests/row.spec.ts | 16 ---------------- packages/server/src/tests/jestSetup.ts | 2 +- packages/server/src/threads/automation.ts | 7 ------- 4 files changed, 2 insertions(+), 27 deletions(-) diff --git a/packages/backend-core/src/queue/inMemoryQueue.ts b/packages/backend-core/src/queue/inMemoryQueue.ts index 2ad95e64d0..dc6890e655 100644 --- a/packages/backend-core/src/queue/inMemoryQueue.ts +++ b/packages/backend-core/src/queue/inMemoryQueue.ts @@ -87,9 +87,7 @@ export class InMemoryQueue implements Partial> { */ async process(concurrencyOrFunc: number | any, func?: any) { func = typeof concurrencyOrFunc === "number" ? func : concurrencyOrFunc - this._emitter.on("message", async msg => { - const message = msg - + this._emitter.on("message", async message => { // For the purpose of testing, don't trigger cron jobs immediately. // Require the test to trigger them manually with timestamps. if (!message.manualTrigger && message.opts?.repeat != null) { diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 6698d6e69d..a916131a39 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -166,18 +166,6 @@ if (descriptions.length) { ) } - // const resetRowUsage = async () => { - // await config.doInContext( - // undefined, - // async () => - // await quotas.setUsage( - // 0, - // StaticQuotaName.ROWS, - // QuotaUsageType.STATIC - // ) - // ) - // } - const getRowUsage = async () => { const { total } = await config.doInContext(undefined, () => quotas.getCurrentUsageValues( @@ -225,10 +213,6 @@ if (descriptions.length) { table = await config.api.table.save(defaultTable()) }) - beforeEach(async () => { - // await resetRowUsage() - }) - describe("create", () => { it("creates a new row successfully", async () => { await expectRowUsage(isInternal ? 1 : 0, async () => { diff --git a/packages/server/src/tests/jestSetup.ts b/packages/server/src/tests/jestSetup.ts index 6c03dec8d4..d9b8a37f98 100644 --- a/packages/server/src/tests/jestSetup.ts +++ b/packages/server/src/tests/jestSetup.ts @@ -3,7 +3,6 @@ import * as matchers from "jest-extended" import { env as coreEnv, timers } from "@budibase/backend-core" import { testContainerUtils } from "@budibase/backend-core/tests" import nock from "nock" -import { quotas } from "@budibase/pro" expect.extend(matchers) if (!process.env.CI) { @@ -25,6 +24,7 @@ nock.enableNetConnect(host => { testContainerUtils.setupEnv(env, coreEnv) beforeAll(async () => { + const quotas = require("@budibase/pro").quotas quotas.disable() }) diff --git a/packages/server/src/threads/automation.ts b/packages/server/src/threads/automation.ts index 675009d74a..f6067f44f5 100644 --- a/packages/server/src/threads/automation.ts +++ b/packages/server/src/threads/automation.ts @@ -293,13 +293,6 @@ class Orchestrator { hasErrored(context: AutomationContext): boolean { return context._error === true - // const [_trigger, ...steps] = context.steps - // for (const step of steps) { - // if (step.success === false) { - // return true - // } - // } - // return false } async execute(): Promise { From 021667c7d90e1e901bef9ffdc78cc772dcfbacc9 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 4 Mar 2025 16:28:25 +0000 Subject: [PATCH 18/29] Fix viewV2.spec.ts --- .../src/api/routes/tests/viewV2.spec.ts | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index 7eed1811d9..d31b2038f2 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -2826,16 +2826,22 @@ if (descriptions.length) { return total } - const assertRowUsage = async (expected: number) => { - const usage = await getRowUsage() + async function expectRowUsage( + expected: number, + f: () => Promise + ) { + const before = await getRowUsage() + await f() + const after = await getRowUsage() + const usage = after - before expect(usage).toBe(expected) } it("should be able to delete a row", async () => { const createdRow = await config.api.row.save(table._id!, {}) - const rowUsage = await getRowUsage() - await config.api.row.bulkDelete(view.id, { rows: [createdRow] }) - await assertRowUsage(isInternal ? rowUsage - 1 : rowUsage) + await expectRowUsage(isInternal ? 0 : -1, async () => { + await config.api.row.bulkDelete(view.id, { rows: [createdRow] }) + }) await config.api.row.get(table._id!, createdRow._id!, { status: 404, }) @@ -2847,14 +2853,13 @@ if (descriptions.length) { config.api.row.save(table._id!, {}), config.api.row.save(table._id!, {}), ]) - const rowUsage = await getRowUsage() - await config.api.row.bulkDelete(view.id, { - rows: [rows[0], rows[2]], + await expectRowUsage(isInternal ? 0 : -2, async () => { + await config.api.row.bulkDelete(view.id, { + rows: [rows[0], rows[2]], + }) }) - await assertRowUsage(isInternal ? rowUsage - 2 : rowUsage) - await config.api.row.get(table._id!, rows[0]._id!, { status: 404, }) From 51301b384232bdb1cbf3cd5fc039bbf7baabaf82 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 4 Mar 2025 16:36:20 +0000 Subject: [PATCH 19/29] Attempt to fix tests. --- packages/pro | 2 +- packages/server/src/tests/jestSetup.ts | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/pro b/packages/pro index f8af563b6a..56d15b2cf5 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit f8af563b6a78391d6e19fd0c94fc78724c27ee83 +Subproject commit 56d15b2cf58a57614030e93b51eb5668bfd17cb3 diff --git a/packages/server/src/tests/jestSetup.ts b/packages/server/src/tests/jestSetup.ts index d9b8a37f98..6fedbf1f5b 100644 --- a/packages/server/src/tests/jestSetup.ts +++ b/packages/server/src/tests/jestSetup.ts @@ -23,11 +23,6 @@ nock.enableNetConnect(host => { testContainerUtils.setupEnv(env, coreEnv) -beforeAll(async () => { - const quotas = require("@budibase/pro").quotas - quotas.disable() -}) - afterAll(async () => { timers.cleanup() }) From b8c5c7cfe2df699cb62fc09acb41772aa497adae Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 4 Mar 2025 16:49:20 +0000 Subject: [PATCH 20/29] Fix automation test output when loop max iterations or failure condition hit. --- .../src/automations/tests/steps/loop.spec.ts | 27 +++++++++++++++++++ packages/server/src/threads/automation.ts | 3 +++ 2 files changed, 30 insertions(+) diff --git a/packages/server/src/automations/tests/steps/loop.spec.ts b/packages/server/src/automations/tests/steps/loop.spec.ts index 34fc175c71..78ef3a279f 100644 --- a/packages/server/src/automations/tests/steps/loop.spec.ts +++ b/packages/server/src/automations/tests/steps/loop.spec.ts @@ -195,7 +195,34 @@ describe("Attempt to run a basic loop automation", () => { .serverLog({ text: "{{steps.1.iterations}}" }) .test({ fields: {} }) + expect(results.steps[0].outputs.status).toBe( + AutomationStepStatus.MAX_ITERATIONS + ) expect(results.steps[0].outputs.iterations).toBe(2) + expect(results.steps[0].outputs.items).toHaveLength(2) + expect(results.steps[0].outputs.items[0].message).toEndWith("test") + expect(results.steps[0].outputs.items[1].message).toEndWith("test2") + }) + + it("should stop when a failure condition is hit", async () => { + const results = await createAutomationBuilder(config) + .onAppAction() + .loop({ + option: LoopStepType.ARRAY, + binding: ["test", "test2", "test3"], + failure: "test3", + }) + .serverLog({ text: "{{loop.currentItem}}" }) + .serverLog({ text: "{{steps.1.iterations}}" }) + .test({ fields: {} }) + + expect(results.steps[0].outputs.status).toBe( + AutomationStepStatus.FAILURE_CONDITION + ) + expect(results.steps[0].outputs.iterations).toBe(2) + expect(results.steps[0].outputs.items).toHaveLength(2) + expect(results.steps[0].outputs.items[0].message).toEndWith("test") + expect(results.steps[0].outputs.items[1].message).toEndWith("test2") }) it("should run an automation with loop and max iterations to ensure context correctness further down the tree", async () => { diff --git a/packages/server/src/threads/automation.ts b/packages/server/src/threads/automation.ts index def2ab4201..b2e7f4c38d 100644 --- a/packages/server/src/threads/automation.ts +++ b/packages/server/src/threads/automation.ts @@ -478,6 +478,7 @@ class Orchestrator { return stepFailure(stepToLoop, { status: AutomationStepStatus.MAX_ITERATIONS, iterations, + items, }) } @@ -488,6 +489,8 @@ class Orchestrator { }) return stepFailure(stepToLoop, { status: AutomationStepStatus.FAILURE_CONDITION, + iterations, + items, }) } From 3e17fa186c494c8555190bf5973e22f884b26b18 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 4 Mar 2025 17:08:20 +0000 Subject: [PATCH 21/29] Fix viewV2.spec.ts --- .../src/api/routes/tests/viewV2.spec.ts | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index d31b2038f2..153a25aca7 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -2826,35 +2826,42 @@ if (descriptions.length) { return total } - async function expectRowUsage( + async function expectRowUsage( expected: number, - f: () => Promise - ) { - const before = await getRowUsage() - await f() - const after = await getRowUsage() - const usage = after - before - expect(usage).toBe(expected) + f: () => Promise + ): Promise { + return await quotas.withEnabled(async () => { + const before = await getRowUsage() + const result = await f() + const after = await getRowUsage() + const usage = after - before + expect(usage).toBe(expected) + return result + }) } it("should be able to delete a row", async () => { - const createdRow = await config.api.row.save(table._id!, {}) - await expectRowUsage(isInternal ? 0 : -1, async () => { - await config.api.row.bulkDelete(view.id, { rows: [createdRow] }) - }) + const createdRow = await expectRowUsage(isInternal ? 1 : 0, () => + config.api.row.save(table._id!, {}) + ) + await expectRowUsage(isInternal ? -1 : 0, () => + config.api.row.bulkDelete(view.id, { rows: [createdRow] }) + ) await config.api.row.get(table._id!, createdRow._id!, { status: 404, }) }) it("should be able to delete multiple rows", async () => { - const rows = await Promise.all([ - config.api.row.save(table._id!, {}), - config.api.row.save(table._id!, {}), - config.api.row.save(table._id!, {}), - ]) + const rows = await expectRowUsage(isInternal ? 3 : 0, async () => { + return [ + await config.api.row.save(table._id!, {}), + await config.api.row.save(table._id!, {}), + await config.api.row.save(table._id!, {}), + ] + }) - await expectRowUsage(isInternal ? 0 : -2, async () => { + await expectRowUsage(isInternal ? -2 : 0, async () => { await config.api.row.bulkDelete(view.id, { rows: [rows[0], rows[2]], }) From 32cad5015020d7072905e82aa8a5eb08f9b16007 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 4 Mar 2025 17:22:20 +0000 Subject: [PATCH 22/29] Fix quotas.spec.ts. --- packages/pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pro b/packages/pro index 56d15b2cf5..c9da6a4200 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit 56d15b2cf58a57614030e93b51eb5668bfd17cb3 +Subproject commit c9da6a4200c9fcb5ce42375a5f991f5e859ecc02 From 84542dd808465479c0460b9b00d330feeaf31ed2 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 4 Mar 2025 17:30:51 +0000 Subject: [PATCH 23/29] Fix other quota tests. --- packages/pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pro b/packages/pro index c9da6a4200..bec35b4c27 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit c9da6a4200c9fcb5ce42375a5f991f5e859ecc02 +Subproject commit bec35b4c27ec10fe31dc07f9d42d8bda426b8535 From e38d4c1e48eda9e11d9e9a52d941f4d86bd8f0e8 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 4 Mar 2025 17:42:10 +0000 Subject: [PATCH 24/29] Remove quota disabling, it didn't have much of an effect. --- packages/pro | 2 +- .../server/src/api/routes/tests/row.spec.ts | 40 +++++++++---------- .../src/api/routes/tests/viewV2.spec.ts | 14 +++---- .../automations/tests/steps/openai.spec.ts | 12 +++--- 4 files changed, 31 insertions(+), 37 deletions(-) diff --git a/packages/pro b/packages/pro index bec35b4c27..b28dbd5492 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit bec35b4c27ec10fe31dc07f9d42d8bda426b8535 +Subproject commit b28dbd549284cf450be7f25ad85aadf614d08f0b diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index a916131a39..1ee0e168a1 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -177,28 +177,26 @@ if (descriptions.length) { } async function expectRowUsage(expected: number, f: () => Promise) { - return await quotas.withEnabled(async () => { - const before = await getRowUsage() - await f() - const after = await getRowUsage() - const usage = after - before + const before = await getRowUsage() + await f() + const after = await getRowUsage() + const usage = after - before - // Because our quota tracking is not perfect, we allow a 10% margin of - // error. This is to account for the fact that parallel writes can - // result in some quota updates getting lost. We don't have any need - // to solve this right now, so we just allow for some error. - if (expected === 0) { - expect(usage).toEqual(0) - return - } - if (usage < 0) { - expect(usage).toBeGreaterThan(expected * 1.1) - expect(usage).toBeLessThan(expected * 0.9) - } else { - expect(usage).toBeGreaterThan(expected * 0.9) - expect(usage).toBeLessThan(expected * 1.1) - } - }) + // Because our quota tracking is not perfect, we allow a 10% margin of + // error. This is to account for the fact that parallel writes can + // result in some quota updates getting lost. We don't have any need + // to solve this right now, so we just allow for some error. + if (expected === 0) { + expect(usage).toEqual(0) + return + } + if (usage < 0) { + expect(usage).toBeGreaterThan(expected * 1.1) + expect(usage).toBeLessThan(expected * 0.9) + } else { + expect(usage).toBeGreaterThan(expected * 0.9) + expect(usage).toBeLessThan(expected * 1.1) + } } const defaultRowFields = isInternal diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index 153a25aca7..ad41aa618c 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -2830,14 +2830,12 @@ if (descriptions.length) { expected: number, f: () => Promise ): Promise { - return await quotas.withEnabled(async () => { - const before = await getRowUsage() - const result = await f() - const after = await getRowUsage() - const usage = after - before - expect(usage).toBe(expected) - return result - }) + const before = await getRowUsage() + const result = await f() + const after = await getRowUsage() + const usage = after - before + expect(usage).toBe(expected) + return result } it("should be able to delete a row", async () => { diff --git a/packages/server/src/automations/tests/steps/openai.spec.ts b/packages/server/src/automations/tests/steps/openai.spec.ts index b370c9b71c..a06c633e5e 100644 --- a/packages/server/src/automations/tests/steps/openai.spec.ts +++ b/packages/server/src/automations/tests/steps/openai.spec.ts @@ -44,13 +44,11 @@ describe("test the openai action", () => { } const expectAIUsage = async (expected: number, f: () => Promise) => { - return await quotas.withEnabled(async () => { - const before = await getAIUsage() - const result = await f() - const after = await getAIUsage() - expect(after - before).toEqual(expected) - return result - }) + const before = await getAIUsage() + const result = await f() + const after = await getAIUsage() + expect(after - before).toEqual(expected) + return result } it("should be able to receive a response from ChatGPT given a prompt", async () => { From ad5249d3c462e30dc56f3b8b071495c2d5d78c9c Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Tue, 4 Mar 2025 17:49:18 +0000 Subject: [PATCH 25/29] Disable session recording in self-host, don't allow posthog to enable this. --- .../{PosthogClient.js => PosthogClient.ts} | 47 +++++++++---------- packages/builder/src/analytics/index.js | 4 ++ packages/builder/src/stores/portal/admin.ts | 3 ++ 3 files changed, 30 insertions(+), 24 deletions(-) rename packages/builder/src/analytics/{PosthogClient.js => PosthogClient.ts} (63%) diff --git a/packages/builder/src/analytics/PosthogClient.js b/packages/builder/src/analytics/PosthogClient.ts similarity index 63% rename from packages/builder/src/analytics/PosthogClient.js rename to packages/builder/src/analytics/PosthogClient.ts index f541b69b13..fe41989a66 100644 --- a/packages/builder/src/analytics/PosthogClient.js +++ b/packages/builder/src/analytics/PosthogClient.ts @@ -1,9 +1,12 @@ import posthog from "posthog-js" -import { Events } from "./constants" export default class PosthogClient { - constructor(token) { + token: string + initialised: boolean + + constructor(token: string) { this.token = token + this.initialised = false } init() { @@ -12,6 +15,8 @@ export default class PosthogClient { posthog.init(this.token, { autocapture: false, capture_pageview: false, + // disable by default + disable_session_recording: true, }) posthog.set_config({ persistence: "cookie" }) @@ -22,7 +27,7 @@ export default class PosthogClient { * Set the posthog context to the current user * @param {String} id - unique user id */ - identify(id) { + identify(id: string) { if (!this.initialised) return posthog.identify(id) @@ -32,7 +37,7 @@ export default class PosthogClient { * Update user metadata associated with current user in posthog * @param {Object} meta - user fields */ - updateUser(meta) { + updateUser(meta: Record) { if (!this.initialised) return posthog.people.set(meta) @@ -43,28 +48,22 @@ export default class PosthogClient { * @param {String} event - event identifier * @param {Object} props - properties for the event */ - captureEvent(eventName, props) { - if (!this.initialised) return - - props.sourceApp = "builder" - posthog.capture(eventName, props) - } - - /** - * Submit NPS feedback to posthog. - * @param {Object} values - NPS Values - */ - npsFeedback(values) { - if (!this.initialised) return - - localStorage.setItem(Events.NPS.SUBMITTED, Date.now()) - - const prefixedFeedback = {} - for (let key in values) { - prefixedFeedback[`feedback_${key}`] = values[key] + captureEvent(event: string, props: Record) { + if (!this.initialised) { + return } - posthog.capture(Events.NPS.SUBMITTED, prefixedFeedback) + props.sourceApp = "builder" + posthog.capture(event, props) + } + + enableSessionRecording() { + if (!this.initialised) { + return + } + posthog.set_config({ + disable_session_recording: false, + }) } /** diff --git a/packages/builder/src/analytics/index.js b/packages/builder/src/analytics/index.js index aa83f3c7ab..12bd548e9b 100644 --- a/packages/builder/src/analytics/index.js +++ b/packages/builder/src/analytics/index.js @@ -31,6 +31,10 @@ class AnalyticsHub { posthog.captureEvent(eventName, props) } + enableSessionRecording() { + posthog.enableSessionRecording() + } + async logout() { posthog.logout() } diff --git a/packages/builder/src/stores/portal/admin.ts b/packages/builder/src/stores/portal/admin.ts index 90e3a5cdc9..6ac8b00b73 100644 --- a/packages/builder/src/stores/portal/admin.ts +++ b/packages/builder/src/stores/portal/admin.ts @@ -8,6 +8,7 @@ import { SystemStatusResponse, } from "@budibase/types" import { BudiStore } from "../BudiStore" +import Analytics from "../../analytics" interface AdminState extends GetEnvironmentResponse { loaded: boolean @@ -33,6 +34,8 @@ export class AdminStore extends BudiStore { await this.getEnvironment() // enable system status checks in the cloud if (get(this.store).cloud) { + // in cloud allow this + Analytics.enableSessionRecording() await this.getSystemStatus() this.checkStatus() } From 4e5101363172ec701c3a151e4d5f2878a0967288 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 4 Mar 2025 17:55:33 +0000 Subject: [PATCH 26/29] Reinstate automation deleting. --- packages/server/src/automations/tests/steps/loop.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/server/src/automations/tests/steps/loop.spec.ts b/packages/server/src/automations/tests/steps/loop.spec.ts index a0a62bdae7..78ef3a279f 100644 --- a/packages/server/src/automations/tests/steps/loop.spec.ts +++ b/packages/server/src/automations/tests/steps/loop.spec.ts @@ -1,3 +1,4 @@ +import * as automation from "../../index" import { basicTable } from "../../../tests/utilities/structures" import { Table, @@ -17,14 +18,18 @@ describe("Attempt to run a basic loop automation", () => { beforeAll(async () => { await config.init() + await automation.init() }) beforeEach(async () => { + await config.api.automation.deleteAll() + table = await config.api.table.save(basicTable()) await config.api.row.save(table._id!, {}) }) afterAll(async () => { + await automation.shutdown() config.end() }) From a67ff23959870351ec0c8e39104a6c5aecddfd69 Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 4 Mar 2025 21:05:41 +0100 Subject: [PATCH 27/29] Format js bindings --- .../components/common/bindings/BindingSidePanel.svelte | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte index 251bf19b49..999c6914a1 100644 --- a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte @@ -246,15 +246,15 @@ {/if} {#if hoverTarget.code} - {#if mode === BindingMode.JavaScript} + {#if mode === BindingMode.Text || (mode === BindingMode.JavaScript && hoverTarget.type === "binding")} + +
{@html hoverTarget.code}
+ {:else} - {:else if mode === BindingMode.Text} - -
{@html hoverTarget.code}
{/if} {/if} From b7dc3d5b16bfc3ba1b8c05cdb9cc570e6563495a Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 4 Mar 2025 21:12:42 +0100 Subject: [PATCH 28/29] Fix description rendering --- .../src/components/common/bindings/BindingSidePanel.svelte | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte index 999c6914a1..513be45692 100644 --- a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte @@ -145,9 +145,11 @@ return } popoverAnchor = target + + const doc = new DOMParser().parseFromString(helper.description, "text/html") hoverTarget = { type: "helper", - description: helper.description, + description: doc.body.textContent || "", code: getHelperExample(helper, mode === BindingMode.JavaScript), } popover.show() @@ -241,8 +243,7 @@ > {#if hoverTarget.description}
- - {@html hoverTarget.description} + {hoverTarget.description}
{/if} {#if hoverTarget.code} From ded8272bc553515df06afd77779917ae88e64f0e Mon Sep 17 00:00:00 2001 From: Budibase Staging Release Bot <> Date: Wed, 5 Mar 2025 11:58:05 +0000 Subject: [PATCH 29/29] Bump version to 3.4.24 --- lerna.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lerna.json b/lerna.json index bd7c91a67b..10e7b8cdee 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "3.4.23", + "version": "3.4.24", "npmClient": "yarn", "concurrency": 20, "command": {