@nodegx/mcp 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5114,7 +5114,7 @@
5114
5114
  "whenToUse": "Whenever a file handle needs to become a URL or filename. Note the cloudfile→image typecast lets a cloudfile feed an Image `src` directly; reach for Cloud File when you need the URL as a *string* or the file's name.",
5115
5115
  "ports": {
5116
5116
  "file": "The cloudfile value to unwrap.",
5117
- "url": "The file's public URL as a string.",
5117
+ "url": "The file's URL as a string, for an Image `src` or a link. It's the address the backend answered from, so don't save it into a text property. Save the file itself into a file-typed one.",
5118
5118
  "name": "The stored file's name."
5119
5119
  },
5120
5120
  "examples": [
@@ -7928,11 +7928,9 @@
7928
7928
  ],
7929
7929
  "dynamicPorts": {
7930
7930
  "mechanisms": [
7931
- "runtime-discovered",
7932
- "editor-adapter"
7931
+ "runtime-discovered"
7933
7932
  ],
7934
- "description": "Filter/sort input ports are generated by the editor from the visual query (QueryRecordsAdapter) and the selected class schema.",
7935
- "editorAdapter": "QueryRecordsAdapter"
7933
+ "description": "Every input is drawn from the parameters, so the static port list is empty: \"collectionName\" (the class), \"visualFilter\", \"visualSort\", \"storageEnableLimit\" + \"storageLimit\"/\"storageSkip\", \"storageEnableCount\", \"search\", \"realtime\", and the \"runOnChange-<input>\" boxes. A server-side filter is \"visualFilter\": {\"combinator\": \"and\", \"rules\": [{\"property\": \"<field>\", \"operator\": \"equal to\", \"input\": \"<name>\"}]}; each rule's \"input\" mints the input port \"qp-<name>\" a connection feeds, or the rule carries a literal \"value\" instead. Any other parameter name is stored and never read."
7936
7934
  },
7937
7935
  "parameterEncoding": {
7938
7936
  "known": true,
@@ -8057,8 +8055,9 @@
8057
8055
  "changedRecords": "All records of the last batch; for deletes, an array of primary-key strings.",
8058
8056
  "changedRecordId": "Primary key of the first affected record, across all three event types."
8059
8057
  },
8060
- "runtimeBehavior": "All *inputs* are runtime-determined (runtime-discovered + editor-adapter): the class choice, filter values, sort keys and paging inputs are generated from the project's database schema and the visual filter you build in the editor — e.g. each 'value' placeholder in a filter becomes its own input port, and actions like fetch ('storageFetch') are registered on demand. A generator cannot enumerate them statically; an authoring tool should set `collectionName` and filter parameters, and may wire signals to fetch-related inputs which the runtime registers when used.",
8058
+ "runtimeBehavior": "Every input is drawn from the parameters (the static list is empty). Set `collectionName` (the class). FILTER ON THE SERVER with `visualFilter`: {\"combinator\": \"and\", \"rules\": [{\"property\": \"<field>\", \"operator\": \"equal to\", \"input\": \"<name>\"}]} — each rule's `input` mints an input port `qp-<name>`; wire the value into it (e.g. a User node's `id` → `qp-ownerId`), or give the rule a literal `\"value\"` instead of `input`. Operators: \"equal to\", \"not equal to\", \"greater than\", \"greater than or equal to\", \"less than\", \"less than or equal to\", \"contain\" (case-insensitive), \"exist\", \"not exist\" (no value), \"points to\" (a pointer field equals a record id), and \"related to\" (with `relatedTo` and `relationProperty`). A rule may itself be a {\"combinator\": \"or\", \"rules\": [...]} group. The filter is compiled to the backend's `where`, so only matching rows leave the server. Sort: `visualSort`: [{\"property\": \"<field>\", \"order\": \"ascending\" | \"descending\"}]. Paging: `storageEnableLimit`: true with `storageLimit` and `storageSkip` (both ignored without it); `storageEnableCount`: true adds the `storageTotalCount` output. `storageFetch` (Do) is the signal that re-runs it. ⚠️ A rule whose `qp-` value has not arrived yet is DROPPED, not failed, so the query that runs when the graph is built returns every row the user may read: with a wired filter value set `\"runOnChange-collectionName\": false` and `\"runOnChange-querySettings\": false` and leave Do unwired, so the value's arrival is what runs the query (get_example cloud-query-own-rows). Any other parameter name (`querySettings`, `filter`, `where`…) is stored and never read, and the write is refused. Once the filter is opened in the editor's panel it is saved as {\"type\": \"and\", \"conditions\": [{\"field\", \"operator\": \"equalTo\", \"valueSource\": \"connected\", \"valuePortName\": \"qp-<name>\"}]}; the runtime reads both shapes.",
8061
8059
  "examples": [
8060
+ "cloud-query-own-rows",
8062
8061
  "repeater-query-records",
8063
8062
  "send-event-across-components",
8064
8063
  "cloud-record-live-refresh",
@@ -8068,12 +8067,14 @@
8068
8067
  "community-email-signup-validat"
8069
8068
  ],
8070
8069
  "patterns": [
8070
+ "A signed-in user's own rows: `visualFilter` rule {\"property\": \"owner\", \"operator\": \"equal to\", \"input\": \"owner\"}, the User node's `id` wired into `qp-owner`, and both fetch-at-build boxes off — the server answers with only that user's rows. `owner` is the backend's: it writes the creator into every record from their session, and a page never sends it. A field the page filled from the User id (an ownerId, a familyId) is the caller's word, never proof of whose a record is.",
8071
8071
  "`items` → Repeater `items`, with record properties delivered to the item component via Component Inputs: the canonical list page.",
8072
8072
  "Event Receiver `eventReceived` → fetch input: re-run a query on demand from anywhere in the app.",
8073
8073
  "Turn on Subscribe To Changes and wire `Changed` back into this node's own `Do`: the list stays current without polling, on every backend whose descriptor allows it — SSE on the built-in backend and PocketBase, a WebSocket on Directus.",
8074
8074
  "`Subscribed` → a status indicator's visibility, so people can see when live updates are actually running rather than assuming."
8075
8075
  ],
8076
8076
  "antiPatterns": [
8077
+ "Fetching every row and dropping the ones a user should not see in a Function or Filter Records: every row still reaches every browser. Filter on the server with `visualFilter` and a `qp-` port.",
8077
8078
  "Re-querying on a timer to feel 'live' — results already update from cloud events; explicit re-fetch is only needed when the server changed outside those events.",
8078
8079
  "Treating `Changed Record` as the full record on a delete — delete events carry only primary keys, so it is null and only `Changed Record Id` is populated.",
8079
8080
  "Polling with a Timer while Subscribe To Changes is available on this backend. Subscribe and re-fetch on `Changed` instead.",
@@ -8539,7 +8540,7 @@
8539
8540
  "name": "signal"
8540
8541
  },
8541
8542
  "isSignal": true,
8542
- "description": "Fires when the Id binds a record, and again when a Fetch finishes reading it — a bind has read nothing, so use Done to act on data that is really there"
8543
+ "description": "Fires when the Id binds a record already in memory, when a record the Id names is first read from the backend, and when a Fetch finishes reading it; use Done to act on the answer to a Fetch"
8543
8544
  },
8544
8545
  {
8545
8546
  "name": "id",
@@ -8632,14 +8633,14 @@
8632
8633
  "enrichment": {
8633
8634
  "typeName": "DbModel2",
8634
8635
  "summary": "Record: binds to one cloud database record by id and exposes each of its properties as an output that follows the record.",
8635
- "description": "DbModel2 (Record) points at a single record of a cloud database class and exposes one value output per property of that class (registered as `prop-<property>`), plus a `changed-<property>` signal per property. Setting `modelId` binds the node to the record in the app's local record store — properties already known locally (for example from a Query Records result) appear immediately, and `fetched` fires. Triggering `fetch` (Fetch, signal) loads the record fresh from the server by id; while `fetch` is connected, local change events no longer drive the outputs, so updates arrive only when you fetch. `changed` fires whenever the bound record changes from anywhere in the app (a Update Record elsewhere, a live query update). `fetched` and `changed` are value-level announcements and fire on binding as well as on a fetch; `done`/`failure`/`completed` are the outcome of a `fetch` invocation specifically, so wire those to sequence a chain and `fetched` to react to the data.",
8636
+ "description": "DbModel2 (Record) points at a single record of a cloud database class and exposes one value output per property of that class (registered as `prop-<property>`), plus a `changed-<property>` signal per property. Setting `modelId` binds the node to that record. A record already in memory (for example from a Query Records result on the page you came from) appears at once and `fetched` fires. A record nothing in memory holds yet (a page opened by its own URL: a reload, a bookmark, a shared link) is read from the backend first, and `fetched` fires when it arrives; until then the property outputs are empty. Triggering `fetch` (Fetch, signal) re-reads the record from the server by id. `changed` fires whenever the bound record changes from anywhere in the app (an Update Record elsewhere, a live query update). `fetched` and `changed` are value-level announcements; `done`/`failure`/`completed` are the outcome of a `fetch` invocation specifically, so wire those to sequence a chain and `fetched` to react to the data. A read the bind makes on its own reports a failure on `error`, not on `failure`.",
8636
8637
  "whenToUse": "Use it to read one record's properties — a detail page, or the current item inside a repeater (set `idSource` to 'From repeater'). It does not write: pair it with SetDbModelProperties to save edits. For lists of records use DbCollection2 (Query Records); for records from a non-cloud (BYOB) backend use noodl.byob.QueryData, whose results are plain objects, not records.",
8637
8638
  "ports": {
8638
8639
  "modelId": "Value input (connections only): the record id to bind to; a full record object is also accepted and dereferenced to its id.",
8639
8640
  "idSource": "Edit-only choice: 'Specify explicitly' uses `modelId`; 'From repeater' binds to the record driving the nearest enclosing For Each item.",
8640
- "fetch": "Signal; fetches the record from the server by the current `modelId`. Connecting it also switches the node to fetch-driven mode: outputs update on Fetch instead of on local record changes.",
8641
- "fetched": "Signal; fires after the record resolves — on bind when `fetch` is not connected, or after a server fetch completes.",
8642
- "changed": "Signal; fires when the bound record's data changes anywhere in the app (suppressed while `fetch` is connected).",
8641
+ "fetch": "Signal; re-reads the record from the server by the current `modelId`, replacing the copy held in memory. Not needed for a page opened by its own URL: binding an id nothing in memory holds already reads it.",
8642
+ "fetched": "Signal; fires when the record is there to read: on bind for a record already in memory, when the backend answers for one that was not, and after a Fetch completes.",
8643
+ "changed": "Signal; fires when the bound record's data changes anywhere in the app.",
8643
8644
  "id": "String value; the id of the currently bound record."
8644
8645
  },
8645
8646
  "runtimeBehavior": "Property ports are runtime-determined from the cloud database class selected via the `collectionName` parameter (itself a runtime-registered enum of the project's classes plus _User and _Role): each schema property becomes a value output `prop-<name>` (typed from the schema) and a signal output `changed-<name>`. Relation properties get no ports. An authoring tool should set `collectionName` in parameters and may reference `prop-<name>`/`changed-<name>` for properties it knows exist in the class schema.",
@@ -8651,7 +8652,7 @@
8651
8652
  "`prop-<name>` → a Text Input's `startValue`, edited text → Update Record `prop-<name>`: prefill an edit form and save it back."
8652
8653
  ],
8653
8654
  "antiPatterns": [
8654
- "Connecting `fetch` just to 'refresh' — while `fetch` is connected the node stops following local record changes, so only do it when you explicitly want server-fetch-driven updates.",
8655
+ "Reading the property outputs before `fetched` on a page that can be opened by its own URL — until the backend answers they are empty, and an Update Record fed from them then writes the empties back. Act on `fetched` (or a Fetch's `done`).",
8655
8656
  "Comparing a read-back Object field with `JSON.stringify` — an Object field does not keep the order of its names (a backend on PostgreSQL gives them back with shorter names first), so two equal objects can give different text. Compare them name by name, and sort the names yourself where a page shows them in an order."
8656
8657
  ],
8657
8658
  "relatedNodes": [
@@ -18525,12 +18526,14 @@
18525
18526
  "examples": [
18526
18527
  "function-compute-on-run",
18527
18528
  "community-csv-download",
18528
- "community-seo-meta-tag-setter"
18529
+ "community-seo-meta-tag-setter",
18530
+ "cloud-members-can-edit-my-records"
18529
18531
  ],
18530
18532
  "patterns": [
18531
18533
  "Async action: `run` from a click → `await fetch(...)` → set value outputs → `Outputs.Success()`; wire Success onward for sequencing.",
18532
18534
  "Transform between nodes: array in, reshaped array out, feeding a Repeater.",
18533
- "Shared helper: `exports.canStep = …` in `scripts/engine/rules.js`, `engine/rules.js` under Uses on each Function that needs it, and `Scripts.rules.canStep(tile)` in each body."
18535
+ "Shared helper: `exports.canStep = …` in `scripts/engine/rules.js`, `engine/rules.js` under Uses on each Function that needs it, and `Scripts.rules.canStep(tile)` in each body.",
18536
+ "Records from a script, in a cloud function: `await Noodl.Records.query('Note', { owner: id }, { plain: true })` (rows as stored, ids as strings), `await Noodl.Records.create('Note', { title, owner }, { acl })`, `await Noodl.Records.save(id, { title }, { className: 'Note', acl })`, `await Noodl.Records.delete(id, { className: 'Note' })` and `Noodl.Records.increment`. A cloud function runs as the system: it reads every record, may name `owner`, and creates where the collection's create rule is \"nobody\". `acl` is the record's whole ACL: `{ \"<userId>\": { read: true, write: true }, \"role:<name>\": { … }, \"*\": { … } }` (example `cloud-members-can-edit-my-records`)."
18534
18537
  ],
18535
18538
  "antiPatterns": [
18536
18539
  "Wiring to the bare script name — `toProperty: \"amount\"` for a script reading `Inputs.amount`. The port is `in-amount`; the bare name is only its display label. The door refuses it, and drawn by hand it fails on the canvas.",
@@ -21801,7 +21804,7 @@
21801
21804
  },
21802
21805
  "enrichment": {
21803
21806
  "typeName": "NewDbModelProperties",
21804
- "summary": "Create Record: inserts a record into a cloud database class from its property inputs when Do is triggered.",
21807
+ "summary": "Create Record: inserts a record into a cloud database class from its property inputs on Do, with optional access rules (ACL: users, roles).",
21805
21808
  "description": "NewDbModelProperties (Create Record) creates one record in the cloud database class chosen with the `collectionName` (Class) parameter. Each property of the class becomes a value input; when the `store` (Do) signal is triggered, the current input values are sent to the server as a new record. On success the node binds to the new record, outputs its `id`, and fires `done` (Done) followed by `completed`; on failure it fires `failure` with a message on `error`, and `completed` after that too. Optionally, `sourceObjectId` seeds the new record with the data of an existing local object (explicit property inputs win), and Access Control Rules restrict who can read/write the record (defaulting to the creating user when a rule is left unconfigured).",
21806
21809
  "whenToUse": "The write half of every 'add item' flow — forms, quick-add buttons, duplicating a record via `sourceObjectId`. Live Query Records nodes pick the new record up automatically, so no manual re-fetch is needed. To modify an existing record use SetDbModelProperties instead; for a non-cloud (BYOB) backend use noodl.byob.CreateRecord.",
21807
21810
  "ports": {
@@ -21810,21 +21813,24 @@
21810
21813
  "completed": "Signal; fires after every Do whatever the outcome — wire it to carry on regardless. It never replaces `failure`, which still fires and still carries its reason.",
21811
21814
  "id": "String value; the id of the created record — feed it to a Record node or navigation parameter.",
21812
21815
  "sourceObjectId": "Value input (connections only): id of a local object/record whose data seeds the new record before the property inputs are applied.",
21813
- "accessControl": "Edit-only list of access rules; each rule expands into its own Target/Read/Write inputs (see runtimeBehavior). With no rules the record gets the class default (public) permissions."
21816
+ "accessControl": "Edit-only list of access rules; each rule expands into its own Target/Read/Write inputs (see runtimeBehavior). With no rules, on the NodeGX backend's default (creator-owned) collections the record is the creator's alone: readable and changeable only by them. Rules you set REPLACE that, so keep one for the creator (target user, the User node's `id` wired into its User Id)."
21814
21817
  },
21815
- "runtimeBehavior": "Ports are runtime-determined: `collectionName` (Class) is registered as an edit-only enum of the project's classes plus _User/_Role, and each schema property of the chosen class becomes a value input `prop-<name>` typed from the schema. Each entry in the `accessControl` list additionally spawns `acl-<ruleId>-target` (user/everyone/role), then `acl-<ruleId>-userid` or `acl-<ruleId>-role`, plus `acl-<ruleId>-read` and `acl-<ruleId>-write` booleans. An authoring tool should set `collectionName` in parameters and drive `prop-<name>` inputs it knows from the class schema.",
21818
+ "runtimeBehavior": "Ports are runtime-determined: `collectionName` (Class) is registered as an edit-only enum of the project's classes plus _User/_Role, and each schema property of the chosen class becomes a value input `prop-<name>` typed from the schema. Each entry in the `accessControl` list additionally spawns `acl-<ruleId>-target` (user/everyone/role), then `acl-<ruleId>-userid` or `acl-<ruleId>-role`, plus `acl-<ruleId>-read` and `acl-<ruleId>-write` booleans. An authoring tool should set `collectionName` in parameters and drive `prop-<name>` inputs it knows from the class schema. What the rules store is the record's ACL, one key per rule: the user's id, `role:<name>` for a role rule (set `acl-<ruleId>-target` to \"role\" as a parameter, then wire or set `acl-<ruleId>-role` to the role's name without the `role:` prefix), or `*` for everyone; each key holds `{\"read\": true, \"write\": true}`. The backend lets a caller read or change the record only through a key it holds, after the collection's own find/get/update rule. Who joins a role is decided only in a cloud function (Add User To Role).",
21816
21819
  "examples": [
21817
21820
  "cloud-create-record-with-upload",
21818
21821
  "cloud-record-crud",
21819
21822
  "community-simple-audio-recorde",
21820
- "community-web-rtc-video-record"
21823
+ "community-web-rtc-video-record",
21824
+ "cloud-members-can-edit-my-records"
21821
21825
  ],
21822
21826
  "patterns": [
21823
21827
  "Text Input `text` → `prop-<name>`, Button `onClick` → `store`: the minimal create form.",
21824
- "`done` → a navigation or Event Sender, with `id` as payload: jump straight to the new record's detail page."
21828
+ "`done` → a navigation or Event Sender, with `id` as payload: jump straight to the new record's detail page.",
21829
+ "Records several people may all change: a rule for the creator and a role rule (`members-of-<ownerId>`), the role joined only in a cloud function that reads an invitation's `owner` (example `cloud-members-can-edit-my-records`). Where only a function should create the records, it creates them as the system and the collection's create rule is \"nobody\". A record it creates while a signed-in person's call runs is that person's (P112-OWN026-OPENSTAT); to share one, the function passes `acl` with `'*'`."
21825
21830
  ],
21826
21831
  "antiPatterns": [
21827
- "Re-fetching a Query Records node after `done` — live queries already receive the new record; only re-fetch when the query is not live."
21832
+ "Re-fetching a Query Records node after `done` — live queries already receive the new record; only re-fetch when the query is not live.",
21833
+ "Wiring the User node's `id` into a field of your own (an ownerId, a familyId, a teamId) to say whose the record is. The backend already writes the creator into `owner` from the session and drops an `owner` the page sends; a field the page fills is whatever the caller sent, so anyone signed in can name another id. Read `owner` wherever it is decided who sees the record (example `cloud-share-my-records-with-someone`)."
21828
21834
  ],
21829
21835
  "relatedNodes": [
21830
21836
  "SetDbModelProperties",
@@ -31286,6 +31292,17 @@
31286
31292
  "description": "Read and write rules stored on the record as it is written, each rule adding its own Target, Read and Write ports; the NodeGX backend enforces them on every query and every realtime event, and backends with no per-record access control ignore them",
31287
31293
  "index": 1000
31288
31294
  },
31295
+ {
31296
+ "name": "addTo",
31297
+ "displayName": "Add To",
31298
+ "group": "General",
31299
+ "plug": "input",
31300
+ "type": {
31301
+ "name": "string"
31302
+ },
31303
+ "isSignal": false,
31304
+ "description": "Property names, comma-separated (e.g. points). For these, the value wired in is an amount added to what the backend holds, in one step on the backend, instead of replacing it: two devices adding at once both count. Wire the amount (1, -1, 5). The record then shows the backend's total. Leave empty to write every value as given."
31305
+ },
31289
31306
  {
31290
31307
  "name": "idSource",
31291
31308
  "displayName": "Id Source",
@@ -31575,6 +31592,9 @@
31575
31592
  "onlyIfUnchanged": [
31576
31593
  "always: read by the next save — the fields the backend must still hold as this record holds them, or the save is refused"
31577
31594
  ],
31595
+ "addTo": [
31596
+ "always: read by the next Do — the fields whose value is an amount the backend adds, not a value it stores"
31597
+ ],
31578
31598
  "accessControl": [
31579
31599
  "always: read by the next call — the access rules sent with the record"
31580
31600
  ],
@@ -31586,9 +31606,9 @@
31586
31606
  },
31587
31607
  "enrichment": {
31588
31608
  "typeName": "SetDbModelProperties",
31589
- "summary": "Update Record: writes property values to an existing cloud database record when Do is triggered.",
31609
+ "summary": "Update Record: writes property values to an existing cloud database record on Do; access rules sent with it replace its ACL.",
31590
31610
  "description": "SetDbModelProperties (Update Record) updates the record identified by `modelId` in the class chosen with the `collectionName` (Class) parameter. Each class property becomes a value input; triggering `store` (Do) applies the connected inputs to the local record and saves them to the server, firing `done` (Done) on confirmation or `failure` with `error` on problems, and `completed` after either. `storeType` (Store to) can switch the node to 'Local only', which updates the in-memory record without any server call — useful for optimistic UI or scratch state. By default only the properties you actually supply are sent (`storeProperties` = 'Only specified'); 'All' sends the record's entire current data. Because the shared local record is updated, every bound Record node and live query in the app sees the change immediately.",
31591
- "whenToUse": "The save half of every edit form, and the way to flip a single field ('mark done') from a list row. To create a record use NewDbModelProperties; to remove one use DeleteDbModelProperties; for BYOB backends use noodl.byob.UpdateRecord.",
31611
+ "whenToUse": "The save half of every edit form, and the way to flip a single field ('mark done') from a list row. To create a record use NewDbModelProperties; to remove one use DeleteDbModelProperties; for BYOB backends use noodl.byob.UpdateRecord. For a count, score, balance or progress, set `addTo` and wire the change, not the new total.",
31592
31612
  "ports": {
31593
31613
  "store": "Signal (displayed as Do); saves to cloud (default) or applies locally, depending on `storeType`.",
31594
31614
  "done": "Signal (displayed as Done); fires after the server confirms the save, or immediately after a local-only store.",
@@ -31597,22 +31617,28 @@
31597
31617
  "idSource": "Edit-only choice: 'Specify explicitly' uses `modelId`; 'From repeater' targets the record driving the nearest enclosing For Each item.",
31598
31618
  "storeProperties": "Edit-only choice ('Only specified' / 'All'): whether to send just the supplied property inputs or the record's whole data. Shown while `storeType` is cloud.",
31599
31619
  "storeType": "Edit-only choice ('Cloud and local' / 'Local only'): whether the save reaches the server or only the in-memory record.",
31600
- "accessControl": "Edit-only list of access rules applied with the save; each rule expands into its own Target/Read/Write inputs.",
31601
- "onlyIfUnchanged": "Edit-only text (displayed as Only If Unchanged): property names, comma-separated, usually `version`. The update applies only if those properties still hold the values the record had when it was read (taken before this node's inputs are applied), checked by the backend inside the UPDATE itself. If someone else wrote the record in between, nothing is written, `failure` fires, `error` says so, and the local record goes back to what it held (the backend answered 409 with reason `precondition-failed`). To retry, FETCH the record again first: retrying with the same values fails the same way. NodeGX backends only; other backends refuse it. Empty: always write."
31620
+ "accessControl": "Edit-only list of access rules applied with the save; each rule expands into its own Target/Read/Write inputs. Rules sent with an update replace the record's whole ACL (keys and shape as on Create Record); an update without rules leaves the ACL as it was.",
31621
+ "onlyIfUnchanged": "Edit-only text (displayed as Only If Unchanged): property names, comma-separated, usually `version`. The update applies only if those properties still hold the values the record had when it was read (taken before this node's inputs are applied), checked by the backend inside the UPDATE itself. If someone else wrote the record in between, nothing is written, `failure` fires, `error` says so, and the local record goes back to what it held (the backend answered 409 with reason `precondition-failed`). To retry, FETCH the record again first: retrying with the same values fails the same way. NodeGX backends only; other backends refuse it. Empty: always write.",
31622
+ "addTo": "Edit-only text (displayed as Add To): property names, comma-separated. For these, the value wired into `prop-<name>` is an AMOUNT (1, -1, 5) the backend adds to what it holds, in one step, instead of a value that replaces it, so two devices or tabs adding at once both count. The record then shows the backend's total. The other properties are saved first, so `onlyIfUnchanged` still guards them and a refused save adds nothing. A value that is not a number fails the Do and says so. 'Local only' adds the amount to the record held in memory."
31602
31623
  },
31603
31624
  "runtimeBehavior": "Ports are runtime-determined: `collectionName` (Class) is registered as an edit-only enum of the project's classes plus _User/_Role, and each schema property of the chosen class becomes a value input `prop-<name>` typed from the schema. Access Control rules spawn `acl-<ruleId>-*` inputs like on Create Record. An authoring tool should set `collectionName` in parameters and drive the `prop-<name>` inputs it intends to change.",
31604
31625
  "examples": [
31605
31626
  "cloud-edit-record",
31606
- "cloud-record-crud"
31627
+ "cloud-record-crud",
31628
+ "cloud-shared-counter",
31629
+ "cloud-members-can-edit-my-records"
31607
31630
  ],
31608
31631
  "patterns": [
31632
+ "A count, score, balance or progress: `addTo` = `points`, `prop-points` = 1 (or the change a Function works out), Button `onClick` → `store`. No script, no read of the old value: the backend adds it, and two devices pressing together both count (example `cloud-shared-counter`).",
31609
31633
  "DbModel2 `prop-<name>` → Text Input `startValue`, edited `text` → `prop-<name>`, Button `onClick` → `store`: prefill, edit, save.",
31610
31634
  "'From repeater' `idSource` inside a list row: one node updates whichever record the row represents.",
31611
31635
  "Two people editing one record: bump a `version` property on every save and set `onlyIfUnchanged` to `version`. The second save to land gets `failure` instead of silently replacing the first; on `failure`, fetch the record again (DbModel2) and let the person re-apply their change."
31612
31636
  ],
31613
31637
  "antiPatterns": [
31614
31638
  "Setting `storeProperties` to 'All' to 'be safe' — it uploads the record's entire local data and can overwrite fields other users changed; send only what the form edits.",
31615
- "Retrying a `failure` from `onlyIfUnchanged` by pressing Do again without fetching the record first: the precondition is taken from the record as it was read, so the retry fails the same way until the record is re-read."
31639
+ "Retrying a `failure` from `onlyIfUnchanged` by pressing Do again without fetching the record first: the precondition is taken from the record as it was read, so the retry fails the same way until the record is re-read.",
31640
+ "Saving a count, score, balance or progress that several tabs or devices change from the value the page holds (read it, add one in a Function, wire the new total into `prop-<name>`, Do): of two such saves the later erases the earlier. Set `addTo` to that property and wire the CHANGE (1, -1) instead: one node, no script (example `cloud-shared-counter`). For a value that is not a count, guard the whole-value save with `onlyIfUnchanged`.",
31641
+ "Wiring two controls (a Button's Click and a Text Input's On Enter) to two DIFFERENT nodes that each lead to this Do. A press that starts a save is taken once (the control's `repeatWhileSaving` is off by default), and controls wired to the SAME node share that guard; wired to different nodes, one press of each still saves twice. Wire both into the same node (the same Function's Run, or this Do)."
31616
31642
  ],
31617
31643
  "relatedNodes": [
31618
31644
  "DbModel2",
@@ -37728,7 +37754,8 @@
37728
37754
  ],
37729
37755
  "antiPatterns": [
37730
37756
  "Treating `cloudFile` as set before `done` fired — sequence record writes on the Done signal.",
37731
- "Setting `private: true` and then binding `cloudFile.url` straight to an Image/link — a private file's plain url 403s for anyone but the uploader; sign it first."
37757
+ "Setting `private: true` and then binding `cloudFile.url` straight to an Image/link — a private file's plain url 403s for anyone but the uploader; sign it first.",
37758
+ "Saving the url of `cloudFile` (or Cloud File's `url`) into a text property. That url names the address the backend was reached at during the upload, which is `127.0.0.1` on your own machine, so it stops working once the app runs on a host. Save the `cloudFile` handle into a file-typed property instead. The NodeGX backend keeps the file's name and fills its address in on every read."
37732
37759
  ],
37733
37760
  "relatedNodes": [
37734
37761
  "Open File Picker",
@@ -50480,6 +50507,18 @@
50480
50507
  "description": "How the element is placed: In Layout follows its siblings, Absolute ignores them, Sticky pins to the parent edge on overflow, Fixed stays put and takes no space",
50481
50508
  "index": 11
50482
50509
  },
50510
+ {
50511
+ "name": "repeatWhileSaving",
50512
+ "displayName": "Repeat While Saving",
50513
+ "group": "General",
50514
+ "plug": "input",
50515
+ "type": {
50516
+ "name": "boolean"
50517
+ },
50518
+ "isSignal": false,
50519
+ "default": false,
50520
+ "description": "Off (the default): when a click starts a save, the next click is ignored until the backend has answered, and for at least half a second, so a double press saves once. Controls wired to the same node share this. On: every click fires, for a control that saves each quick tap"
50521
+ },
50483
50522
  {
50484
50523
  "name": "scrollIntoView",
50485
50524
  "displayName": "Scroll Into View",
@@ -51279,9 +51318,10 @@
51279
51318
  "ports": {
51280
51319
  "label": "The button caption string.",
51281
51320
  "onClick": "Signal; the click event — wire this to the action (Condition `eval`, Navigate `navigate`, Send Event `sendEvent`, …).",
51282
- "enabled": "Boolean level input; false renders the disabled state and suppresses `onClick`. The current value is also mirrored as an output of the same name."
51321
+ "enabled": "Boolean level input; false renders the disabled state and suppresses `onClick`. The current value is also mirrored as an output of the same name.",
51322
+ "repeatWhileSaving": "Boolean (displayed as Repeat While Saving), default off. Off: when a click starts a backend save (a Record node's Do, a `Noodl.Records` call made before its first await), the next `onClick` from this control, or from any control wired to the same node, is ignored until the backend has answered and for at least half a second, so a double press saves once. A press that starts no save is never held. On: every `onClick` fires (a stepper that saves each tap)."
51283
51323
  },
51284
- "runtimeBehavior": "Declared-port-groups: icon ports appear when an icon source is chosen, and each visual state (hover, pressed, disabled, focused) contributes state-scoped styling ports. The catalog list is the superset; the editor shows the subset matching current parameters.",
51324
+ "runtimeBehavior": "Declared-port-groups: icon ports appear when an icon source is chosen, and each visual state (hover, pressed, disabled, focused) contributes state-scoped styling ports. The catalog list is the superset; the editor shows the subset matching current parameters. A `onClick` that starts a backend save is taken once: a second one is ignored while that save is waiting and for half a second after the press (`repeatWhileSaving` turns this off). There is no need to wire a busy flag back to `enabled` for a double press.",
51285
51325
  "examples": [
51286
51326
  "validate-before-submit",
51287
51327
  "navigate-on-click",
@@ -56083,11 +56123,12 @@
56083
56123
  ],
56084
56124
  "items": [
56085
56125
  "nothing handed (undefined): the list it has stays",
56126
+ "the value is still the placed node's seed and the list does not offer it: the list is kept, and the value is cleared to '' and sent",
56086
56127
  "otherwise: the list is kept as handed and drawn at the next paint; the value stays, so a list handed again keeps the pick"
56087
56128
  ],
56088
56129
  "value": [
56089
56130
  "null: ignored",
56090
- "the value it already holds, as text: nothing",
56131
+ "the value it already holds, as text: nothing (it is no longer the seed)",
56091
56132
  "a change: kept as text, Value follows, and the list selects it at the next paint if it names an option — Changed does not fire (only a person's pick fires it)"
56092
56133
  ],
56093
56134
  "mounted": [
@@ -62677,6 +62718,18 @@
62677
62718
  "description": "How the element is placed: In Layout follows its siblings, Absolute ignores them, Sticky pins to the parent edge on overflow, Fixed stays put and takes no space",
62678
62719
  "index": 11
62679
62720
  },
62721
+ {
62722
+ "name": "repeatWhileSaving",
62723
+ "displayName": "Repeat While Saving",
62724
+ "group": "General",
62725
+ "plug": "input",
62726
+ "type": {
62727
+ "name": "boolean"
62728
+ },
62729
+ "isSignal": false,
62730
+ "default": false,
62731
+ "description": "Off (the default): when Enter starts a save, the next Enter is ignored until the backend has answered, and for at least half a second, so a double press saves once. Controls wired to the same node share this. On: every Enter fires, for a control that saves each quick tap"
62732
+ },
62680
62733
  {
62681
62734
  "name": "runOnChange-startValue",
62682
62735
  "displayName": "Value",
@@ -63650,9 +63703,10 @@
63650
63703
  "set": "Signal; applies the pending `startValue` to the field.",
63651
63704
  "clear": "Signal; empties the field.",
63652
63705
  "focus": "Signal; gives the field keyboard focus (`blur` removes it).",
63653
- "type": "Enum: text, textArea, email, number, password or url. textArea renders a multi-line editor."
63706
+ "type": "Enum: text, textArea, email, number, password or url. textArea renders a multi-line editor.",
63707
+ "repeatWhileSaving": "Boolean (displayed as Repeat While Saving), default off. Off: when Enter starts a backend save (a Record node's Do, a `Noodl.Records` call made before its first await), the next `onEnter` from this control, or from any control wired to the same node, is ignored until the backend has answered and for at least half a second, so a double press saves once. A press that starts no save is never held. On: every `onEnter` fires (a stepper that saves each tap)."
63654
63708
  },
63655
- "runtimeBehavior": "Declared-port-groups: `width`/`height` appear per `sizeMode`, icon ports per `useIcon`/`iconSourceType`, label styling ports when `useLabel` is true, and border/shadow ports per border-style and `boxShadowEnabled` settings. The catalog list is the superset; the editor shows the subset matching current parameters.",
63709
+ "runtimeBehavior": "Declared-port-groups: `width`/`height` appear per `sizeMode`, icon ports per `useIcon`/`iconSourceType`, label styling ports when `useLabel` is true, and border/shadow ports per border-style and `boxShadowEnabled` settings. The catalog list is the superset; the editor shows the subset matching current parameters. An `onEnter` that starts a backend save is taken once: a second one is ignored while that save is waiting and for half a second after the press (`repeatWhileSaving` turns this off). There is no need to wire a busy flag back to `enabled` for a double press.",
63656
63710
  "examples": [
63657
63711
  "ctl-settings-form",
63658
63712
  "community-email-signup-validat"
@@ -66950,10 +67004,12 @@
66950
67004
  "error": "Value; why the last attempt failed."
66951
67005
  },
66952
67006
  "examples": [
66953
- "cloud-invite-a-team-member"
67007
+ "cloud-invite-a-team-member",
67008
+ "cloud-members-can-edit-my-records"
66954
67009
  ],
66955
67010
  "patterns": [
66956
- "Wire straight after Create User in a signup function to put every new account into a default role like `member` — `unchanged` and `done` both mean the account is now in that role, so wiring both to the same next step covers a re-run cleanly."
67011
+ "Wire straight after Create User in a signup function to put every new account into a default role like `member` — `unchanged` and `done` both mean the account is now in that role, so wiring both to the same next step covers a re-run cleanly.",
67012
+ "A group per owner, joined by invitation: the role is named in code from the owner's id (`'members-of-' + invitation.owner`), so `createRole` is on (one name per owner, never typed), and the owner is the invitation's `owner` (the backend's word), never a group field the invitation carries: anyone signed in can create an invitation naming any group (example `cloud-members-can-edit-my-records`)."
66957
67013
  ],
66958
67014
  "antiPatterns": [
66959
67015
  "Wiring a request parameter straight into `userId` (or `role`) on a function anyone may call. That is 'grant any role to any account' with an HTTP endpoint in front of it — give the function a `call` rule (CWF-017) and check inside the graph that the caller is entitled to make this particular grant.",
@@ -69160,7 +69216,8 @@
69160
69216
  "error": "Value; why the last attempt failed, or (on Unchanged) which of the two non-membership cases it was."
69161
69217
  },
69162
69218
  "examples": [
69163
- "cloud-revoke-a-role-and-record-it"
69219
+ "cloud-revoke-a-role-and-record-it",
69220
+ "cloud-members-can-edit-my-records"
69164
69221
  ],
69165
69222
  "patterns": [
69166
69223
  "Wire a lapsed-subscription webhook straight to this node's `remove`, with `done` and `unchanged` both leading to the same next step — the account ends up without the role either way, which is the only thing that response needs to be true."
@@ -69254,7 +69311,7 @@
69254
69311
  "group": "Request",
69255
69312
  "plug": "output",
69256
69313
  "type": {
69257
- "name": "boolean"
69314
+ "name": "string"
69258
69315
  },
69259
69316
  "isSignal": false,
69260
69317
  "description": "Id of the user the session token resolved to, and blank for an unauthenticated request"
@@ -70915,7 +70972,10 @@
70915
70972
  {
70916
70973
  "id": "prompt_body",
70917
70974
  "type": "Javascript2",
70918
- "label": "Build request body"
70975
+ "label": "Build request body",
70976
+ "parameters": {
70977
+ "code": "Script.Inputs = { prompt: 'string', token: 'string' };\nScript.Outputs = { body: 'object', headers: 'object', send: 'signal' };\n\n// Run builds the request from the prompt as it is now, then asks the stream to connect.\n// `token` is the signed-in person's token for your own endpoint, never a provider key in the page.\nScript.Signals.run = function () {\n Script.setOutputs({\n body: { stream: true, messages: [{ role: 'user', content: Script.Inputs.prompt || '' }] },\n headers: { Authorization: 'Bearer ' + (Script.Inputs.token || '') }\n });\n Script.Outputs.send();\n};"
70978
+ }
70919
70979
  },
70920
70980
  {
70921
70981
  "id": "chat_stream",
@@ -70943,7 +71003,18 @@
70943
71003
  {
70944
71004
  "id": "state_label",
70945
71005
  "type": "String Mapper",
70946
- "label": "State to label"
71006
+ "label": "State to label",
71007
+ "parameters": {
71008
+ "input 0": "connecting",
71009
+ "output 0": "Connecting…",
71010
+ "input 1": "open",
71011
+ "output 1": "Answering",
71012
+ "input 2": "reconnecting",
71013
+ "output 2": "Reconnecting…",
71014
+ "input 3": "error",
71015
+ "output 3": "The stream failed",
71016
+ "defaultMapping": ""
71017
+ }
70947
71018
  }
70948
71019
  ],
70949
71020
  "connections": [
@@ -71005,11 +71076,11 @@
71005
71076
  "fromId": "chat_stream",
71006
71077
  "fromProperty": "connectionState",
71007
71078
  "toId": "state_label",
71008
- "toProperty": "input"
71079
+ "toProperty": "inputString"
71009
71080
  },
71010
71081
  {
71011
71082
  "fromId": "state_label",
71012
- "fromProperty": "output",
71083
+ "fromProperty": "mappedString",
71013
71084
  "toId": "status_text",
71014
71085
  "toProperty": "text"
71015
71086
  }
@@ -71422,7 +71493,7 @@
71422
71493
  {
71423
71494
  "id": "agent-store-shared-state",
71424
71495
  "title": "Shared state: one store read by two unrelated components",
71425
- "description": "Two components that are not parent and child, sharing state through a named store instead of prop drilling or a Send/Receive Event pair. Sign In writes `user` with Set Global Store; Header declares the same store by name and renders `state` — no wiring runs between the two components, because naming the store is what connects them. Header also subscribes to just the `user` key, so a write to any other key does not wake it, and uses `previousValue` against `value` to fire only on the signed-out → signed-in transition rather than on every change. Initial State fills gaps without overwriting live state, so Header remounting cannot reset a store Sign In is already using.",
71496
+ "description": "Two components that are not parent and child, sharing state through a named store instead of prop drilling or a Send/Receive Event pair. Sign In writes `user` with Set Global Store; Header declares the same store by name and reads `user` from it — no wiring runs between the two components, because naming the store is what connects them. Header subscribes to just the `user` key, so a write to any other key does not wake it, and puts the value into its greeting through a String Format whose `{user}` tag is the input the wire lands on. The store persists under its storage key, so the greeting survives a reload.",
71426
71497
  "demonstrates": [
71427
71498
  "net.noodl.GlobalStore",
71428
71499
  "net.noodl.GlobalStore.Set",
@@ -71524,7 +71595,10 @@
71524
71595
  {
71525
71596
  "id": "greeting",
71526
71597
  "type": "String Format",
71527
- "label": "Format greeting"
71598
+ "label": "Format greeting",
71599
+ "parameters": {
71600
+ "format": "Signed in as {user}"
71601
+ }
71528
71602
  }
71529
71603
  ],
71530
71604
  "connections": [
@@ -71532,11 +71606,11 @@
71532
71606
  "fromId": "watch_user",
71533
71607
  "fromProperty": "value",
71534
71608
  "toId": "greeting",
71535
- "toProperty": "input"
71609
+ "toProperty": "user"
71536
71610
  },
71537
71611
  {
71538
71612
  "fromId": "greeting",
71539
- "fromProperty": "result",
71613
+ "fromProperty": "formatted",
71540
71614
  "toId": "user_text",
71541
71615
  "toProperty": "text"
71542
71616
  }
@@ -71905,7 +71979,7 @@
71905
71979
  "type": "net.noodl.Log",
71906
71980
  "label": "The provider round trip did not start",
71907
71981
  "parameters": {
71908
- "level": "warning"
71982
+ "level": "warn"
71909
71983
  }
71910
71984
  },
71911
71985
  {
@@ -72507,7 +72581,7 @@
72507
72581
  "type": "net.noodl.Log",
72508
72582
  "label": "Unreadable due date",
72509
72583
  "parameters": {
72510
- "level": "warning",
72584
+ "level": "warn",
72511
72585
  "message": "Invoice row: the due date could not be read as a date."
72512
72586
  }
72513
72587
  }
@@ -72732,7 +72806,7 @@
72732
72806
  "type": "net.noodl.Log",
72733
72807
  "label": "The server did not answer usably",
72734
72808
  "parameters": {
72735
- "level": "warning"
72809
+ "level": "warn"
72736
72810
  }
72737
72811
  },
72738
72812
  {
@@ -72740,7 +72814,7 @@
72740
72814
  "type": "net.noodl.Log",
72741
72815
  "label": "The answer was not a feed",
72742
72816
  "parameters": {
72743
- "level": "warning"
72817
+ "level": "warn"
72744
72818
  }
72745
72819
  }
72746
72820
  ],
@@ -72911,7 +72985,7 @@
72911
72985
  "type": "net.noodl.Log",
72912
72986
  "label": "The paste would not parse",
72913
72987
  "parameters": {
72914
- "level": "warning"
72988
+ "level": "warn"
72915
72989
  }
72916
72990
  }
72917
72991
  ],
@@ -73010,7 +73084,7 @@
73010
73084
  "type": "net.noodl.Log",
73011
73085
  "label": "The server did not answer usably",
73012
73086
  "parameters": {
73013
- "level": "warning"
73087
+ "level": "warn"
73014
73088
  }
73015
73089
  },
73016
73090
  {
@@ -73018,7 +73092,7 @@
73018
73092
  "type": "net.noodl.Log",
73019
73093
  "label": "Refused or unreadable — read Error Code",
73020
73094
  "parameters": {
73021
- "level": "warning"
73095
+ "level": "warn"
73022
73096
  }
73023
73097
  }
73024
73098
  ],
@@ -73375,7 +73449,7 @@
73375
73449
  ],
73376
73450
  "components": [
73377
73451
  {
73378
- "name": "/Cloud Functions/Check Client Session",
73452
+ "name": "/#__cloud__/Cloud Functions/Check Client Session",
73379
73453
  "nodes": [
73380
73454
  {
73381
73455
  "id": "fn_request",
@@ -73479,7 +73553,7 @@
73479
73553
  ],
73480
73554
  "components": [
73481
73555
  {
73482
- "name": "/Cloud Functions/Verify Inbound",
73556
+ "name": "/#__cloud__/Cloud Functions/Verify Inbound",
73483
73557
  "nodes": [
73484
73558
  {
73485
73559
  "id": "fn_request",
@@ -73787,7 +73861,7 @@
73787
73861
  ],
73788
73862
  "components": [
73789
73863
  {
73790
- "name": "/Cloud Functions/Delete Account",
73864
+ "name": "/#__cloud__/Cloud Functions/Delete Account",
73791
73865
  "nodes": [
73792
73866
  {
73793
73867
  "id": "fn_request",
@@ -74161,7 +74235,7 @@
74161
74235
  ],
74162
74236
  "components": [
74163
74237
  {
74164
- "name": "/Cloud Functions/Invite Team Member",
74238
+ "name": "/#__cloud__/Cloud Functions/Invite Team Member",
74165
74239
  "nodes": [
74166
74240
  {
74167
74241
  "id": "fn_request",
@@ -74299,6 +74373,982 @@
74299
74373
  }
74300
74374
  ]
74301
74375
  },
74376
+ {
74377
+ "id": "cloud-members-can-edit-my-records",
74378
+ "title": "Invite people who can change my records too: a role per owner, joined only through an invitation its owner made",
74379
+ "description": "A second person, or several, who can change everything you can: people who may all read AND change the same records (a partner, a co-organiser, a colleague, anyone you invite to edit with you). The group belongs to one person, its owner, and its role is named from that person's id (\"members-of-<id>\"). The owner's page creates an Invite holding only the invited address; the backend writes who made it into `owner`. Join Group, a cloud function the invited person's page calls, finds the invitations naming the caller's own address (from their session, not the request) and puts them in the role of the invitation's OWNER, never a group id the invitation carries: anyone signed in can create an Invite naming any group, and only `owner` is the server's word, so an invitation can only ever let someone into its creator's own group. Every record of the group is created by Add To Group, which runs as the system: it refuses a caller who is neither the owner nor in the role (the User node's `roles`, read from the caller's session), writes the GROUP owner into `owner` (a page could never name it), and stores access rules for the owner and the role ({\"<ownerId>\": read+write, \"role:members-of-<ownerId>\": read+write}). So members change a record straight from the page with Update Record (the role's write rule lets them), and the group's list is a Query Records filtered on `owner` = the group, which a record somebody else made and opened to the role can never match. Remove Member takes back the caller's own invitation and takes the member out of the caller's own group, whatever the invitation says. `createRole` is on in Join Group because the role name is built in code, one per owner, never typed. Declare Invite (withEmail, memberId) and Note (title) in provision_backend, both creator-owned (the default), and set Note's create rule to \"nobody\" (set_collection_permissions): a cloud function runs as the system and still creates, and a Note posted straight to the collection from a page is refused, so a check in Add To Group is not one a caller can walk around.",
74380
+ "demonstrates": [
74381
+ "NewDbModelProperties",
74382
+ "SetDbModelProperties",
74383
+ "DbCollection2",
74384
+ "CloudFunction2",
74385
+ "noodl.cloud.request",
74386
+ "net.noodl.user.User",
74387
+ "JavaScriptFunction",
74388
+ "noodl.cloud.addusertorole",
74389
+ "noodl.cloud.removeuserfromrole",
74390
+ "noodl.cloud.response"
74391
+ ],
74392
+ "components": [
74393
+ {
74394
+ "name": "/Member Row",
74395
+ "nodes": [
74396
+ {
74397
+ "id": "mem_root",
74398
+ "type": "Group",
74399
+ "label": "One invitation",
74400
+ "parameters": {
74401
+ "flexDirection": "row",
74402
+ "columnGap": "var(--space-2)",
74403
+ "alignItems": "center"
74404
+ },
74405
+ "children": [
74406
+ "mem_address",
74407
+ "mem_remove"
74408
+ ]
74409
+ },
74410
+ {
74411
+ "id": "mem_address",
74412
+ "type": "Text",
74413
+ "label": "Who it invites",
74414
+ "parent": "mem_root"
74415
+ },
74416
+ {
74417
+ "id": "mem_remove",
74418
+ "type": "net.noodl.controls.button",
74419
+ "label": "Take it back",
74420
+ "parent": "mem_root",
74421
+ "parameters": {
74422
+ "label": "Remove"
74423
+ }
74424
+ },
74425
+ {
74426
+ "id": "mem_inputs",
74427
+ "type": "Component Inputs",
74428
+ "label": "Invitation in",
74429
+ "ports": [
74430
+ {
74431
+ "name": "withEmail",
74432
+ "plug": "output",
74433
+ "type": "*"
74434
+ }
74435
+ ]
74436
+ },
74437
+ {
74438
+ "id": "mem_call",
74439
+ "type": "CloudFunction2",
74440
+ "label": "Take the invitation back, and the access with it",
74441
+ "parameters": {
74442
+ "function": "Cloud Functions/Remove Member"
74443
+ }
74444
+ }
74445
+ ],
74446
+ "connections": [
74447
+ {
74448
+ "fromId": "mem_inputs",
74449
+ "fromProperty": "withEmail",
74450
+ "toId": "mem_address",
74451
+ "toProperty": "text"
74452
+ },
74453
+ {
74454
+ "fromId": "mem_inputs",
74455
+ "fromProperty": "withEmail",
74456
+ "toId": "mem_call",
74457
+ "toProperty": "in-withEmail"
74458
+ },
74459
+ {
74460
+ "fromId": "mem_remove",
74461
+ "fromProperty": "onClick",
74462
+ "toId": "mem_call",
74463
+ "toProperty": "call"
74464
+ }
74465
+ ]
74466
+ },
74467
+ {
74468
+ "name": "/Invite Someone To Edit",
74469
+ "nodes": [
74470
+ {
74471
+ "id": "inv_root",
74472
+ "type": "Group",
74473
+ "label": "Invite someone to edit my notes",
74474
+ "parameters": {
74475
+ "flexDirection": "column",
74476
+ "rowGap": "var(--space-3)"
74477
+ },
74478
+ "children": [
74479
+ "inv_address",
74480
+ "inv_button",
74481
+ "inv_error",
74482
+ "inv_list"
74483
+ ]
74484
+ },
74485
+ {
74486
+ "id": "inv_address",
74487
+ "type": "net.noodl.controls.textinput",
74488
+ "label": "Their email address",
74489
+ "parent": "inv_root",
74490
+ "parameters": {
74491
+ "type": "email",
74492
+ "placeholder": "name@example.com"
74493
+ }
74494
+ },
74495
+ {
74496
+ "id": "inv_button",
74497
+ "type": "net.noodl.controls.button",
74498
+ "label": "Invite them",
74499
+ "parent": "inv_root",
74500
+ "parameters": {
74501
+ "label": "Invite"
74502
+ }
74503
+ },
74504
+ {
74505
+ "id": "inv_error",
74506
+ "type": "Text",
74507
+ "label": "Why it did not save",
74508
+ "parent": "inv_root",
74509
+ "parameters": {
74510
+ "text": ""
74511
+ }
74512
+ },
74513
+ {
74514
+ "id": "inv_list",
74515
+ "type": "For Each",
74516
+ "label": "One row per invitation I made",
74517
+ "parent": "inv_root",
74518
+ "parameters": {
74519
+ "template": "/Member Row"
74520
+ }
74521
+ },
74522
+ {
74523
+ "id": "inv_create",
74524
+ "type": "NewDbModelProperties",
74525
+ "label": "An Invite naming only their address (owner is the backend's)",
74526
+ "parameters": {
74527
+ "collectionName": "Invite"
74528
+ }
74529
+ },
74530
+ {
74531
+ "id": "inv_mine",
74532
+ "type": "DbCollection2",
74533
+ "label": "My invitations (creator-owned: a query returns only mine)",
74534
+ "parameters": {
74535
+ "collectionName": "Invite",
74536
+ "runOnChange-collectionName": false,
74537
+ "runOnChange-querySettings": false
74538
+ }
74539
+ }
74540
+ ],
74541
+ "connections": [
74542
+ {
74543
+ "fromId": "inv_address",
74544
+ "fromProperty": "onTextChanged",
74545
+ "toId": "inv_create",
74546
+ "toProperty": "prop-withEmail"
74547
+ },
74548
+ {
74549
+ "fromId": "inv_button",
74550
+ "fromProperty": "onClick",
74551
+ "toId": "inv_create",
74552
+ "toProperty": "store"
74553
+ },
74554
+ {
74555
+ "fromId": "inv_create",
74556
+ "fromProperty": "error",
74557
+ "toId": "inv_error",
74558
+ "toProperty": "text"
74559
+ },
74560
+ {
74561
+ "fromId": "inv_root",
74562
+ "fromProperty": "didMount",
74563
+ "toId": "inv_mine",
74564
+ "toProperty": "storageFetch"
74565
+ },
74566
+ {
74567
+ "fromId": "inv_create",
74568
+ "fromProperty": "done",
74569
+ "toId": "inv_mine",
74570
+ "toProperty": "storageFetch"
74571
+ },
74572
+ {
74573
+ "fromId": "inv_mine",
74574
+ "fromProperty": "items",
74575
+ "toId": "inv_list",
74576
+ "toProperty": "items"
74577
+ }
74578
+ ]
74579
+ },
74580
+ {
74581
+ "name": "/Group Note Row",
74582
+ "nodes": [
74583
+ {
74584
+ "id": "gnr_root",
74585
+ "type": "Group",
74586
+ "label": "One note of the group",
74587
+ "parameters": {
74588
+ "flexDirection": "row",
74589
+ "columnGap": "var(--space-2)",
74590
+ "alignItems": "center"
74591
+ },
74592
+ "children": [
74593
+ "gnr_title",
74594
+ "gnr_save"
74595
+ ]
74596
+ },
74597
+ {
74598
+ "id": "gnr_title",
74599
+ "type": "net.noodl.controls.textinput",
74600
+ "label": "The note, editable by every member",
74601
+ "parent": "gnr_root"
74602
+ },
74603
+ {
74604
+ "id": "gnr_save",
74605
+ "type": "net.noodl.controls.button",
74606
+ "label": "Save the change",
74607
+ "parent": "gnr_root",
74608
+ "parameters": {
74609
+ "label": "Save"
74610
+ }
74611
+ },
74612
+ {
74613
+ "id": "gnr_inputs",
74614
+ "type": "Component Inputs",
74615
+ "label": "Note in",
74616
+ "ports": [
74617
+ {
74618
+ "name": "title",
74619
+ "plug": "output",
74620
+ "type": "*"
74621
+ }
74622
+ ]
74623
+ },
74624
+ {
74625
+ "id": "gnr_update",
74626
+ "type": "SetDbModelProperties",
74627
+ "label": "Change this note (the group's role may write it)",
74628
+ "parameters": {
74629
+ "collectionName": "Note",
74630
+ "idSource": "foreach"
74631
+ }
74632
+ }
74633
+ ],
74634
+ "connections": [
74635
+ {
74636
+ "fromId": "gnr_inputs",
74637
+ "fromProperty": "title",
74638
+ "toId": "gnr_title",
74639
+ "toProperty": "startValue"
74640
+ },
74641
+ {
74642
+ "fromId": "gnr_title",
74643
+ "fromProperty": "onTextChanged",
74644
+ "toId": "gnr_update",
74645
+ "toProperty": "prop-title"
74646
+ },
74647
+ {
74648
+ "fromId": "gnr_save",
74649
+ "fromProperty": "onClick",
74650
+ "toId": "gnr_update",
74651
+ "toProperty": "store"
74652
+ }
74653
+ ]
74654
+ },
74655
+ {
74656
+ "name": "/Group Notes",
74657
+ "nodes": [
74658
+ {
74659
+ "id": "grp_root",
74660
+ "type": "Group",
74661
+ "label": "The notes of the group I am in",
74662
+ "parameters": {
74663
+ "flexDirection": "column",
74664
+ "rowGap": "var(--space-3)"
74665
+ },
74666
+ "children": [
74667
+ "grp_title",
74668
+ "grp_add",
74669
+ "grp_list"
74670
+ ]
74671
+ },
74672
+ {
74673
+ "id": "grp_title",
74674
+ "type": "net.noodl.controls.textinput",
74675
+ "label": "A new note",
74676
+ "parent": "grp_root",
74677
+ "parameters": {
74678
+ "placeholder": "A new note"
74679
+ }
74680
+ },
74681
+ {
74682
+ "id": "grp_add",
74683
+ "type": "net.noodl.controls.button",
74684
+ "label": "Add it to the group",
74685
+ "parent": "grp_root",
74686
+ "parameters": {
74687
+ "label": "Add"
74688
+ }
74689
+ },
74690
+ {
74691
+ "id": "grp_list",
74692
+ "type": "For Each",
74693
+ "label": "One row per note of the group",
74694
+ "parent": "grp_root",
74695
+ "parameters": {
74696
+ "template": "/Group Note Row"
74697
+ }
74698
+ },
74699
+ {
74700
+ "id": "grp_join",
74701
+ "type": "CloudFunction2",
74702
+ "label": "Which group am I in (the server decides)",
74703
+ "parameters": {
74704
+ "function": "Cloud Functions/Join Group"
74705
+ }
74706
+ },
74707
+ {
74708
+ "id": "grp_notes",
74709
+ "type": "DbCollection2",
74710
+ "label": "The group's notes: owned by the group's owner",
74711
+ "parameters": {
74712
+ "collectionName": "Note",
74713
+ "visualFilter": {
74714
+ "combinator": "and",
74715
+ "rules": [
74716
+ {
74717
+ "property": "owner",
74718
+ "operator": "equal to",
74719
+ "input": "group"
74720
+ }
74721
+ ]
74722
+ },
74723
+ "visualSort": [
74724
+ {
74725
+ "property": "createdAt",
74726
+ "order": "ascending"
74727
+ }
74728
+ ],
74729
+ "runOnChange-collectionName": false,
74730
+ "runOnChange-querySettings": false
74731
+ }
74732
+ },
74733
+ {
74734
+ "id": "grp_add_call",
74735
+ "type": "CloudFunction2",
74736
+ "label": "Add a note to the group (the server checks I am in it)",
74737
+ "parameters": {
74738
+ "function": "Cloud Functions/Add To Group"
74739
+ }
74740
+ }
74741
+ ],
74742
+ "connections": [
74743
+ {
74744
+ "fromId": "grp_root",
74745
+ "fromProperty": "didMount",
74746
+ "toId": "grp_join",
74747
+ "toProperty": "call"
74748
+ },
74749
+ {
74750
+ "fromId": "grp_join",
74751
+ "fromProperty": "out-group",
74752
+ "toId": "grp_notes",
74753
+ "toProperty": "qp-group"
74754
+ },
74755
+ {
74756
+ "fromId": "grp_join",
74757
+ "fromProperty": "done",
74758
+ "toId": "grp_notes",
74759
+ "toProperty": "storageFetch"
74760
+ },
74761
+ {
74762
+ "fromId": "grp_notes",
74763
+ "fromProperty": "items",
74764
+ "toId": "grp_list",
74765
+ "toProperty": "items"
74766
+ },
74767
+ {
74768
+ "fromId": "grp_title",
74769
+ "fromProperty": "onTextChanged",
74770
+ "toId": "grp_add_call",
74771
+ "toProperty": "in-title"
74772
+ },
74773
+ {
74774
+ "fromId": "grp_join",
74775
+ "fromProperty": "out-group",
74776
+ "toId": "grp_add_call",
74777
+ "toProperty": "in-group"
74778
+ },
74779
+ {
74780
+ "fromId": "grp_add",
74781
+ "fromProperty": "onClick",
74782
+ "toId": "grp_add_call",
74783
+ "toProperty": "call"
74784
+ },
74785
+ {
74786
+ "fromId": "grp_add_call",
74787
+ "fromProperty": "done",
74788
+ "toId": "grp_notes",
74789
+ "toProperty": "storageFetch"
74790
+ }
74791
+ ]
74792
+ },
74793
+ {
74794
+ "name": "/#__cloud__/Cloud Functions/Join Group",
74795
+ "nodes": [
74796
+ {
74797
+ "id": "jg_request",
74798
+ "type": "noodl.cloud.request",
74799
+ "label": "The caller (no parameters: nothing to forge)",
74800
+ "parameters": {
74801
+ "allowNoAuth": false
74802
+ }
74803
+ },
74804
+ {
74805
+ "id": "jg_me",
74806
+ "type": "net.noodl.user.User",
74807
+ "label": "The caller's own session"
74808
+ },
74809
+ {
74810
+ "id": "jg_invites",
74811
+ "type": "DbCollection2",
74812
+ "label": "Every invitation",
74813
+ "parameters": {
74814
+ "collectionName": "Invite",
74815
+ "runOnChange-collectionName": false,
74816
+ "runOnChange-querySettings": false
74817
+ }
74818
+ },
74819
+ {
74820
+ "id": "jg_pick",
74821
+ "type": "JavaScriptFunction",
74822
+ "label": "The group of whoever invited this address: the invitation's owner",
74823
+ "parameters": {
74824
+ "functionScript": "if (Inputs.me === undefined || Inputs.email === undefined || Inputs.invites === undefined) return;\nconst me = String(Inputs.me || '');\nconst email = String(Inputs.email || '').trim().toLowerCase();\n// The group an invitation lets you into is the person who MADE it: its owner, which the backend wrote\n// from their session and nobody can change. Never a group id the invitation carries: anyone signed in\n// can create an invitation naming any group. Your own invitations are not a way in to anything.\nconst mine = Inputs.invites\n .filter((i) => email !== '' && String(i.withEmail || '').trim().toLowerCase() === email && String(i.owner) !== me)\n .sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));\nconst invite = mine[0];\nif (!invite) {\n // Nobody invited this address: the caller's group is their own.\n Outputs.group = me;\n Outputs.none();\n return;\n}\n// Remember who took the invitation up, so its owner can take it back (Remove Member).\nawait Noodl.Records.save(invite.id || invite.objectId, { memberId: me }, { className: 'Invite' });\nOutputs.group = String(invite.owner);\nOutputs.userId = me;\nOutputs.role = 'members-of-' + String(invite.owner);\nOutputs.join();"
74825
+ }
74826
+ },
74827
+ {
74828
+ "id": "jg_join",
74829
+ "type": "noodl.cloud.addusertorole",
74830
+ "label": "Put the caller in that group's role",
74831
+ "parameters": {
74832
+ "createRole": true
74833
+ }
74834
+ },
74835
+ {
74836
+ "id": "jg_ok",
74837
+ "type": "noodl.cloud.response",
74838
+ "label": "The group this caller is in",
74839
+ "parameters": {
74840
+ "params": "group"
74841
+ }
74842
+ },
74843
+ {
74844
+ "id": "jg_fail",
74845
+ "type": "noodl.cloud.response",
74846
+ "label": "Tell the caller it went wrong",
74847
+ "parameters": {
74848
+ "status": "failure"
74849
+ }
74850
+ }
74851
+ ],
74852
+ "connections": [
74853
+ {
74854
+ "fromId": "jg_request",
74855
+ "fromProperty": "receive",
74856
+ "toId": "jg_me",
74857
+ "toProperty": "fetch"
74858
+ },
74859
+ {
74860
+ "fromId": "jg_request",
74861
+ "fromProperty": "receive",
74862
+ "toId": "jg_invites",
74863
+ "toProperty": "storageFetch"
74864
+ },
74865
+ {
74866
+ "fromId": "jg_me",
74867
+ "fromProperty": "id",
74868
+ "toId": "jg_pick",
74869
+ "toProperty": "in-me"
74870
+ },
74871
+ {
74872
+ "fromId": "jg_me",
74873
+ "fromProperty": "email",
74874
+ "toId": "jg_pick",
74875
+ "toProperty": "in-email"
74876
+ },
74877
+ {
74878
+ "fromId": "jg_invites",
74879
+ "fromProperty": "items",
74880
+ "toId": "jg_pick",
74881
+ "toProperty": "in-invites"
74882
+ },
74883
+ {
74884
+ "fromId": "jg_pick",
74885
+ "fromProperty": "out-role",
74886
+ "toId": "jg_join",
74887
+ "toProperty": "role"
74888
+ },
74889
+ {
74890
+ "fromId": "jg_pick",
74891
+ "fromProperty": "out-userId",
74892
+ "toId": "jg_join",
74893
+ "toProperty": "userId"
74894
+ },
74895
+ {
74896
+ "fromId": "jg_pick",
74897
+ "fromProperty": "out-join",
74898
+ "toId": "jg_join",
74899
+ "toProperty": "add"
74900
+ },
74901
+ {
74902
+ "fromId": "jg_pick",
74903
+ "fromProperty": "out-group",
74904
+ "toId": "jg_ok",
74905
+ "toProperty": "pm-group"
74906
+ },
74907
+ {
74908
+ "fromId": "jg_pick",
74909
+ "fromProperty": "out-none",
74910
+ "toId": "jg_ok",
74911
+ "toProperty": "send"
74912
+ },
74913
+ {
74914
+ "fromId": "jg_join",
74915
+ "fromProperty": "done",
74916
+ "toId": "jg_ok",
74917
+ "toProperty": "send"
74918
+ },
74919
+ {
74920
+ "fromId": "jg_join",
74921
+ "fromProperty": "unchanged",
74922
+ "toId": "jg_ok",
74923
+ "toProperty": "send"
74924
+ },
74925
+ {
74926
+ "fromId": "jg_pick",
74927
+ "fromProperty": "error",
74928
+ "toId": "jg_fail",
74929
+ "toProperty": "errorMessage"
74930
+ },
74931
+ {
74932
+ "fromId": "jg_join",
74933
+ "fromProperty": "error",
74934
+ "toId": "jg_fail",
74935
+ "toProperty": "errorMessage"
74936
+ },
74937
+ {
74938
+ "fromId": "jg_pick",
74939
+ "fromProperty": "failure",
74940
+ "toId": "jg_fail",
74941
+ "toProperty": "send"
74942
+ },
74943
+ {
74944
+ "fromId": "jg_join",
74945
+ "fromProperty": "failure",
74946
+ "toId": "jg_fail",
74947
+ "toProperty": "send"
74948
+ }
74949
+ ]
74950
+ },
74951
+ {
74952
+ "name": "/#__cloud__/Cloud Functions/Add To Group",
74953
+ "nodes": [
74954
+ {
74955
+ "id": "ag_request",
74956
+ "type": "noodl.cloud.request",
74957
+ "label": "A note, and the group it is for",
74958
+ "parameters": {
74959
+ "allowNoAuth": false,
74960
+ "params": "title,group"
74961
+ }
74962
+ },
74963
+ {
74964
+ "id": "ag_me",
74965
+ "type": "net.noodl.user.User",
74966
+ "label": "The caller's own session, and the roles it carries"
74967
+ },
74968
+ {
74969
+ "id": "ag_add",
74970
+ "type": "JavaScriptFunction",
74971
+ "label": "Only a member writes, and the note is the group owner's",
74972
+ "parameters": {
74973
+ "functionScript": "if (Inputs.me === undefined || Inputs.roles === undefined || Inputs.group === undefined) return;\nconst me = String(Inputs.me || '');\nconst group = String(Inputs.group || '');\nconst roles = Array.isArray(Inputs.roles) ? Inputs.roles : [];\n// The group a caller may add to: their own, or one whose role their own session carries. The role is\n// only ever granted by Join Group, from an invitation's owner, so a group id sent here proves nothing.\nif (group === '' || (group !== me && !roles.includes('members-of-' + group))) {\n Outputs.reason = 'You are not a member of this group.';\n Outputs.refused();\n return;\n}\n// Created as the system, so the record's owner is the GROUP's owner (a page could never name it), and\n// its access rules are the owner and the group's role: every member reads and changes it.\nconst acl = {};\nacl[group] = { read: true, write: true };\nacl['role:members-of-' + group] = { read: true, write: true };\nconst note = await Noodl.Records.create('Note', { title: String(Inputs.title || ''), owner: group }, { acl });\nOutputs.id = note.getId();\nOutputs.added();"
74974
+ }
74975
+ },
74976
+ {
74977
+ "id": "ag_ok",
74978
+ "type": "noodl.cloud.response",
74979
+ "label": "The new note",
74980
+ "parameters": {
74981
+ "params": "id"
74982
+ }
74983
+ },
74984
+ {
74985
+ "id": "ag_fail",
74986
+ "type": "noodl.cloud.response",
74987
+ "label": "Refused, or it went wrong",
74988
+ "parameters": {
74989
+ "status": "failure"
74990
+ }
74991
+ }
74992
+ ],
74993
+ "connections": [
74994
+ {
74995
+ "fromId": "ag_request",
74996
+ "fromProperty": "receive",
74997
+ "toId": "ag_me",
74998
+ "toProperty": "fetch"
74999
+ },
75000
+ {
75001
+ "fromId": "ag_me",
75002
+ "fromProperty": "id",
75003
+ "toId": "ag_add",
75004
+ "toProperty": "in-me"
75005
+ },
75006
+ {
75007
+ "fromId": "ag_me",
75008
+ "fromProperty": "roles",
75009
+ "toId": "ag_add",
75010
+ "toProperty": "in-roles"
75011
+ },
75012
+ {
75013
+ "fromId": "ag_request",
75014
+ "fromProperty": "pm-group",
75015
+ "toId": "ag_add",
75016
+ "toProperty": "in-group"
75017
+ },
75018
+ {
75019
+ "fromId": "ag_request",
75020
+ "fromProperty": "pm-title",
75021
+ "toId": "ag_add",
75022
+ "toProperty": "in-title"
75023
+ },
75024
+ {
75025
+ "fromId": "ag_add",
75026
+ "fromProperty": "out-id",
75027
+ "toId": "ag_ok",
75028
+ "toProperty": "pm-id"
75029
+ },
75030
+ {
75031
+ "fromId": "ag_add",
75032
+ "fromProperty": "out-added",
75033
+ "toId": "ag_ok",
75034
+ "toProperty": "send"
75035
+ },
75036
+ {
75037
+ "fromId": "ag_add",
75038
+ "fromProperty": "out-reason",
75039
+ "toId": "ag_fail",
75040
+ "toProperty": "errorMessage"
75041
+ },
75042
+ {
75043
+ "fromId": "ag_add",
75044
+ "fromProperty": "out-refused",
75045
+ "toId": "ag_fail",
75046
+ "toProperty": "send"
75047
+ },
75048
+ {
75049
+ "fromId": "ag_add",
75050
+ "fromProperty": "error",
75051
+ "toId": "ag_fail",
75052
+ "toProperty": "errorMessage"
75053
+ },
75054
+ {
75055
+ "fromId": "ag_add",
75056
+ "fromProperty": "failure",
75057
+ "toId": "ag_fail",
75058
+ "toProperty": "send"
75059
+ }
75060
+ ]
75061
+ },
75062
+ {
75063
+ "name": "/#__cloud__/Cloud Functions/Remove Member",
75064
+ "nodes": [
75065
+ {
75066
+ "id": "rm_request",
75067
+ "type": "noodl.cloud.request",
75068
+ "label": "The address whose invitation the caller takes back",
75069
+ "parameters": {
75070
+ "allowNoAuth": false,
75071
+ "params": "withEmail"
75072
+ }
75073
+ },
75074
+ {
75075
+ "id": "rm_me",
75076
+ "type": "net.noodl.user.User",
75077
+ "label": "The caller's own session"
75078
+ },
75079
+ {
75080
+ "id": "rm_invites",
75081
+ "type": "DbCollection2",
75082
+ "label": "Every invitation",
75083
+ "parameters": {
75084
+ "collectionName": "Invite",
75085
+ "runOnChange-collectionName": false,
75086
+ "runOnChange-querySettings": false
75087
+ }
75088
+ },
75089
+ {
75090
+ "id": "rm_pick",
75091
+ "type": "JavaScriptFunction",
75092
+ "label": "The caller's own invitation, and the member who took it up",
75093
+ "parameters": {
75094
+ "functionScript": "if (Inputs.me === undefined || Inputs.withEmail === undefined || Inputs.invites === undefined) return;\nconst me = String(Inputs.me || '');\nconst email = String(Inputs.withEmail || '').trim().toLowerCase();\n// Only the caller's own invitations, and only the caller's own group: whatever an invitation says, the\n// role taken away is the caller's.\nconst mine = Inputs.invites.filter(\n (i) => String(i.owner) === me && String(i.withEmail || '').trim().toLowerCase() === email\n);\nif (mine.length === 0) {\n Outputs.reason = 'You have not invited that address.';\n Outputs.refused();\n return;\n}\nconst member = mine.map((i) => String(i.memberId || '')).find((m) => m !== '') || '';\nfor (const i of mine) await Noodl.Records.delete(i.id || i.objectId, { className: 'Invite' });\nif (member === '') {\n Outputs.removed();\n return;\n}\nOutputs.userId = member;\nOutputs.role = 'members-of-' + me;\nOutputs.remove();"
75095
+ }
75096
+ },
75097
+ {
75098
+ "id": "rm_role",
75099
+ "type": "noodl.cloud.removeuserfromrole",
75100
+ "label": "Take them out of the caller's group"
75101
+ },
75102
+ {
75103
+ "id": "rm_ok",
75104
+ "type": "noodl.cloud.response",
75105
+ "label": "Taken back"
75106
+ },
75107
+ {
75108
+ "id": "rm_fail",
75109
+ "type": "noodl.cloud.response",
75110
+ "label": "Refused, or it went wrong",
75111
+ "parameters": {
75112
+ "status": "failure"
75113
+ }
75114
+ }
75115
+ ],
75116
+ "connections": [
75117
+ {
75118
+ "fromId": "rm_request",
75119
+ "fromProperty": "receive",
75120
+ "toId": "rm_me",
75121
+ "toProperty": "fetch"
75122
+ },
75123
+ {
75124
+ "fromId": "rm_request",
75125
+ "fromProperty": "receive",
75126
+ "toId": "rm_invites",
75127
+ "toProperty": "storageFetch"
75128
+ },
75129
+ {
75130
+ "fromId": "rm_me",
75131
+ "fromProperty": "id",
75132
+ "toId": "rm_pick",
75133
+ "toProperty": "in-me"
75134
+ },
75135
+ {
75136
+ "fromId": "rm_request",
75137
+ "fromProperty": "pm-withEmail",
75138
+ "toId": "rm_pick",
75139
+ "toProperty": "in-withEmail"
75140
+ },
75141
+ {
75142
+ "fromId": "rm_invites",
75143
+ "fromProperty": "items",
75144
+ "toId": "rm_pick",
75145
+ "toProperty": "in-invites"
75146
+ },
75147
+ {
75148
+ "fromId": "rm_pick",
75149
+ "fromProperty": "out-role",
75150
+ "toId": "rm_role",
75151
+ "toProperty": "role"
75152
+ },
75153
+ {
75154
+ "fromId": "rm_pick",
75155
+ "fromProperty": "out-userId",
75156
+ "toId": "rm_role",
75157
+ "toProperty": "userId"
75158
+ },
75159
+ {
75160
+ "fromId": "rm_pick",
75161
+ "fromProperty": "out-remove",
75162
+ "toId": "rm_role",
75163
+ "toProperty": "remove"
75164
+ },
75165
+ {
75166
+ "fromId": "rm_pick",
75167
+ "fromProperty": "out-removed",
75168
+ "toId": "rm_ok",
75169
+ "toProperty": "send"
75170
+ },
75171
+ {
75172
+ "fromId": "rm_role",
75173
+ "fromProperty": "done",
75174
+ "toId": "rm_ok",
75175
+ "toProperty": "send"
75176
+ },
75177
+ {
75178
+ "fromId": "rm_role",
75179
+ "fromProperty": "unchanged",
75180
+ "toId": "rm_ok",
75181
+ "toProperty": "send"
75182
+ },
75183
+ {
75184
+ "fromId": "rm_pick",
75185
+ "fromProperty": "out-reason",
75186
+ "toId": "rm_fail",
75187
+ "toProperty": "errorMessage"
75188
+ },
75189
+ {
75190
+ "fromId": "rm_pick",
75191
+ "fromProperty": "out-refused",
75192
+ "toId": "rm_fail",
75193
+ "toProperty": "send"
75194
+ },
75195
+ {
75196
+ "fromId": "rm_pick",
75197
+ "fromProperty": "error",
75198
+ "toId": "rm_fail",
75199
+ "toProperty": "errorMessage"
75200
+ },
75201
+ {
75202
+ "fromId": "rm_role",
75203
+ "fromProperty": "error",
75204
+ "toId": "rm_fail",
75205
+ "toProperty": "errorMessage"
75206
+ },
75207
+ {
75208
+ "fromId": "rm_pick",
75209
+ "fromProperty": "failure",
75210
+ "toId": "rm_fail",
75211
+ "toProperty": "send"
75212
+ },
75213
+ {
75214
+ "fromId": "rm_role",
75215
+ "fromProperty": "failure",
75216
+ "toId": "rm_fail",
75217
+ "toProperty": "send"
75218
+ }
75219
+ ]
75220
+ }
75221
+ ]
75222
+ },
75223
+ {
75224
+ "id": "cloud-query-own-rows",
75225
+ "title": "Only my records: a Query Records filtered on the server by the signed-in user's id",
75226
+ "description": "A signed-in user's own rows, with the filter on the server (a server-side filter, the backend's where clause), not a Filter Records or a Function after the fetch. The Query Records node's `visualFilter` holds one rule, {\"property\": \"owner\", \"operator\": \"equal to\", \"input\": \"owner\"}, and that `input` mints the input port `qp-owner`; the User node's `id` is wired into it, so other users' rows never leave the server. `owner` is the backend's own field: it writes the creator's id into every record a signed-in user makes, from their session, and drops any `owner` a page sends, so the page that creates a Note sends no owner field at all. Never copy the user's id into a field of your own (an ownerId, a familyId, a teamId) to say whose a record is: that is the caller's word, and anyone signed in can send another id. Both fetch-at-build boxes are off (`runOnChange-collectionName` and `runOnChange-querySettings`: false) and Do is unwired: a rule whose qp- value has not arrived yet is dropped, so a query run before the id arrives would return every row the user may read; this way the id's arrival is what runs it. Sort and limit sit beside the filter (`visualSort`, `storageEnableLimit` + `storageLimit`). When a collection keeps its default creatorOwns rule and the backend enforces it, a user's query already returns only rows they own; the filter is how you narrow further, or how you ask for one person's rows in a shared collection.",
75227
+ "demonstrates": [
75228
+ "DbCollection2",
75229
+ "net.noodl.user.User",
75230
+ "For Each",
75231
+ "Component Inputs"
75232
+ ],
75233
+ "components": [
75234
+ {
75235
+ "name": "/My Notes",
75236
+ "nodes": [
75237
+ {
75238
+ "id": "notes_root",
75239
+ "type": "Group",
75240
+ "label": "My notes",
75241
+ "parameters": {
75242
+ "flexDirection": "column"
75243
+ },
75244
+ "children": [
75245
+ "notes_list"
75246
+ ]
75247
+ },
75248
+ {
75249
+ "id": "notes_list",
75250
+ "type": "For Each",
75251
+ "label": "One row per note of mine",
75252
+ "parent": "notes_root",
75253
+ "parameters": {
75254
+ "template": "/Note Row"
75255
+ }
75256
+ },
75257
+ {
75258
+ "id": "me",
75259
+ "type": "net.noodl.user.User",
75260
+ "label": "Signed-in user"
75261
+ },
75262
+ {
75263
+ "id": "my_notes",
75264
+ "type": "DbCollection2",
75265
+ "label": "Notes where owner = my id",
75266
+ "parameters": {
75267
+ "collectionName": "Note",
75268
+ "visualFilter": {
75269
+ "combinator": "and",
75270
+ "rules": [
75271
+ {
75272
+ "property": "owner",
75273
+ "operator": "equal to",
75274
+ "input": "owner"
75275
+ }
75276
+ ]
75277
+ },
75278
+ "visualSort": [
75279
+ {
75280
+ "property": "createdAt",
75281
+ "order": "descending"
75282
+ }
75283
+ ],
75284
+ "storageEnableLimit": true,
75285
+ "storageLimit": 50,
75286
+ "runOnChange-collectionName": false,
75287
+ "runOnChange-querySettings": false
75288
+ }
75289
+ }
75290
+ ],
75291
+ "connections": [
75292
+ {
75293
+ "fromId": "me",
75294
+ "fromProperty": "id",
75295
+ "toId": "my_notes",
75296
+ "toProperty": "qp-owner"
75297
+ },
75298
+ {
75299
+ "fromId": "my_notes",
75300
+ "fromProperty": "items",
75301
+ "toId": "notes_list",
75302
+ "toProperty": "items"
75303
+ }
75304
+ ]
75305
+ },
75306
+ {
75307
+ "name": "/Note Row",
75308
+ "nodes": [
75309
+ {
75310
+ "id": "row_root",
75311
+ "type": "Group",
75312
+ "label": "Note row",
75313
+ "parameters": {
75314
+ "flexDirection": "column",
75315
+ "paddingTop": "var(--space-2)",
75316
+ "paddingBottom": "var(--space-2)"
75317
+ },
75318
+ "children": [
75319
+ "row_title"
75320
+ ]
75321
+ },
75322
+ {
75323
+ "id": "row_title",
75324
+ "type": "Text",
75325
+ "label": "Note title",
75326
+ "parent": "row_root"
75327
+ },
75328
+ {
75329
+ "id": "row_inputs",
75330
+ "type": "Component Inputs",
75331
+ "label": "Record properties in",
75332
+ "ports": [
75333
+ {
75334
+ "name": "title",
75335
+ "plug": "output",
75336
+ "type": "*"
75337
+ }
75338
+ ]
75339
+ }
75340
+ ],
75341
+ "connections": [
75342
+ {
75343
+ "fromId": "row_inputs",
75344
+ "fromProperty": "title",
75345
+ "toId": "row_title",
75346
+ "toProperty": "text"
75347
+ }
75348
+ ]
75349
+ }
75350
+ ]
75351
+ },
74302
75352
  {
74303
75353
  "id": "cloud-record-crud",
74304
75354
  "title": "Records on a REST backend: query, create, update, delete",
@@ -74825,7 +75875,7 @@
74825
75875
  ],
74826
75876
  "components": [
74827
75877
  {
74828
- "name": "/Cloud Functions/Revoke Role",
75878
+ "name": "/#__cloud__/Cloud Functions/Revoke Role",
74829
75879
  "nodes": [
74830
75880
  {
74831
75881
  "id": "fn_request",
@@ -74983,6 +76033,424 @@
74983
76033
  }
74984
76034
  ]
74985
76035
  },
76036
+ {
76037
+ "id": "cloud-share-my-records-with-someone",
76038
+ "title": "Invite someone to see my records: a share row, and a cloud function that trusts its owner",
76039
+ "description": "Letting another person see your records (invite a teacher, a coach, a family member, a colleague by email). The page creates a Share record holding only the address it is shared with: it sends no field naming whose records these are, because the backend already writes that into the record's `owner`, from the signed-in caller's own session, and drops any `owner` a page sends. A cloud function answers \"what has been shared with me\": the caller's address comes from a User node inside the function (the caller's session, not the request body), the shares that name that address say WHO shared (their `owner`), and the notes returned are exactly the notes those people own (`note.owner`). Never decide it by a field the page wrote (a familyId, a teamId, an ownerId set from the User id): anyone signed in can create a Share saying any id they like, and only `owner` is the server's word. The records stay creator-owned (the default rules), so the function, which reads as the system, is the only door to another person's notes, and it answers read-only. The address match is done in the function rather than as a server filter on purpose: a filter rule whose value has not arrived yet is dropped, which would widen a system read to every share.",
76040
+ "demonstrates": [
76041
+ "NewDbModelProperties",
76042
+ "noodl.cloud.request",
76043
+ "net.noodl.user.User",
76044
+ "DbCollection2",
76045
+ "JavaScriptFunction",
76046
+ "noodl.cloud.response",
76047
+ "CloudFunction2"
76048
+ ],
76049
+ "components": [
76050
+ {
76051
+ "name": "/Share My Notes",
76052
+ "nodes": [
76053
+ {
76054
+ "id": "share_root",
76055
+ "type": "Group",
76056
+ "label": "Share my notes",
76057
+ "parameters": {
76058
+ "flexDirection": "column",
76059
+ "rowGap": "var(--space-3)"
76060
+ },
76061
+ "children": [
76062
+ "share_address",
76063
+ "share_button",
76064
+ "share_error"
76065
+ ]
76066
+ },
76067
+ {
76068
+ "id": "share_address",
76069
+ "type": "net.noodl.controls.textinput",
76070
+ "label": "Their email address",
76071
+ "parent": "share_root",
76072
+ "parameters": {
76073
+ "type": "email",
76074
+ "placeholder": "name@example.com"
76075
+ }
76076
+ },
76077
+ {
76078
+ "id": "share_button",
76079
+ "type": "net.noodl.controls.button",
76080
+ "label": "Share with them",
76081
+ "parent": "share_root",
76082
+ "parameters": {
76083
+ "label": "Share my notes"
76084
+ }
76085
+ },
76086
+ {
76087
+ "id": "share_error",
76088
+ "type": "Text",
76089
+ "label": "Why it did not save",
76090
+ "parent": "share_root",
76091
+ "parameters": {
76092
+ "text": ""
76093
+ }
76094
+ },
76095
+ {
76096
+ "id": "share_create",
76097
+ "type": "NewDbModelProperties",
76098
+ "label": "A Share naming only their address (owner is the backend's)",
76099
+ "parameters": {
76100
+ "collectionName": "Share"
76101
+ }
76102
+ }
76103
+ ],
76104
+ "connections": [
76105
+ {
76106
+ "fromId": "share_address",
76107
+ "fromProperty": "onTextChanged",
76108
+ "toId": "share_create",
76109
+ "toProperty": "prop-withEmail"
76110
+ },
76111
+ {
76112
+ "fromId": "share_button",
76113
+ "fromProperty": "onClick",
76114
+ "toId": "share_create",
76115
+ "toProperty": "store"
76116
+ },
76117
+ {
76118
+ "fromId": "share_create",
76119
+ "fromProperty": "error",
76120
+ "toId": "share_error",
76121
+ "toProperty": "text"
76122
+ }
76123
+ ]
76124
+ },
76125
+ {
76126
+ "name": "/Shared With Me",
76127
+ "nodes": [
76128
+ {
76129
+ "id": "seen_root",
76130
+ "type": "Group",
76131
+ "label": "Notes shared with me",
76132
+ "parameters": {
76133
+ "flexDirection": "column",
76134
+ "rowGap": "var(--space-2)"
76135
+ },
76136
+ "children": [
76137
+ "seen_refresh",
76138
+ "seen_list"
76139
+ ]
76140
+ },
76141
+ {
76142
+ "id": "seen_refresh",
76143
+ "type": "net.noodl.controls.button",
76144
+ "label": "Refresh",
76145
+ "parent": "seen_root",
76146
+ "parameters": {
76147
+ "label": "Refresh"
76148
+ }
76149
+ },
76150
+ {
76151
+ "id": "seen_list",
76152
+ "type": "For Each",
76153
+ "label": "One row per shared note",
76154
+ "parent": "seen_root",
76155
+ "parameters": {
76156
+ "template": "/Shared Note Row"
76157
+ }
76158
+ },
76159
+ {
76160
+ "id": "seen_call",
76161
+ "type": "CloudFunction2",
76162
+ "label": "Ask the server what was shared with me",
76163
+ "parameters": {
76164
+ "function": "Cloud Functions/Shared With Me"
76165
+ }
76166
+ }
76167
+ ],
76168
+ "connections": [
76169
+ {
76170
+ "fromId": "seen_refresh",
76171
+ "fromProperty": "onClick",
76172
+ "toId": "seen_call",
76173
+ "toProperty": "call"
76174
+ },
76175
+ {
76176
+ "fromId": "seen_call",
76177
+ "fromProperty": "out-notes",
76178
+ "toId": "seen_list",
76179
+ "toProperty": "items"
76180
+ }
76181
+ ]
76182
+ },
76183
+ {
76184
+ "name": "/Shared Note Row",
76185
+ "nodes": [
76186
+ {
76187
+ "id": "row_root",
76188
+ "type": "Group",
76189
+ "label": "Shared note row",
76190
+ "parameters": {
76191
+ "flexDirection": "column",
76192
+ "paddingTop": "var(--space-2)",
76193
+ "paddingBottom": "var(--space-2)"
76194
+ },
76195
+ "children": [
76196
+ "row_title"
76197
+ ]
76198
+ },
76199
+ {
76200
+ "id": "row_title",
76201
+ "type": "Text",
76202
+ "label": "Note title",
76203
+ "parent": "row_root"
76204
+ },
76205
+ {
76206
+ "id": "row_inputs",
76207
+ "type": "Component Inputs",
76208
+ "label": "Note in",
76209
+ "ports": [
76210
+ {
76211
+ "name": "title",
76212
+ "plug": "output",
76213
+ "type": "*"
76214
+ }
76215
+ ]
76216
+ }
76217
+ ],
76218
+ "connections": [
76219
+ {
76220
+ "fromId": "row_inputs",
76221
+ "fromProperty": "title",
76222
+ "toId": "row_title",
76223
+ "toProperty": "text"
76224
+ }
76225
+ ]
76226
+ },
76227
+ {
76228
+ "name": "/#__cloud__/Cloud Functions/Shared With Me",
76229
+ "nodes": [
76230
+ {
76231
+ "id": "fn_request",
76232
+ "type": "noodl.cloud.request",
76233
+ "label": "The caller (no parameters: nothing to forge)",
76234
+ "parameters": {
76235
+ "allowNoAuth": false
76236
+ }
76237
+ },
76238
+ {
76239
+ "id": "fn_me",
76240
+ "type": "net.noodl.user.User",
76241
+ "label": "The caller's own session"
76242
+ },
76243
+ {
76244
+ "id": "fn_shares",
76245
+ "type": "DbCollection2",
76246
+ "label": "Every share",
76247
+ "parameters": {
76248
+ "collectionName": "Share",
76249
+ "runOnChange-collectionName": false,
76250
+ "runOnChange-querySettings": false
76251
+ }
76252
+ },
76253
+ {
76254
+ "id": "fn_notes",
76255
+ "type": "DbCollection2",
76256
+ "label": "Every note",
76257
+ "parameters": {
76258
+ "collectionName": "Note",
76259
+ "runOnChange-collectionName": false,
76260
+ "runOnChange-querySettings": false
76261
+ }
76262
+ },
76263
+ {
76264
+ "id": "fn_pick",
76265
+ "type": "JavaScriptFunction",
76266
+ "label": "The notes of the people who shared with this address",
76267
+ "parameters": {
76268
+ "functionScript": "if (Inputs.email === undefined || Inputs.shares === undefined || Inputs.notes === undefined) return;\nconst me = String(Inputs.email || '').trim().toLowerCase();\n// WHO shared is each share's owner: the backend wrote it from their session and nobody can change it.\n// withEmail is only what they typed. A field the page wrote (a groupId, an ownerId) proves nothing.\nconst from = new Set(\n Inputs.shares\n .filter((s) => me !== '' && String(s.withEmail || '').trim().toLowerCase() === me)\n .map((s) => s.owner)\n);\nOutputs.notes = Inputs.notes\n .filter((n) => from.has(n.owner))\n .map((n) => ({ id: n.objectId || n.id, title: n.title || '' }));\nOutputs.answer();"
76269
+ }
76270
+ },
76271
+ {
76272
+ "id": "fn_response",
76273
+ "type": "noodl.cloud.response",
76274
+ "label": "What this caller may see",
76275
+ "parameters": {
76276
+ "params": "notes"
76277
+ }
76278
+ },
76279
+ {
76280
+ "id": "fn_fail",
76281
+ "type": "noodl.cloud.response",
76282
+ "label": "Tell the caller it went wrong",
76283
+ "parameters": {
76284
+ "status": "failure"
76285
+ }
76286
+ }
76287
+ ],
76288
+ "connections": [
76289
+ {
76290
+ "fromId": "fn_request",
76291
+ "fromProperty": "receive",
76292
+ "toId": "fn_me",
76293
+ "toProperty": "fetch"
76294
+ },
76295
+ {
76296
+ "fromId": "fn_request",
76297
+ "fromProperty": "receive",
76298
+ "toId": "fn_shares",
76299
+ "toProperty": "storageFetch"
76300
+ },
76301
+ {
76302
+ "fromId": "fn_request",
76303
+ "fromProperty": "receive",
76304
+ "toId": "fn_notes",
76305
+ "toProperty": "storageFetch"
76306
+ },
76307
+ {
76308
+ "fromId": "fn_me",
76309
+ "fromProperty": "email",
76310
+ "toId": "fn_pick",
76311
+ "toProperty": "in-email"
76312
+ },
76313
+ {
76314
+ "fromId": "fn_shares",
76315
+ "fromProperty": "items",
76316
+ "toId": "fn_pick",
76317
+ "toProperty": "in-shares"
76318
+ },
76319
+ {
76320
+ "fromId": "fn_notes",
76321
+ "fromProperty": "items",
76322
+ "toId": "fn_pick",
76323
+ "toProperty": "in-notes"
76324
+ },
76325
+ {
76326
+ "fromId": "fn_pick",
76327
+ "fromProperty": "out-notes",
76328
+ "toId": "fn_response",
76329
+ "toProperty": "pm-notes"
76330
+ },
76331
+ {
76332
+ "fromId": "fn_pick",
76333
+ "fromProperty": "out-answer",
76334
+ "toId": "fn_response",
76335
+ "toProperty": "send"
76336
+ },
76337
+ {
76338
+ "fromId": "fn_pick",
76339
+ "fromProperty": "error",
76340
+ "toId": "fn_fail",
76341
+ "toProperty": "errorMessage"
76342
+ },
76343
+ {
76344
+ "fromId": "fn_pick",
76345
+ "fromProperty": "failure",
76346
+ "toId": "fn_fail",
76347
+ "toProperty": "send"
76348
+ }
76349
+ ]
76350
+ }
76351
+ ]
76352
+ },
76353
+ {
76354
+ "id": "cloud-shared-counter",
76355
+ "title": "A count several devices change: Update Record adds on the server, each press taken once",
76356
+ "description": "A value two tabs or two devices change at once (a count, a score, a balance, progress) is changed on the server by the amount, never read into the page, changed, and written back whole: of two such whole saves the later erases the earlier. Here Update Record names `points` in Add To, so the 1 on `prop-points` is an amount the backend adds in one step, and the Record node bound to the same id shows the backend's total. No script, and no read of the old value. The press is taken once with no wiring: while the save a click started is waiting, and for half a second, the Button's next click is ignored (its Repeat While Saving is off). A Text Input's On Enter wired to the same Do shares that guard. To add a different amount, wire it into `prop-points` (a Function's output, -1 for a take-away button). To replace a whole value instead, use Update Record with `onlyIfUnchanged`. For a change that also needs `Noodl.Records.increment` in a script, read `recordId` once at the top so the answer belongs to the record the press was for.",
76357
+ "demonstrates": [
76358
+ "SetDbModelProperties",
76359
+ "DbModel2",
76360
+ "net.noodl.controls.button"
76361
+ ],
76362
+ "components": [
76363
+ {
76364
+ "name": "/Shared Count",
76365
+ "nodes": [
76366
+ {
76367
+ "id": "root",
76368
+ "type": "Group",
76369
+ "label": "Count",
76370
+ "parameters": {
76371
+ "flexDirection": "row",
76372
+ "alignItems": "center"
76373
+ },
76374
+ "children": [
76375
+ "count_text",
76376
+ "add_btn"
76377
+ ]
76378
+ },
76379
+ {
76380
+ "id": "count_text",
76381
+ "type": "Text",
76382
+ "label": "Total so far",
76383
+ "parent": "root"
76384
+ },
76385
+ {
76386
+ "id": "add_btn",
76387
+ "type": "net.noodl.controls.button",
76388
+ "label": "Add one",
76389
+ "parent": "root",
76390
+ "parameters": {
76391
+ "label": "+1"
76392
+ }
76393
+ },
76394
+ {
76395
+ "id": "count_inputs",
76396
+ "type": "Component Inputs",
76397
+ "label": "Record id in",
76398
+ "ports": [
76399
+ {
76400
+ "name": "recordId",
76401
+ "plug": "output",
76402
+ "type": "string"
76403
+ }
76404
+ ]
76405
+ },
76406
+ {
76407
+ "id": "count_record",
76408
+ "type": "DbModel2",
76409
+ "label": "The shared record",
76410
+ "parameters": {
76411
+ "collectionName": "Score"
76412
+ }
76413
+ },
76414
+ {
76415
+ "id": "add_one",
76416
+ "type": "SetDbModelProperties",
76417
+ "label": "Add one on the server",
76418
+ "parameters": {
76419
+ "collectionName": "Score",
76420
+ "addTo": "points",
76421
+ "prop-points": 1
76422
+ }
76423
+ }
76424
+ ],
76425
+ "connections": [
76426
+ {
76427
+ "fromId": "count_inputs",
76428
+ "fromProperty": "recordId",
76429
+ "toId": "count_record",
76430
+ "toProperty": "modelId"
76431
+ },
76432
+ {
76433
+ "fromId": "count_inputs",
76434
+ "fromProperty": "recordId",
76435
+ "toId": "add_one",
76436
+ "toProperty": "modelId"
76437
+ },
76438
+ {
76439
+ "fromId": "count_record",
76440
+ "fromProperty": "prop-points",
76441
+ "toId": "count_text",
76442
+ "toProperty": "text"
76443
+ },
76444
+ {
76445
+ "fromId": "add_btn",
76446
+ "fromProperty": "onClick",
76447
+ "toId": "add_one",
76448
+ "toProperty": "store"
76449
+ }
76450
+ ]
76451
+ }
76452
+ ]
76453
+ },
74986
76454
  {
74987
76455
  "id": "cloud-sign-a-token-with-a-stored-secret",
74988
76456
  "title": "Mint a signed token for another service to check",
@@ -74995,7 +76463,7 @@
74995
76463
  ],
74996
76464
  "components": [
74997
76465
  {
74998
- "name": "/Cloud Functions/Issue Partner Token",
76466
+ "name": "/#__cloud__/Cloud Functions/Issue Partner Token",
74999
76467
  "nodes": [
75000
76468
  {
75001
76469
  "id": "fn_request",
@@ -75623,7 +77091,7 @@
75623
77091
  ],
75624
77092
  "components": [
75625
77093
  {
75626
- "name": "/Cloud Functions/List Role Members",
77094
+ "name": "/#__cloud__/Cloud Functions/List Role Members",
75627
77095
  "nodes": [
75628
77096
  {
75629
77097
  "id": "fn_request",
@@ -75835,7 +77303,7 @@
75835
77303
  "type": "Javascript2",
75836
77304
  "label": "Extract headline",
75837
77305
  "parameters": {
75838
- "code": "script.inputs = { response: 'object' };\nscript.outputs = { headline: 'string' };\nscript.run = function (inputs, outputs) {\n outputs.headline = inputs.response && inputs.response.items ? inputs.response.items[0].title : '';\n};"
77306
+ "code": "Script.Inputs = { response: 'object' };\nScript.Outputs = { headline: 'string' };\n\n// Each response sets the headline: the first item's title, or nothing when the answer has no items.\nScript.Setters.response = function (response) {\n Script.setOutputs({ headline: response && response.items && response.items.length ? response.items[0].title : '' });\n};"
75839
77307
  }
75840
77308
  },
75841
77309
  {
@@ -76151,9 +77619,7 @@
76151
77619
  {
76152
77620
  "name": "Clicked",
76153
77621
  "plug": "input",
76154
- "type": {
76155
- "name": "*"
76156
- },
77622
+ "type": "*",
76157
77623
  "index": 0
76158
77624
  }
76159
77625
  ]
@@ -76165,135 +77631,105 @@
76165
77631
  {
76166
77632
  "name": "text",
76167
77633
  "plug": "output",
76168
- "type": {
76169
- "name": "*"
76170
- },
77634
+ "type": "*",
76171
77635
  "group": "General",
76172
77636
  "index": 2
76173
77637
  },
76174
77638
  {
76175
77639
  "name": "css-class",
76176
77640
  "plug": "output",
76177
- "type": {
76178
- "name": "*"
76179
- },
77641
+ "type": "*",
76180
77642
  "group": "General",
76181
77643
  "index": 1
76182
77644
  },
76183
77645
  {
76184
77646
  "name": "padding top",
76185
77647
  "plug": "output",
76186
- "type": {
76187
- "name": "*"
76188
- },
77648
+ "type": "*",
76189
77649
  "group": "Margin and padding",
76190
77650
  "index": 3
76191
77651
  },
76192
77652
  {
76193
77653
  "name": "padding bottom",
76194
77654
  "plug": "output",
76195
- "type": {
76196
- "name": "*"
76197
- },
77655
+ "type": "*",
76198
77656
  "group": "Margin and padding",
76199
77657
  "index": 4
76200
77658
  },
76201
77659
  {
76202
77660
  "name": "padding left",
76203
77661
  "plug": "output",
76204
- "type": {
76205
- "name": "*"
76206
- },
77662
+ "type": "*",
76207
77663
  "group": "Margin and padding",
76208
77664
  "index": 5
76209
77665
  },
76210
77666
  {
76211
77667
  "name": "padding right",
76212
77668
  "plug": "output",
76213
- "type": {
76214
- "name": "*"
76215
- },
77669
+ "type": "*",
76216
77670
  "group": "Margin and padding",
76217
77671
  "index": 6
76218
77672
  },
76219
77673
  {
76220
77674
  "name": "margin top",
76221
77675
  "plug": "output",
76222
- "type": {
76223
- "name": "*"
76224
- },
77676
+ "type": "*",
76225
77677
  "group": "Margin and padding",
76226
77678
  "index": 7
76227
77679
  },
76228
77680
  {
76229
77681
  "name": "margin bottom",
76230
77682
  "plug": "output",
76231
- "type": {
76232
- "name": "*"
76233
- },
77683
+ "type": "*",
76234
77684
  "group": "Margin and padding",
76235
77685
  "index": 8
76236
77686
  },
76237
77687
  {
76238
77688
  "name": "margin left",
76239
77689
  "plug": "output",
76240
- "type": {
76241
- "name": "*"
76242
- },
77690
+ "type": "*",
76243
77691
  "group": "Margin and padding",
76244
77692
  "index": 9
76245
77693
  },
76246
77694
  {
76247
77695
  "name": "margin right",
76248
77696
  "plug": "output",
76249
- "type": {
76250
- "name": "*"
76251
- },
77697
+ "type": "*",
76252
77698
  "group": "Margin and padding",
76253
77699
  "index": 10
76254
77700
  },
76255
77701
  {
76256
77702
  "name": "mounted",
76257
77703
  "plug": "output",
76258
- "type": {
76259
- "name": "*"
76260
- },
77704
+ "type": "*",
76261
77705
  "group": "General",
76262
77706
  "index": 0
76263
77707
  },
76264
77708
  {
76265
77709
  "name": "align horizontal",
76266
77710
  "plug": "output",
76267
- "type": {
76268
- "name": "*"
76269
- },
77711
+ "type": "*",
76270
77712
  "group": "Other",
76271
77713
  "index": 11
76272
77714
  },
76273
77715
  {
76274
77716
  "name": "align vertical",
76275
77717
  "plug": "output",
76276
- "type": {
76277
- "name": "*"
76278
- },
77718
+ "type": "*",
76279
77719
  "group": "Other",
76280
77720
  "index": 12
76281
77721
  },
76282
77722
  {
76283
77723
  "name": "position",
76284
77724
  "plug": "output",
76285
- "type": {
76286
- "name": "*"
76287
- },
77725
+ "type": "*",
76288
77726
  "group": "Other",
76289
77727
  "index": 13
76290
77728
  },
76291
77729
  {
76292
77730
  "name": "layout",
76293
77731
  "plug": "output",
76294
- "type": {
76295
- "name": "*"
76296
- },
77732
+ "type": "*",
76297
77733
  "group": "Other",
76298
77734
  "index": 14
76299
77735
  }
@@ -76473,17 +77909,13 @@
76473
77909
  {
76474
77910
  "name": "Do",
76475
77911
  "plug": "output",
76476
- "type": {
76477
- "name": "*"
76478
- },
77912
+ "type": "*",
76479
77913
  "index": 0
76480
77914
  },
76481
77915
  {
76482
77916
  "name": "JsonArray",
76483
77917
  "plug": "output",
76484
- "type": {
76485
- "name": "*"
76486
- },
77918
+ "type": "*",
76487
77919
  "index": 1
76488
77920
  }
76489
77921
  ]
@@ -76545,54 +77977,42 @@
76545
77977
  {
76546
77978
  "name": "File",
76547
77979
  "plug": "input",
76548
- "type": {
76549
- "name": "*"
76550
- },
77980
+ "type": "*",
76551
77981
  "group": "General",
76552
77982
  "index": 0
76553
77983
  },
76554
77984
  {
76555
77985
  "name": "Path",
76556
77986
  "plug": "input",
76557
- "type": {
76558
- "name": "*"
76559
- },
77987
+ "type": "*",
76560
77988
  "group": "Metadata",
76561
77989
  "index": 1
76562
77990
  },
76563
77991
  {
76564
77992
  "name": "Name",
76565
77993
  "plug": "input",
76566
- "type": {
76567
- "name": "*"
76568
- },
77994
+ "type": "*",
76569
77995
  "group": "Metadata",
76570
77996
  "index": 2
76571
77997
  },
76572
77998
  {
76573
77999
  "name": "Size in bytes",
76574
78000
  "plug": "input",
76575
- "type": {
76576
- "name": "*"
76577
- },
78001
+ "type": "*",
76578
78002
  "group": "Metadata",
76579
78003
  "index": 3
76580
78004
  },
76581
78005
  {
76582
78006
  "name": "Type",
76583
78007
  "plug": "input",
76584
- "type": {
76585
- "name": "*"
76586
- },
78008
+ "type": "*",
76587
78009
  "group": "Metadata",
76588
78010
  "index": 4
76589
78011
  },
76590
78012
  {
76591
78013
  "name": "Success",
76592
78014
  "plug": "input",
76593
- "type": {
76594
- "name": "*"
76595
- },
78015
+ "type": "*",
76596
78016
  "group": "Events",
76597
78017
  "index": 5
76598
78018
  }
@@ -76632,18 +78052,14 @@
76632
78052
  {
76633
78053
  "name": "Enable File Picker on click",
76634
78054
  "plug": "output",
76635
- "type": {
76636
- "name": "*"
76637
- },
78055
+ "type": "*",
76638
78056
  "group": "Dropzone",
76639
78057
  "index": 0
76640
78058
  },
76641
78059
  {
76642
78060
  "name": "Accepted file types",
76643
78061
  "plug": "output",
76644
- "type": {
76645
- "name": "*"
76646
- },
78062
+ "type": "*",
76647
78063
  "group": "Dropzone",
76648
78064
  "index": 1
76649
78065
  }
@@ -76675,54 +78091,42 @@
76675
78091
  {
76676
78092
  "name": "File",
76677
78093
  "plug": "input",
76678
- "type": {
76679
- "name": "*"
76680
- },
78094
+ "type": "*",
76681
78095
  "group": "General",
76682
78096
  "index": 0
76683
78097
  },
76684
78098
  {
76685
78099
  "name": "Path",
76686
78100
  "plug": "input",
76687
- "type": {
76688
- "name": "*"
76689
- },
78101
+ "type": "*",
76690
78102
  "group": "Metadata",
76691
78103
  "index": 1
76692
78104
  },
76693
78105
  {
76694
78106
  "name": "Name",
76695
78107
  "plug": "input",
76696
- "type": {
76697
- "name": "*"
76698
- },
78108
+ "type": "*",
76699
78109
  "group": "Metadata",
76700
78110
  "index": 2
76701
78111
  },
76702
78112
  {
76703
78113
  "name": "Size in bytes",
76704
78114
  "plug": "input",
76705
- "type": {
76706
- "name": "*"
76707
- },
78115
+ "type": "*",
76708
78116
  "group": "Metadata",
76709
78117
  "index": 3
76710
78118
  },
76711
78119
  {
76712
78120
  "name": "Type",
76713
78121
  "plug": "input",
76714
- "type": {
76715
- "name": "*"
76716
- },
78122
+ "type": "*",
76717
78123
  "group": "Metadata",
76718
78124
  "index": 4
76719
78125
  },
76720
78126
  {
76721
78127
  "name": "Success",
76722
78128
  "plug": "input",
76723
- "type": {
76724
- "name": "*"
76725
- },
78129
+ "type": "*",
76726
78130
  "group": "Events",
76727
78131
  "index": 5
76728
78132
  }
@@ -77179,108 +78583,84 @@
77179
78583
  {
77180
78584
  "name": "title",
77181
78585
  "plug": "output",
77182
- "type": {
77183
- "name": "*"
77184
- },
78586
+ "type": "*",
77185
78587
  "group": "General search engine",
77186
78588
  "index": 1
77187
78589
  },
77188
78590
  {
77189
78591
  "name": "description",
77190
78592
  "plug": "output",
77191
- "type": {
77192
- "name": "*"
77193
- },
78593
+ "type": "*",
77194
78594
  "group": "General search engine",
77195
78595
  "index": 2
77196
78596
  },
77197
78597
  {
77198
78598
  "name": "og:title",
77199
78599
  "plug": "output",
77200
- "type": {
77201
- "name": "*"
77202
- },
78600
+ "type": "*",
77203
78601
  "group": "Open Graph",
77204
78602
  "index": 4
77205
78603
  },
77206
78604
  {
77207
78605
  "name": "og:description",
77208
78606
  "plug": "output",
77209
- "type": {
77210
- "name": "*"
77211
- },
78607
+ "type": "*",
77212
78608
  "group": "Open Graph",
77213
78609
  "index": 5
77214
78610
  },
77215
78611
  {
77216
78612
  "name": "og:image",
77217
78613
  "plug": "output",
77218
- "type": {
77219
- "name": "*"
77220
- },
78614
+ "type": "*",
77221
78615
  "group": "Open Graph",
77222
78616
  "index": 6
77223
78617
  },
77224
78618
  {
77225
78619
  "name": "og:type",
77226
78620
  "plug": "output",
77227
- "type": {
77228
- "name": "*"
77229
- },
78621
+ "type": "*",
77230
78622
  "group": "Open Graph",
77231
78623
  "index": 9
77232
78624
  },
77233
78625
  {
77234
78626
  "name": "og:url (override default)",
77235
78627
  "plug": "output",
77236
- "type": {
77237
- "name": "*"
77238
- },
78628
+ "type": "*",
77239
78629
  "group": "Open Graph",
77240
78630
  "index": 10
77241
78631
  },
77242
78632
  {
77243
78633
  "name": "robots",
77244
78634
  "plug": "output",
77245
- "type": {
77246
- "name": "*"
77247
- },
78635
+ "type": "*",
77248
78636
  "group": "General search engine",
77249
78637
  "index": 3
77250
78638
  },
77251
78639
  {
77252
78640
  "name": "twitter:title",
77253
78641
  "plug": "output",
77254
- "type": {
77255
- "name": "*"
77256
- },
78642
+ "type": "*",
77257
78643
  "group": "Twitter",
77258
78644
  "index": 11
77259
78645
  },
77260
78646
  {
77261
78647
  "name": "twitter:description",
77262
78648
  "plug": "output",
77263
- "type": {
77264
- "name": "*"
77265
- },
78649
+ "type": "*",
77266
78650
  "group": "Twitter",
77267
78651
  "index": 12
77268
78652
  },
77269
78653
  {
77270
78654
  "name": "twitter:card",
77271
78655
  "plug": "output",
77272
- "type": {
77273
- "name": "*"
77274
- },
78656
+ "type": "*",
77275
78657
  "group": "Twitter",
77276
78658
  "index": 13
77277
78659
  },
77278
78660
  {
77279
78661
  "name": "twitter:image",
77280
78662
  "plug": "output",
77281
- "type": {
77282
- "name": "*"
77283
- },
78663
+ "type": "*",
77284
78664
  "group": "Twitter",
77285
78665
  "index": 14
77286
78666
  }
@@ -77787,17 +79167,13 @@
77787
79167
  {
77788
79168
  "name": "uploadRecordCreated",
77789
79169
  "plug": "input",
77790
- "type": {
77791
- "name": "*"
77792
- },
79170
+ "type": "*",
77793
79171
  "index": 1
77794
79172
  },
77795
79173
  {
77796
79174
  "name": "absoluteFilePathCreated",
77797
79175
  "plug": "input",
77798
- "type": {
77799
- "name": "*"
77800
- },
79176
+ "type": "*",
77801
79177
  "index": 2
77802
79178
  }
77803
79179
  ]
@@ -78355,25 +79731,19 @@
78355
79731
  {
78356
79732
  "name": "Html Content",
78357
79733
  "plug": "input",
78358
- "type": {
78359
- "name": "*"
78360
- },
79734
+ "type": "*",
78361
79735
  "index": 1
78362
79736
  },
78363
79737
  {
78364
79738
  "name": "Changed",
78365
79739
  "plug": "input",
78366
- "type": {
78367
- "name": "*"
78368
- },
79740
+ "type": "*",
78369
79741
  "index": 2
78370
79742
  },
78371
79743
  {
78372
79744
  "name": "Saved",
78373
79745
  "plug": "input",
78374
- "type": {
78375
- "name": "*"
78376
- },
79746
+ "type": "*",
78377
79747
  "index": 3
78378
79748
  }
78379
79749
  ]
@@ -78385,17 +79755,13 @@
78385
79755
  {
78386
79756
  "name": "Initial Content",
78387
79757
  "plug": "output",
78388
- "type": {
78389
- "name": "*"
78390
- },
79758
+ "type": "*",
78391
79759
  "index": 1
78392
79760
  },
78393
79761
  {
78394
79762
  "name": "Update Content",
78395
79763
  "plug": "output",
78396
- "type": {
78397
- "name": "*"
78398
- },
79764
+ "type": "*",
78399
79765
  "index": 2
78400
79766
  }
78401
79767
  ]
@@ -78499,7 +79865,7 @@
78499
79865
  {
78500
79866
  "id": "community-tiptap-text-editor",
78501
79867
  "title": "A rich-text editor with a toolbar and local drafts",
78502
- "description": "The larger companion to the single-button component: a `Static Data` node holds the list of toolbar commands, a `For Each` draws one button per entry, and the editor itself is mounted by a `JavaScriptFunction` onto a `Group`'s element. Two details are worth more than the editor. The draft is saved to `localStorage` on a `Timer` and read back on mount, which is the whole of 'don't lose my work' and costs two function nodes. And `Model2` plus `SetModelProperties` keep the document in the project's own data model rather than only inside the third-party editor, so something other than the editor can read what was typed. ⚠️ The toolbar is driven by data, so adding a command is a row in the `Static Data` node — not a new button.",
79868
+ "description": "The larger companion to the single-button component: a `Static Data` node holds the list of toolbar commands, a `For Each` draws one button per entry, and the editor itself is mounted by a `JavaScriptFunction` onto a `Group`'s element. Two details are worth more than the editor. The draft is saved to `localStorage` on a `Timer` and read back on mount, which is the whole of 'don't lose my work' and costs two function nodes. And `Model2` plus `SetModelProperties` keep the document in the project's own data model rather than only inside the third-party editor, so something other than the editor can read what was typed. ⚠️ The toolbar is driven by data, so adding a command is a row in the `Static Data` node — not a new button. Each row is the companion example's button component, `/Tiptap text editor r` (`community-tiptap-text-editor-r`): write that one first.",
78503
79869
  "demonstrates": [
78504
79870
  "Boolean",
78505
79871
  "CSS Definition",
@@ -78561,7 +79927,7 @@
78561
79927
  "type": "For Each",
78562
79928
  "parent": "menu",
78563
79929
  "parameters": {
78564
- "template": "/#Experiments/experiments/Tiptap editor/Editor button"
79930
+ "template": "/Tiptap text editor r"
78565
79931
  }
78566
79932
  },
78567
79933
  {
@@ -78876,7 +80242,7 @@
78876
80242
  },
78877
80243
  {
78878
80244
  "fromId": "setmodelproperties",
78879
- "fromProperty": "stored",
80245
+ "fromProperty": "done",
78880
80246
  "toId": "model2",
78881
80247
  "toProperty": "fetch"
78882
80248
  },
@@ -79014,17 +80380,13 @@
79014
80380
  {
79015
80381
  "name": "buttonType",
79016
80382
  "plug": "output",
79017
- "type": {
79018
- "name": "*"
79019
- },
80383
+ "type": "*",
79020
80384
  "index": 1
79021
80385
  },
79022
80386
  {
79023
80387
  "name": "editor",
79024
80388
  "plug": "output",
79025
- "type": {
79026
- "name": "*"
79027
- },
80389
+ "type": "*",
79028
80390
  "index": 2
79029
80391
  }
79030
80392
  ]
@@ -79070,17 +80432,13 @@
79070
80432
  {
79071
80433
  "name": "Success",
79072
80434
  "plug": "input",
79073
- "type": {
79074
- "name": "*"
79075
- },
80435
+ "type": "*",
79076
80436
  "index": 1
79077
80437
  },
79078
80438
  {
79079
80439
  "name": "buttonClicked",
79080
80440
  "plug": "input",
79081
- "type": {
79082
- "name": "*"
79083
- },
80441
+ "type": "*",
79084
80442
  "index": 2
79085
80443
  }
79086
80444
  ]
@@ -79298,17 +80656,13 @@
79298
80656
  {
79299
80657
  "name": "videoURL",
79300
80658
  "plug": "input",
79301
- "type": {
79302
- "name": "*"
79303
- },
80659
+ "type": "*",
79304
80660
  "index": 1
79305
80661
  },
79306
80662
  {
79307
80663
  "name": "uploadId",
79308
80664
  "plug": "input",
79309
- "type": {
79310
- "name": "*"
79311
- },
80665
+ "type": "*",
79312
80666
  "index": 2
79313
80667
  }
79314
80668
  ]
@@ -79320,9 +80674,7 @@
79320
80674
  {
79321
80675
  "name": "assignmentId",
79322
80676
  "plug": "output",
79323
- "type": {
79324
- "name": "*"
79325
- },
80677
+ "type": "*",
79326
80678
  "index": 1
79327
80679
  }
79328
80680
  ]
@@ -79996,7 +81348,7 @@
79996
81348
  },
79997
81349
  {
79998
81350
  "fromId": "draft_object",
79999
- "fromProperty": "title",
81351
+ "fromProperty": "prop-title",
80000
81352
  "toId": "draft_title_text",
80001
81353
  "toProperty": "text"
80002
81354
  }
@@ -80462,7 +81814,7 @@
80462
81814
  "parent": "row",
80463
81815
  "parameters": {
80464
81816
  "useLabel": true,
80465
- "labelSpacing": "8px"
81817
+ "labelSpacing": "var(--space-2)"
80466
81818
  }
80467
81819
  },
80468
81820
  {
@@ -82124,7 +83476,7 @@
82124
83476
  {
82125
83477
  "id": "fn-aggregate-stats-function",
82126
83478
  "title": "Server-side aggregation exposed as a cloud function",
82127
- "description": "Aggregate Records runs count/sum/min/max/average over a database class without transferring the records — it exists only in the cloud runtime, so the idiomatic shape is a small cloud function around it: Request triggers the aggregate's fetch, `fetched` sends the Response, and the aggregate results (outputs generated from the `aggregates` configuration) are returned as response params. The browser calls it like any cloud function and renders the statistic.",
83479
+ "description": "Aggregate Records runs a sum, min, max or average over a number column (or the distinct values of a text column) of a database class without transferring the records — it exists only in the cloud runtime, so the idiomatic shape is a small cloud function around it: Request triggers the aggregate's fetch, `fetched` sends the Response, and each name in `aggregates` gets its own Property and Operation inputs (`aggprop-<name>`, `aggop-<name>`) and its result on `agg-<name>`, returned as a response param. Here that is `totalHours`, the sum of every task's `hours`. There is no count operation: for a count, use Query Records with Fetch total count on. The browser calls the function like any cloud function and renders the statistic.",
82128
83480
  "demonstrates": [
82129
83481
  "noodl.cloud.aggregate",
82130
83482
  "CloudFunction2",
@@ -82144,7 +83496,7 @@
82144
83496
  },
82145
83497
  "children": [
82146
83498
  "refresh_btn",
82147
- "count_text"
83499
+ "total_text"
82148
83500
  ]
82149
83501
  },
82150
83502
  {
@@ -82157,9 +83509,9 @@
82157
83509
  }
82158
83510
  },
82159
83511
  {
82160
- "id": "count_text",
83512
+ "id": "total_text",
82161
83513
  "type": "Text",
82162
- "label": "Task count",
83514
+ "label": "Total hours",
82163
83515
  "parent": "root"
82164
83516
  },
82165
83517
  {
@@ -82180,8 +83532,8 @@
82180
83532
  },
82181
83533
  {
82182
83534
  "fromId": "call_stats",
82183
- "fromProperty": "out-taskCount",
82184
- "toId": "count_text",
83535
+ "fromProperty": "out-totalHours",
83536
+ "toId": "total_text",
82185
83537
  "toProperty": "text"
82186
83538
  }
82187
83539
  ]
@@ -82195,12 +83547,14 @@
82195
83547
  "label": "Request"
82196
83548
  },
82197
83549
  {
82198
- "id": "count_tasks",
83550
+ "id": "sum_hours",
82199
83551
  "type": "noodl.cloud.aggregate",
82200
- "label": "Count tasks",
83552
+ "label": "Sum task hours",
82201
83553
  "parameters": {
82202
83554
  "collectionName": "Task",
82203
- "aggregates": "taskCount"
83555
+ "aggregates": "totalHours",
83556
+ "aggprop-totalHours": "hours",
83557
+ "aggop-totalHours": "sum"
82204
83558
  }
82205
83559
  },
82206
83560
  {
@@ -82208,7 +83562,7 @@
82208
83562
  "type": "noodl.cloud.response",
82209
83563
  "label": "Response",
82210
83564
  "parameters": {
82211
- "params": "taskCount"
83565
+ "params": "totalHours"
82212
83566
  }
82213
83567
  },
82214
83568
  {
@@ -82224,29 +83578,29 @@
82224
83578
  {
82225
83579
  "fromId": "fn_request",
82226
83580
  "fromProperty": "receive",
82227
- "toId": "count_tasks",
83581
+ "toId": "sum_hours",
82228
83582
  "toProperty": "fetch"
82229
83583
  },
82230
83584
  {
82231
- "fromId": "count_tasks",
82232
- "fromProperty": "taskCount",
83585
+ "fromId": "sum_hours",
83586
+ "fromProperty": "agg-totalHours",
82233
83587
  "toId": "fn_response",
82234
- "toProperty": "pm-taskCount"
83588
+ "toProperty": "pm-totalHours"
82235
83589
  },
82236
83590
  {
82237
- "fromId": "count_tasks",
83591
+ "fromId": "sum_hours",
82238
83592
  "fromProperty": "fetched",
82239
83593
  "toId": "fn_response",
82240
83594
  "toProperty": "send"
82241
83595
  },
82242
83596
  {
82243
- "fromId": "count_tasks",
83597
+ "fromId": "sum_hours",
82244
83598
  "fromProperty": "error",
82245
83599
  "toId": "fn_fail_response",
82246
83600
  "toProperty": "errorMessage"
82247
83601
  },
82248
83602
  {
82249
- "fromId": "count_tasks",
83603
+ "fromId": "sum_hours",
82250
83604
  "fromProperty": "failure",
82251
83605
  "toId": "fn_fail_response",
82252
83606
  "toProperty": "send"
@@ -82649,7 +84003,7 @@
82649
84003
  ],
82650
84004
  "components": [
82651
84005
  {
82652
- "name": "/Cloud Functions/Notify",
84006
+ "name": "/#__cloud__/Cloud Functions/Notify",
82653
84007
  "nodes": [
82654
84008
  {
82655
84009
  "id": "fn_request",
@@ -92358,7 +93712,7 @@
92358
93712
  {
92359
93713
  "id": "user-login-flow",
92360
93714
  "title": "Log in, show the user, log out",
92361
- "description": "The authentication triangle: Log In takes `username`/`password` values and a `login` signal, firing `success` or `failure` (+`error`). The User node is the session's single source of truth — its `authenticated` boolean and profile outputs update on login/logout, so UI binds to it rather than to the action nodes. Log Out ends the session from anywhere. Gate visible areas on `authenticated`, not on the login node's `success` pulse.",
93715
+ "description": "The authentication triangle: Log In takes `username`/`password` values and a `login` signal, firing `done` or `failure` (+`error`). The User node is the session's single source of truth — its `authenticated` boolean and profile outputs update on login/logout, so UI binds to it rather than to the action nodes. Log Out ends the session from anywhere. Gate visible areas on `authenticated`, not on the login node's `done` pulse.",
92362
93716
  "demonstrates": [
92363
93717
  "net.noodl.user.LogIn",
92364
93718
  "net.noodl.user.LogOut",