Skip to main content

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);
What the community table got wrong

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();
What the community table got wrong

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)
What the community table got wrong

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()
What the community table got wrong

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();
What the community table did here

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)
What the community table got wrong

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
What the community table got wrong

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.

What the community table got wrong

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();
What the community table got wrong

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.

What the community table got wrong

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.

What the community table got wrong

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)
What the community table got wrong

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])]
What the community table got wrong

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)
What the community table did here

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]
What the community table got wrong

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]
What the community table got wrong

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))
What the community table got wrong

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();
What the community table got wrong

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.