Coming from Bubble
Describes NodeGX 0.1.0.
If you have built in Bubble, you already know what you want to do β you just do not know what it is called here. This page is a phrasebook: 94 Bubble operators, written the way Bubble writes them, each with the NodeGX answer beside it. Search this page for the thing you would have typed in Bubble.
What this is notβ
π΄ It is not a migration tool. Nothing here reads a Bubble app, converts a workflow, or imports data. If you arrived looking for an importer, there is not one, and knowing that now is better than finding out after a weekend. What this is good for is the other problem: you are evaluating NodeGX, you know exactly what you want, and you cannot work out how to say it.
It is also honest about the gaps. 5 of the 94 operators have no NodeGX equivalent, and those rows say so in plain words and suggest what to do instead, rather than being quietly left out β a missing row and an unanswerable one look identical to a reader, and only one of them is fair.
Where this came fromβ
The operator list is not ours. It is a translation table the Noodl community built up over years, row by row, in Bubble's own vocabulary β which is the whole reason it is worth publishing: :ranked by and <-range-> are not phrases we would ever have invented, and they are what a person actually searches for.
β οΈ The code in it had never been run. Running all of it found 16 rows that are demonstrably wrong, in three different ways: 11 whose code produces the wrong answer when executed β an output quoted twice, three naming the wrong variable, a date helper that mutates its input and shifts it by six units at once, two that assign no output at all and one that does not compile; 3 whose stated ANSWER is wrong while the code is fine; and 2 that claim an answer they never shipped β one whose code cell is the literal text "See above", one that names a node and leaves the code empty.
Those are corrected here, and every correction quotes what the original actually did, under What the community table got wrong β the corrections are not taken on trust either: the checker re-runs the original code too, and a row accusing the table of being broken fails the build if the old code turns out to work. Two rows were demoted from "broken" to "right answer, wrong reason" exactly that way. Every one of the 77 code cells on this page is executed by npm run docs:bubble:check, against a fixture built from Bubble's own worked examples. None of it is here because it looked right.
One thing to know before you paste anythingβ
π΄ An Expression that ends in a // comment does not compile. The node wraps what you type as return ( your text ); on a single line, so a trailing line comment swallows the closing ); and the whole expression fails with Unexpected token '}' β which names nothing you wrote and does not mention comments. Three rows in the community table are written this way and none of them can ever have been run. Put the comment on its own line above the expression, or use /* β¦ */, or leave it out.
Anyβ
Operators Bubble allows on any value.
... is ...β
Bubble:
String1 is String2βFALSE
NodeGX: Expression
String1 === String2
Every free name in an Expression becomes an input port, so String1 and String2 are the two ports this node grows. Use ===, never ==.
... is not ...β
Bubble:
String1 is not String2βTRUE
NodeGX: Expression
String1 !== String2
... is emptyβ
Bubble:
String1 is emptyβFALSE
NodeGX: Function
const hasContent = Boolean(Inputs.String1);
Outputs.CurrentState = hasContent;
if (hasContent) {
Outputs.IsNotEmpty();
} else {
Outputs.IsEmpty();
}
One node answers both this row and the next. It publishes a boolean AND a signal per branch, so you can wire it into a Condition or straight into a flow. β οΈ Boolean("") is false and so is Boolean(0) β for a number input, "empty" and "zero" are the same answer here.
... is not emptyβ
Bubble:
String2 is not emptyβTRUE
NodeGX: Function
Outputs.IsNotEmpty = Boolean(Inputs.String2);
The corpusβs code cell for this row is the literal text "See above", and its Notes cell is "See above" too. A cell that cannot be pasted into a node is not an answer; row 98 answers both directions, and this row states the one-line form.
:formatted as JSON-safeβ
Bubble:
List1 :formatted as JSON-safeβ"pink, blue, purple, red, white, cherry\/deep-red"
NodeGX: Function
Outputs.OutputString = JSON.stringify(Inputs.InputString || '');
Outputs.Success();
The corpus wrapped the result in quotes a second time β Outputs.OutputString = "${escapedString}"`` on top of JSON.stringify, which already adds them. Executed, it returns ""pink, blueβ¦"", and an API called with that body gets a quoted string where it expected a value.
JSON.stringify is the whole answer: it escapes quotes, backslashes, newlines and control characters and returns the value already quoted. Do not add quotes around it.
Stringβ
Text. Most of these are one Expression; two of them NodeGX deliberately does not have.
is not inβ
Bubble:
String1 is not in List1βTRUE
NodeGX: Expression
!List1.includes(String1)
Blank in the corpus. There is no Array Contains node, so this is a one-liner rather than a wiring β which is also true of Bubbleβs own operator.
... containsβ
Bubble:
String2 contains String1βTRUE
NodeGX: Expression
String2.includes(String1)
The corpus wrote string1.includes(string2) β the operands the wrong way round against its own example, which asks whether String2 contains String1. Executed on the rowβs own values it answers FALSE where the row says TRUE.
Exact substring match, including case and spaces. For a word-aware search see ... contains keyword(s) below, which NodeGX does not have.
... doesn't containβ
Bubble:
String1 doesn't contain String2βTRUE
NodeGX: Expression
!String1.includes(String2)
The corpus wrote !string1.includes(string2) here, which β unlike the row above, written from the same template β happens to match its own example and returns the right answer. Executed, this one is correct.
... contains keyword(s)β
Bubble:
String2 contains keywords String1βTRUE
NodeGX: no equivalent.
NodeGX has no equivalent, and .includes() is NOT one. Bubbleβs operator breaks the argument into words, removes stop words like "the" and "a", and matches stems β its own documentation says searching cat hat returns the cat in the hat, and that pepp does not return peppers. Substring matching gets both of those backwards: it fails cat hat (those characters never appear together) and it wrongly matches pepp. What to do instead: for a real search, put the text in a database column and use your backendβs text search; for a small in-memory list, tokenise deliberately (text.toLowerCase().split(/\W+/)) and decide for yourself about stop words and stems β and write down which you chose.
Blank in the corpus, and it stays blank on purpose. Recorded as a product gap by COM-001 AC4.
... doesn't contain keyword(s)β
Bubble:
String2 doesn't contain keywords String1βFALSE
NodeGX: no equivalent.
The negation of the row above, and missing for the same reason. See ... contains keyword(s).
:capitalized wordsβ
Bubble:
String2 :capitalized wordsβ"Hello World"
NodeGX: Function
const input = Inputs.InputString || '';
Outputs.OutputString = input
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
Outputs.Success();
β οΈ Usually the wrong tool. Every node that draws text has a Case property under Text Style, and Capitalize there costs no node and no wire. Use this when the capitalised string itself has to travel somewhere β into a request body, or a record.
:uppercaseβ
Bubble:
String2 :uppercaseβ"HELLO WORLD"
NodeGX: Expression
String2.toUpperCase()
Same caveat as :capitalized words β Text Style βΈ Case does this without a node.
:lowercaseβ
Bubble:
String1 :lowercaseβ"hello"
NodeGX: Expression
String1.toLowerCase()
The corpus wrote string2.toLowerCase() under an example reading String1 :lowercase. Pasted as written, the node grows a string2 port and the String1 the row is about is never read.
:appendβ
Bubble:
String1 :append "World"β"Hello World"
NodeGX: String Format
Set Format to {String1} World. Every {tag} in that string becomes an input port, so this is a node with no code at all β and it stays readable when the sentence grows to three or four values, which a chain of + does not.
An Expression String1 + ' World' also works; String Format is the one to reach for once there is more than one hole.
:formatted as URL encodedβ
Bubble:
String2 :formatted as URL encodedβ"Hello%20world"
NodeGX: Function
const value = Inputs.String || '';
Outputs.EncodedString = encodeURIComponent(value);
Outputs.Success();
encodeURIComponent is right for one query-string value. encodeURI is for a whole URL and will leave &, = and ? alone β which is what you want for the URL and what you do not want for a value inside it.
:formatted as MD5 hashβ
Bubble:
String2 :formatted as MD5 hashβ"3e25960a79dbc69b674cd4ec67a72c62"
NodeGX: no equivalent.
The Hash node offers SHA-256, SHA-384 and SHA-512, and will never offer MD5. That is a decision rather than a gap: Hash runs on WebCrypto, which implements neither MD5 nor SHA-1 for digesting, and hand-rolling one would mean shipping a hash that is broken for every purpose it was ever used for. If your Bubble app stored MD5 fingerprints, they will not reproduce here and no setting will make them β plan a re-hash of the source data with SHA-256, not a translation.
:formatted as SHA1 Hashβ
Bubble:
String2 :formatted as SHA1 Hashβ"7b502c3a1f48c8609ae212cdfb639dee39673f5e"
NodeGX: no equivalent.
Same answer as MD5 above, for the same reason. Use SHA-256 on the Hash node. The corpusβs own note says this row was never a native Bubble operator either.
:trimmedβ
Bubble:
String2 :trimmedβ"Hello world"
NodeGX: Expression
String2.trim()
The fixture pads the sample value, because trim() on a string with no surrounding whitespace demonstrates nothing.
:number of charactersβ
Bubble:
String2 :number of charactersβ11
NodeGX: Expression
String2.length
β οΈ .length counts UTF-16 units, not characters a person would count: an emoji or an accented character built from a combining mark reads as 2. [...String2].length is closer to what a reader means.
:truncated toβ
Bubble:
String2 :truncated to 5β"Hello"
NodeGX: Expression
String2.slice(0, 5)
:truncated from end toβ
Bubble:
String2 :truncated from end to 5β"world"
NodeGX: Expression
String2.slice(-5)
:converted to numberβ
Bubble:
String3 :convert to numberβ99
NodeGX: Number
The Number node converts a value to a number on its own. In an Expression, Number(String3) does the same.
β οΈ Parse CSV and most request bodies hand you strings, and "42" === 42 is false β a comparison against a number stays false everywhere until something converts.
:split by...β
Bubble:
String4 :split by ", "β[ "pink", "blue", "purple", "red", "white", "cherry/deep-red" ]
NodeGX: Expression
String4.split(", ")
For CSV proper, use Parse CSV rather than splitting: a cell containing the delimiter, a quote or a newline is exactly what split gets wrong and what that node gets right.
:find/replace...β
Bubble:
String2 :find/replace "world" "Homer"β"Hello Homer"
NodeGX: Expression
String2.replace("world", "Homer")
β οΈ A string first argument replaces the FIRST occurrence only. For every occurrence use replaceAll, or a regular expression with the g flag.
:extract with Regexβ
Bubble:
String2 :extract with Regexβ"world"
NodeGX: Function
const value = Inputs.String2 || '';
const match = value.match(/\bw\w*/i);
if (match) {
Outputs.allMatches = match;
Outputs.FirstWord = match[0];
Outputs.Success();
} else {
Outputs.Failure();
}
Wire Failure β a regex that does not match is the ordinary case, not the exceptional one, and without that wire the chain stops with no explanation.
Integerβ
Numbers. Expression pre-defines min, max, round, floor, ceil, abs, sqrt, pow, log, exp, pi, random, sin, cos and tan, so you write floor(x) rather than Math.floor(x).
:formatted as ... (currency)β
Bubble:
Int1 :formatted as currencyβ"$3.33"
NodeGX: Function
const amount = Number(Inputs.Amount) || 0;
Outputs.FormattedAmount = new Intl.NumberFormat(Inputs.Locale || 'en-US', {
style: 'currency',
currency: Inputs.Currency || 'USD'
}).format(amount);
Outputs.Success();
The corpus hard-coded the dollar sign, and executed on this rowβs own example it returns $3.33 β exactly what the row says. It is not wrong here; it is wrong everywhere else. It gives $1234.5 where a German reader expects 1.234,50 β¬, and toFixed(2) is wrong for JPY, which has no decimal places at all.
Intl.NumberFormat is built into both runtimes and needs no dependency. It places the symbol, picks the separators and rounds to the currencyβs own number of decimals β JPY has none, and toFixed(2) gets that wrong too.
:formatted as ... (decimal places)β
Bubble:
Int1 :formatted as 2 decimal placesβ"3.33"
NodeGX: Expression
Int1.toFixed(2)
β οΈ toFixed returns a string. Feed it to a Text and it draws correctly; compare it to a number and the comparison is false.
< > β₯ β€ + - / *β
Bubble:
Int1 * Int2β29997
NodeGX: Expression
Int1 * Int2
All of Bubbleβs arithmetic and comparison operators are just JavaScript here. Expression also pre-defines min, max, round, floor, ceil, abs, sqrt, pow, log, exp, pi, random, sin, cos and tan, so you write floor(x) and not Math.floor(x).
:rounded toβ
Bubble:
Int1 :rounded to 0β3
NodeGX: Expression
round(Int1)
The corpus wrote int1.toFixed(0), which returns the STRING "3" where the row says the number 3. round is pre-defined in an Expression and returns a number.
For a number of decimal places rather than a whole number, round(Int1 * 100) / 100 keeps it numeric; toFixed(2) is the one that turns it into text.
:floorβ
Bubble:
Int1 :floorβ3
NodeGX: Expression
floor(Int1)
floor is pre-defined by the Expression node β this is not shorthand for Math.floor, it is the name the node gives you.
:ceilingβ
Bubble:
Int1 :ceilingβ4
NodeGX: Expression
ceil(Int1)
... ^ ...β
Bubble:
Int1 ^ 2β9.999
NodeGX: Expression
Int1 ** 2
The corpusβs stated RESULT is wrong, not its code. Int1 is 3.333 and the row says Int1 ^ 2 is 9.999 β that is 3.333 Γ 3. The square is 11.108889.
** is exponentiation; pow(Int1, 2) is the same thing and is pre-defined. β οΈ The fixture on this row expects 11.108889000000001, and that is not a typo β it is what IEEE-754 doubles actually return for 3.333 ** 2, in this runtime and in Bubbleβs. It is here rather than rounded away because money and totals hit it constantly: never compare two computed floats with ===, compare abs(a - b) < 1e-9, and round only at the point you DISPLAY the number. β οΈ The fixture on this row expects 11.108889000000001, and that is not a typo β it is what IEEE-754 doubles actually return for 3.333 ** 2, in this runtime and in Bubbleβs. It is here rather than rounded away because money and totals hit it constantly: never compare two computed floats with ===, compare abs(a - b) < 1e-9, and round only at the point you DISPLAY the number.
<- range ->β
Bubble:
Int1 <-range-> Int2β[3.333, 9000]
NodeGX: Expression
[Int1, Int2]
π΄ Bubble has a range TYPE; NodeGX does not. A range here is a two-element array you make yourself, which is why the range operators below are all short expressions over [start, end] rather than node ports. Nothing enforces that start β€ end β see the warning under overlaps with.
Integer rangeβ
A pair of numbers. π΄ Bubble has a range TYPE and NodeGX does not β a range here is a two-element array you build yourself, which is why every row in this group is a short expression over [start, end].
:minβ
Bubble:
IntRange1 :minβ1
NodeGX: Expression
min(...IntRange1)
The corpus used a Function node with a guard that throws on a malformed range. The expression is enough once the range is built by the graph rather than typed by a user; keep the guard if it comes from outside.
:maxβ
Bubble:
IntRange1 :maxβ20
NodeGX: Expression
max(...IntRange1)
:averageβ
Bubble:
IntRange1 :averageβ10.5
NodeGX: Expression
(IntRange1[0] + IntRange1[1]) / 2
The midpoint of the two ends β not the mean of a list. For a list, see :average under List.
contains rangeβ
Bubble:
IntRange1 contains range IntRange2βTRUE
NodeGX: Expression
IntRange2[0] >= IntRange1[0] && IntRange2[1] <= IntRange1[1]
contains pointβ
Bubble:
IntRange1 contains point Int1βTRUE
NodeGX: Expression
Int1 >= IntRange1[0] && Int1 <= IntRange1[1]
is contained byβ
Bubble:
IntRange1 is contained by IntRange2βFALSE
NodeGX: Expression
IntRange1[0] >= IntRange2[0] && IntRange1[1] <= IntRange2[1]
contains range with the operands swapped β the same expression read the other way round.
overlaps withβ
Bubble:
IntRange1 overlaps with IntRange2βTRUE
NodeGX: Expression
IntRange1[0] <= IntRange2[1] && IntRange1[1] >= IntRange2[0]
π΄ Two intervals overlap exactly when each starts before the other ends β one identity that also answers contains point, is contained by, is after and is before. β οΈ It assumes each range is the right way round: give it [20, 1] and it answers "no overlap", which is the wrong answer to a malformed range rather than the right answer to a valid one. Validate the range before you ask.
is greaterβ
Bubble:
IntRange1 is greater than IntRange2ββ
NodeGX: Expression
IntRange1[0] > IntRange2[1]
Blank in the corpus, and its Example and Result cells are blank too β so the reading taken here is Bubbleβs: one range is greater than another when it starts after the other ends, with no overlap. [1,20] and [5,10] overlap, so this is false.
is greater (point)β
Bubble:
IntRange1 is greater than Int1ββ
NodeGX: Expression
IntRange1[0] > Int1
The whole range sits above the point. 3.333 is inside [1, 20], so this is false.
is smallerβ
Bubble:
IntRange1 is smaller than IntRange2ββ
NodeGX: Expression
IntRange1[1] < IntRange2[0]
is smaller (point)β
Bubble:
IntRange1 is smaller than Int1ββ
NodeGX: Expression
IntRange1[1] < Int1
Dateβ
Dates have real nodes rather than code: Now, Date Add, Date Compare, Date Difference, Date Parts and Date To String. Reach for those before an Expression.
:formatted as JSON-safeβ
Bubble:
Date1 :formatted as JSON-safeβ2023-07-06T12:43:14.649Z
NodeGX: Now
A date in a JSON body wants ISO-8601 UTC. The Now node publishes exactly that on its ISO output, and for any other date Date To String formats one. There is no code for this row.
Blank in the corpus. Date.prototype.toISOString() is the same string if you are already inside a Function node.
Current date & timeβ
Bubble:
Current date & timeβ15/08/2024
NodeGX: Now
The Now node publishes the same instant three ways β Date for the other date nodes, Timestamp in milliseconds, and ISO for a JSON body. β οΈ It is not a live clock: it reads the wall clock when it is created and again on every Read, so a card left open keeps the instant it was rendered with until something re-reads it. Pair it with a Timer if you want it to tick.
Blank in the corpus. In a cloud function this is the SERVERβs clock, which is the one you want for anything a client should not be able to lie about.
Worked example: bubble-is-this-date-overdue β in docs/node-catalog/examples/, and served to an assistant building for you.
formatted asβ
Bubble:
Date1 formatted as ddd dS mm-yy HH:MMβThu 6th 07-23 14:22
NodeGX: Date To String
Set Format String. The tokens are {dayName} {monthName} {year} {month} {date} {hours} {minutes} {seconds} for the padded forms and {m} {d} {h} {min} {s} for the unpadded ones, plus {h12} and {ampm}. Locale translates the day and month NAMES and nothing else; Timezone decides which day it is.
Blank in the corpus β and the format string in its Example cell is Bubbleβs, not ours. π΄ Anything the node does not recognise is copied through unchanged, so pasting a moment.js or date-fns pattern like HH:mm:ss renders the literal text HH:mm:ss rather than a time. That failure is silent and looks like a broken node.
Worked example: logic-date-formatting β in docs/node-catalog/examples/, and served to an assistant building for you.
formatted as JSON safeβ
Bubble:
Date1 formatted as JSON safeβ2023-07-06T12:43:14.649Z
NodeGX: Now
The same answer as the Any-typed row above: Nowβs ISO output, or Date To String for a date you already hold. The corpus has this row twice and left both blank.
<-range->β
Bubble:
Date2 <-range-> Date1ββ
NodeGX: Date Difference
A date range is two dates, and the thing you actually want from it is usually its LENGTH β which is Date Difference, To minus From in the unit you choose. It is signed, so "days until" and "days since" are one node read two ways, and Absolute drops the sign.
β οΈ Fixed units are NOT rounded: 36 hours is 1.5 days. Round in an Expression and say which way you rounded.
Worked example: bubble-how-many-days-until β in docs/node-catalog/examples/, and served to an assistant building for you.
+(seconds) / +(minutes) / +(days) β¦β
Bubble:
Date1 +(days): 2β08/07/2023 14:22
NodeGX: Date Add
Set Amount and Unit. A negative amount subtracts. π΄ Months and years CLAMP: 31 January plus one month is 28 February, never 2 March, and 29 February plus one year is 28 February. Both answers are defensible; the classic date bug is not knowing which one you have.
The corpus answered this with a Function that adds seconds AND minutes AND hours AND days AND months AND years in one pass β six shifts where the row asks for one β and does it with date.setDate(...), which MUTATES the Date object it was handed. A Date arriving on an input is shared, so that write reaches whatever else is reading it.
Worked example: bubble-how-many-days-until β in docs/node-catalog/examples/, and served to an assistant building for you.
change seconds to / change minutes to β¦β
Bubble:
Date1 change minutes to 0β06/07/2023 14:00
NodeGX: Function
const source = new Date(Inputs.Date);
source.setMinutes(Number(Inputs.Minutes) || 0);
Outputs.NewDate = source;
Outputs.Success();
Two bugs in two lines. Outputs.newDate = date.setMinutes(0) publishes the RETURN of setMinutes, which is a millisecond timestamp and not a Date β so everything downstream expecting a date gets a number. And it mutated Inputs.date in place, for the same reason as the row above.
new Date(Inputs.Date) copies before writing. That one line is the whole difference between this and the corpusβs version.
>β
Bubble:
Date1 > Date2βTRUE
NodeGX: Expression
Date1 > Date2
β οΈ > and < on Dates work because JavaScript converts them to numbers β but === does NOT: two Dates for the same instant are different objects and are never ===. For equality, and for "the same day" rather than "the same millisecond", use Date Compare.
<β
Bubble:
Date1 < Date2βFALSE
NodeGX: Expression
Date1 < Date2
Without code: Date Compare
Worked example: bubble-is-this-date-overdue β in docs/node-catalog/examples/, and served to an assistant building for you.
- :formatted as seconds / minutes / years β¦β
Bubble:
Date1 - Date2 :formatted as yearsβ53
NodeGX: Date Difference
Set Unit to Years. Months and years are counted in whole calendar steps rather than divided by an average year, because a fractional month is not a quantity anyone can check.
The corpusβs Function computes differenceInYears into a local variable and then ends. It never assigns anything to Outputs, so executed, it produces nothing at all β the node runs, reports success, and every wire out of it stays empty.
Worked example: bubble-how-many-days-until β in docs/node-catalog/examples/, and served to an assistant building for you.
extract (minutes, day, date, month)β
Bubble:
Date1 extract monthβ7
NodeGX: Date Parts
Eleven outputs off one node: Year, Month (1β12, not JavaScriptβs 0β11), Date, Hours, Minutes, Seconds, Milliseconds, Day Of Week (0 is Sunday), Day Name, ISO Week and Timestamp.
The corpusβs Function reads Inputs.dates into a variable it calls date1, then answers with date1.getDay() β the day of the WEEK β under an example asking for the month. Two different mistakes in three lines, and getMonth() would still have been 6 rather than 7.
β οΈ All fields are read in the hostβs local zone, which on a server is whatever the containerβs TZ says. When the zone is part of the answer, format through Date To Stringβs Timezone input instead.
Worked example: bubble-round-a-date-down-to-the-month β in docs/node-catalog/examples/, and served to an assistant building for you.
rounded down to (second, minute, week β¦)β
Bubble:
Date1 rounded down to weekβ03/07/2023 00:00
NodeGX: Date To String
It depends what you want the rounded date FOR, and the two answers are different nodes. For a key to group by, use Date To String with a format like {year}-{month} β its {month} is zero-padded, so the keys sort correctly as text. To compare two dates rounded down, do not round at all: Date Compareβs Granularity truncates internally, so "same month" is one setting.
π΄ Do not build the key from Date Parts: its Month is the number 1β12, so September renders 2026-9, which sorts after 2026-10 as text and quietly scrambles a grouped list. β οΈ Rounding down to a WEEK has no node β and no agreed answer either: the corpusβs own note says Bubble used to round to Sunday and that Monday is the more sensible international choice. Pick one in an Expression and write down which.
Worked example: bubble-round-a-date-down-to-the-month β in docs/node-catalog/examples/, and served to an assistant building for you.
equals rounded down to (second, month β¦)β
Bubble:
Date1 equals rounded down to year Date2βFALSE
NodeGX: Date Compare
Set Granularity to Year (or Month, Day, Hour, Minute, Second). It answers Before, After and Same together, as booleans and as signals, and fires exactly one signal per comparison.
π΄ Left at the default Millisecond, two instants are essentially never Same β which is why an invoice "due today" reports as overdue one millisecond after midnight. Granularity is the whole point of the node.
Worked example: bubble-round-a-date-down-to-the-month β in docs/node-catalog/examples/, and served to an assistant building for you.
<-max->β
Bubble:
Date1 <-max-> Date2β06/07/2023 14:22
NodeGX: Expression
Date1 > Date2 ? Date1 : Date2
β οΈ max(Date1, Date2) returns a NUMBER β Math.max converts its arguments β so it is the wrong tool when you want a Date back. The conditional keeps the Date object.
<-min->β
Bubble:
Date1 <-min-> Date2β01/01/1970 00:01
NodeGX: Expression
Date1 < Date2 ? Date1 : Date2
Date Rangeβ
Two dates. The same missing type as Integer range β π΄ and all twelve of these rows were blank in the community table for that reason.
:startβ
Bubble:
DateRange1 :startβ01/01/2023 14:00
NodeGX: Expression
DateRange1[0]
π΄ NodeGX has no date-range type. A range is a two-element array, or two ports, whichever your graph already has β so :start is indexing rather than an operator. That is the reason all twelve Date Range rows were blank in the corpus.
:endβ
Bubble:
DateRange1 :endβ06/01/2023 14:00
NodeGX: Expression
DateRange1[1]
:centerβ
Bubble:
DateRange1 :centerβ03/01/2023 02:00
NodeGX: Expression
new Date((DateRange1[0].getTime() + DateRange1[1].getTime()) / 2)
Blank in the corpus, but its Notes cell describes the operation correctly β "averaging the start and end". β οΈ Its stated Result, 03/01/2023 02:00, is the midpoint of 1 Jan 14:00 and 5 Jan 14:00, not of the :end its own :end row gives (6 Jan 14:00). The midpoint of the range the corpus defines is 4 Jan 02:00.
contains rangeβ
Bubble:
DateRange1 contains range DateRange2βFALSE
NodeGX: Expression
DateRange2[0] >= DateRange1[0] && DateRange2[1] <= DateRange1[1]
Date comparison with >= and <= works directly β those operators convert a Date to a number. Only === does not.
contains pointβ
Bubble:
DateRange1 contains (point) Date1βFALSE
NodeGX: Expression
Date1 >= DateRange1[0] && Date1 <= DateRange1[1]
A point is a range of zero length, which is why this is the overlaps with identity with both ends of the second range set to the same instant.
is contained byβ
Bubble:
DateRange3 is contained by DateRange1βTRUE
NodeGX: Expression
DateRange3[0] >= DateRange1[0] && DateRange3[1] <= DateRange1[1]
overlaps withβ
Bubble:
DateRange1 overlaps with DateRange2βTRUE
NodeGX: Expression
DateRange1[0] <= DateRange2[1] && DateRange1[1] >= DateRange2[0]
Without code: Date Compare
π΄ This is the row worth reading if you read one. Two intervals overlap exactly when each starts before the other ends β one identity that also answers contains point, is contained by, is after and is before, which is half this section. The worked example builds it out of two Date Compare nodes instead of raw <=, which is what you want when the granularity matters (two bookings that touch at 10:00 are not a clash) or when an unparseable date must be heard rather than silently read as "no overlap".
Worked example: bubble-do-two-date-ranges-overlap β in docs/node-catalog/examples/, and served to an assistant building for you.
is afterβ
Bubble:
DateRange2 is after DateRange3βTRUE
NodeGX: Expression
DateRange2[0] > DateRange3[1]
Entirely after β the range starts after the other one has ended.
is after (point)β
Bubble:
DateRange2 is after (point) Date2βTRUE
NodeGX: Expression
DateRange2[0] > Date2
is beforeβ
Bubble:
DateRange2 is before DateRange3βFALSE
NodeGX: Expression
DateRange2[1] < DateRange3[0]
is before (point)β
Bubble:
DateRange2 is before (point) Date1βTRUE
NodeGX: Expression
DateRange2[1] < Date1
Listβ
Arrays. Bubble counts list items from 1 and JavaScript indexes from 0, which is the single most common migration bug in this table.
:unique elementsβ
Bubble:
List1 merged with List2 :unique elementsβpink, blue, purple, red, white, cherry/deep-red, apples, bananas, blueberries, yellow
NodeGX: Expression
[...new Set([...List1, ...List2])]
The corpus deduplicated by item.id β on a list of colour STRINGS, which have no .id. Every itemβs id reads undefined, the Set sees undefined once, and the function returns a single-element array where the row says ten. It is the right code for a list of records and the wrong code for the example beside it.
For a list of RECORDS, dedupe on the key you mean: [...new Map(items.map((i) => [i.id, i])).values()] keeps the LAST of each id, and reversing the array first keeps the first.
:countβ
Bubble:
List1 :countβ6
NodeGX: Expression
List1.length
contains (a value)β
Bubble:
List1 contains "apples"βFALSE
NodeGX: Expression
List1.includes(Value)
The corpus hard-coded the needle as the singular "apple" under an example asking about "apples". Executed, it still answers FALSE and so agrees with the row β it agrees for the wrong reason, and a literal buried inside the expression is a value nobody can wire.
includes says it more plainly than some when you are comparing whole values.
contains (a record)β
Bubble:
List3 contains { "name": "Frank" }βTRUE
NodeGX: Expression
List3.some((item) => item.name === Name)
π΄ List3.includes({name: "Frank"}) is always FALSE β two objects with the same contents are different objects. Comparing records means naming the field you are comparing, which is what some does here.
doesn't containβ
Bubble:
List3 doesnβt contain { "name": "John" }βTRUE
NodeGX: Expression
!List3.some((item) => item.name === Name)
The corpus answered this with a fifteen-line Function and a for loop. It worked; this is the same thing.
:first itemβ
Bubble:
List1 :first itemβpink
NodeGX: Expression
List1[0]
:last itemβ
Bubble:
List1 :last itemβcherry/deep-red
NodeGX: Expression
List1[List1.length - 1]
List1.at(-1) is the same thing and reads better; both runtimes have it.
:random itemβ
Bubble:
List1 :randomβwhite
NodeGX: Expression
List1[floor(random() * List1.length)]
random and floor are both pre-defined by the Expression node. β οΈ Math.random() is not cryptographically random β for a token or an id somebody might guess, use the Random Bytes or UUID node instead.
Worked example: bubble-mint-an-id-and-an-invite-token β in docs/node-catalog/examples/, and served to an assistant building for you.
:item #β
Bubble:
List1 :item #2βblue
NodeGX: Expression
List1[1]
The corpus wrote list.slice(1,2), which returns the one-element ARRAY ["blue"] where Bubbleβs :item #2 returns the item "blue". Wired into a Text it draws blue anyway, which is exactly why the mistake survives β it shows up later, when something compares it to a string.
β οΈ Bubble counts list items from 1 and JavaScript indexes from 0, so :item #2 is [1]. That off-by-one is the single most common migration bug in this table.
:items until #β
Bubble:
List1 :items until 2βpink, blue
NodeGX: Expression
List1.slice(0, 2)
β οΈ The corpusβs version of this row carries a trailing // comment and therefore does not compile β see the warning above.
:items from #β
Bubble:
List1 :items from 5βwhite, cherry/deep-red
NodeGX: Expression
List1.slice(4)
The same off-by-one as :item #: Bubbleβs "from 5" is index 4. β οΈ The corpusβs version carries a trailing // comment and does not compile either.
contains listβ
Bubble:
List1 contains list List2βFALSE
NodeGX: Expression
List2.every((value) => List1.includes(value))
β οΈ [].every(...) is true β an empty second list is "contained" by anything. That is usually what you want and occasionally a surprise.
:each item's [value]β
Bubble:
List3 :each itemβs locationβNew York, New York, Boston
NodeGX: Expression
List3.map((item) => item.location)
To render one component per item, do not map at all β that is the Repeater node with a template component.
:plus itemβ
Bubble:
List1 :plus item "mauve"βpink, blue, purple, red, white, cherry/deep-red, mauve
NodeGX: Expression
[...List1, Item]
The corpus wrote list.push("mauve") and assigned nothing to Outputs. Executed, the node produces no output at all β and it mutates the array it was handed, so whatever else holds that array is changed underneath it without a change being published.
π΄ Build a NEW array rather than pushing. A node that mutates its input in place changes a value nobody was told about, and the nodes watching that array do not re-render because, as far as they can tell, nothing was assigned.
:plus item (into an Array node)β
Bubble:
List1 :plus item "mauve"βpink, blue, purple, red, white, cherry/deep-red, mauve
NodeGX: Function
Noodl.Arrays.List1 = Noodl.Arrays.List1.concat(Inputs.Item);
Outputs.Success();
Noodl.Arrays.<id> reaches a named Array node by its id, and assigning a NEW array to it is what publishes the change β concat returns one, push does not. This is the corpusβs own answer and it is correct; it is here beside the expression because the two solve different problems: one computes a list, this one updates a list the graph already owns.
:minus itemβ
Bubble:
List1 :minus item "pink"βblue, purple, red, white, cherry/deep-red
NodeGX: Expression
List1.filter((value) => value !== Item)
β οΈ filter removes EVERY match; Bubbleβs :minus item removes the item. If duplicates matter, splice by index instead. The corpus answered this with an eighteen-line index-and-rebuild against Noodl.Arrays, which does remove only the first.
:minus listβ
Bubble:
List1 :minus list "pink", "blue"βpurple, red, white, cherry/deep-red
NodeGX: Expression
List1.filter((value) => !Remove.includes(value))
The corpus hard-coded both colours into the filter. Taking them on a port is the difference between a node you can reuse and a node you rewrite.
merged withβ
Bubble:
List1 merged with List2βpink, blue, purple, red, white, cherry/deep-red, apples, bananas, yellow, blueberries
NodeGX: Expression
[...List1, ...List2]
Duplicates survive. To drop them, see :unique elements.
intersects withβ
Bubble:
List1 intersects with List2βblue, red
NodeGX: Expression
List1.filter((value) => List2.includes(value))
The corpusβs stated RESULT is impossible against its own data. It says List1 intersects with List2 is blue, red, but the List2 it defines everywhere else is apples, bananas, yellow, blueberries β which shares nothing with List1. The fixture here uses a List2 that actually intersects, because a row whose example cannot produce its own answer teaches nothing.
For records rather than primitives, compare on a key β List1.filter((a) => List2.some((b) => a.id === b.id)). The corpus reached for a recursive deep-equal, which is slow and answers a different question than "the same record".
:group by...β
Bubble:
List3 grouped by "location" (aggregation: count)β2, 1
NodeGX: Function
const items = Inputs.Items || [];
const key = Inputs.Key;
const counts = new Map();
for (const item of items) {
counts.set(item[key], (counts.get(item[key]) || 0) + 1);
}
Outputs.Groups = [...counts].map(([value, count]) => ({ value, count }));
Outputs.Success();
This row is the tableβs quietest hole. Its "Noodl node" cell says Function / Script, so it does not count among the 32 rows blank in both columns β but its code cell is EMPTY. A reader scanning the node column sees an answered row; the answer was never written.
A Map keeps insertion order, so the groups come out in the order the values were first seen β which is what 2, 1 in Bubbleβs own result column means.
:filteredβ
Bubble:
List3 :filtered (name is not Frank)βJim, Jane
NodeGX: Expression
List3.filter((item) => item.name !== Name)
Without code: Array Filter
For a list you are about to render, the Array Filter node does this without code, and filters, sorts and limits in one pass. For a list that lives in the database, filter in the Query Records node instead β filtering after fetching means fetching everything first.
:sortedβ
Bubble:
List3 :sorted by name (descending)βJim, Jane, Frank
NodeGX: Expression
[...List3].sort((a, b) => b.name.localeCompare(a.name))
Without code: Array Filter
π΄ Blank in the corpus, but NodeGX does sort: Array Filter sorts in memory as part of its filter settings, and Query Records sorts at the query, which is where anything not already in memory should be sorted. β οΈ sort MUTATES β [...List3] copies first. And the default comparator sorts as TEXT, so [10, 9] sorts to [10, 9]; pass a comparator for numbers. localeCompare is the one that gets accented names right.
:ranked byβ
Bubble:
List3 :ranked by numerical similarity to JaneβJane, Frank, Jim
NodeGX: no equivalent.
NodeGX has no equivalent and none is invented here. Bubbleβs operator ranks a list by similarity to a value β its own example is "ranked by numerical similarity to Jane" β and nothing in the node library does that, nor is there an honest one-liner for it. What to do instead: if you can state the ranking as a NUMBER per item, you have a :sorted with a computed key and the row above answers it. If you cannot, the thing you want is a search index or a similarity function, and that is a decision to make deliberately rather than a translation.
Recorded as a product gap by COM-001 AC4, and left as one here rather than filled with something that looks close.
:format as textβ
Bubble:
List3 :format as text (per item: name) (delimiter: " & ")βFrank & Jim & Jane
NodeGX: Expression
List3.map((item) => item.name).join(Delimiter)
Without code: To CSV
π΄ For a sentence, this is right. For a FILE, it is the version that breaks β a name containing your delimiter, or a note containing a newline, silently produces a file with the wrong number of columns and no error anywhere. Use the To CSV node, which quotes any cell that needs it so the output reads back through Parse CSV unchanged.
Worked example: bubble-write-a-list-out-as-csv β in docs/node-catalog/examples/, and served to an assistant building for you.