@norbix.ai/react-redux 0.1.0

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.
package/README.md ADDED
@@ -0,0 +1,825 @@
1
+ <!-- BEGIN: HERO -->
2
+ <p align="center">
3
+ <a href="https://norbix.ai">
4
+ <img src="https://norbix.ai/brand/wordmark.svg" alt="Norbix" height="64" />
5
+ </a>
6
+ </p>
7
+
8
+ <div align="center">
9
+ <h1>Norbix React + Redux Toolkit</h1>
10
+ <p><strong>RTK Query helpers over the typed Norbix SDK — cache, dedup, refetch, invalidation.</strong></p>
11
+
12
+ <p>
13
+ <a href="https://github.com/norbix-code/react-redux/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/badge/license-MIT-blue.svg" /></a>
14
+ <a href="https://github.com/norbix-code/react-redux/actions"><img alt="CI" src="https://github.com/norbix-code/react-redux/actions/workflows/ci.yml/badge.svg" /></a>
15
+ <a href="https://www.npmjs.com/package/@norbix.ai/react-redux"><img alt="@norbix.ai/react-redux" src="https://img.shields.io/npm/v/@norbix.ai/react-redux.svg?label=@norbix.ai/react-redux&logo=npm" /></a>
16
+ </p>
17
+
18
+ <hr />
19
+ </div>
20
+ <!-- END: HERO -->
21
+
22
+ ## What this is
23
+
24
+ `@norbix.ai/react-redux` is a thin, opinionated layer that wires the Norbix TypeScript SDK into a Redux Toolkit + RTK Query app. You get React hooks for the most-used Norbix endpoints with full caching, request dedup, and tag-based invalidation built in. Under the hood every hook calls the same typed `Norbix` SDK — so DTOs, auth, errors, and base URLs all stay consistent with the rest of your stack.
25
+
26
+ | You get | Provided by |
27
+ |---|---|
28
+ | `useGetUsersQuery`, `useFindCollectionQuery`, `useInsertOneMutation`, ... | this package |
29
+ | Cache, dedup, polling, refetch on focus, optimistic updates | RTK Query (`@reduxjs/toolkit/query`) |
30
+ | Tree-shakeable typed methods, JWT auth, error mapping | `@norbix.ai/ts` SDK |
31
+
32
+ ## Install
33
+
34
+ ```sh
35
+ npm install @norbix.ai/react-redux @norbix.ai/ts @reduxjs/toolkit react-redux react
36
+ ```
37
+
38
+ ## Quickstart
39
+
40
+ Three steps: create the API slice, plug it into your store, mount the provider.
41
+
42
+ ```ts
43
+ // src/norbix.ts
44
+ import { Norbix } from '@norbix.ai/ts';
45
+ import { createNorbixApi } from '@norbix.ai/react-redux';
46
+
47
+ export const norbix = new Norbix(); // reads env vars, or pass { apiKey, projectId }
48
+
49
+ export const norbixApi = createNorbixApi(() => norbix);
50
+
51
+ export const {
52
+ useGetUsersQuery,
53
+ useInviteUserMutation,
54
+ useFindCollectionQuery,
55
+ useInsertOneMutation,
56
+ useUpdateOneMutation,
57
+ useDeleteOneMutation,
58
+ useGetAccountProfileQuery,
59
+ useGetDatabaseSchemasQuery,
60
+ useLoginMutation,
61
+ } = norbixApi;
62
+ ```
63
+
64
+ ```ts
65
+ // src/store.ts
66
+ import { configureStore } from '@reduxjs/toolkit';
67
+ import { norbixApi } from './norbix';
68
+
69
+ export const store = configureStore({
70
+ reducer: { [norbixApi.reducerPath]: norbixApi.reducer },
71
+ middleware: (getDefault) => getDefault().concat(norbixApi.middleware),
72
+ });
73
+
74
+ export type RootState = ReturnType<typeof store.getState>;
75
+ export type AppDispatch = typeof store.dispatch;
76
+ ```
77
+
78
+ ```tsx
79
+ // src/main.tsx
80
+ import { Provider } from 'react-redux';
81
+ import { NorbixProvider } from '@norbix.ai/react-redux';
82
+ import { store } from './store';
83
+ import { norbix } from './norbix';
84
+
85
+ createRoot(document.getElementById('root')!).render(
86
+ <Provider store={store}>
87
+ <NorbixProvider client={norbix}>
88
+ <App />
89
+ </NorbixProvider>
90
+ </Provider>,
91
+ );
92
+ ```
93
+
94
+ ```tsx
95
+ // src/UsersList.tsx
96
+ import { useGetUsersQuery, useInviteUserMutation } from './norbix';
97
+
98
+ export function UsersList() {
99
+ const { data, isLoading, error } = useGetUsersQuery({ take: 20, skip: 0 });
100
+ const [invite, { isLoading: inviting }] = useInviteUserMutation();
101
+
102
+ if (isLoading) return <p>Loading…</p>;
103
+ if (error) return <p>Error: {error.message}</p>;
104
+
105
+ return (
106
+ <ul>
107
+ {data?.users?.map((u) => <li key={u.id}>{u.email}</li>)}
108
+ <button
109
+ disabled={inviting}
110
+ onClick={() => invite({ email: 'maya@team.io', roleIds: [] })}
111
+ >
112
+ Invite
113
+ </button>
114
+ </ul>
115
+ );
116
+ }
117
+ ```
118
+
119
+ After `invite()` resolves, the `User/LIST` tag is invalidated and `useGetUsersQuery` automatically refetches — no manual cache busting needed.
120
+
121
+ ## What ships in the box
122
+
123
+ A *curated* set of hooks for the most common endpoints. Every hook is end-to-end typed from the SDK's own DTOs.
124
+
125
+ ### API surface — `norbix.api.*`
126
+
127
+ | Hook | Wraps | Cache tags |
128
+ |---|---|---|
129
+ | `useLoginMutation` | `client.login(...)` | invalidates `User/LIST`, `AccountProfile/CURRENT` |
130
+ | `useLogoutMutation` | `client.logout()` | invalidates `User`, `AccountProfile`, `Collection`, `Schema` |
131
+ | `useAuthenticateMutation` | `api.auth.authenticate` | — |
132
+ | `useGetUsersQuery` | `api.membership.getUsers` | provides `User/LIST` |
133
+ | `useGetUserQuery` | `api.membership.getUser` | provides `User/<id>` |
134
+ | `useInviteUserMutation` | `api.membership.inviteUser` | invalidates `User/LIST` |
135
+ | `useUpdateUserMutation` | `api.membership.updateUser` | invalidates `User/<id>` + `User/LIST` |
136
+ | `useBlockUserMutation` | `api.membership.blockUser` | invalidates `User/LIST` |
137
+ | `useUnblockUserMutation` | `api.membership.unblockUser` | invalidates `User/LIST` |
138
+ | `useDeleteUserMutation` | `api.membership.deleteUser` | invalidates `User/LIST` |
139
+ | `useFindCollectionQuery` | `api.database.find` | provides `Collection/<name>` |
140
+ | `useFindOneQuery` | `api.database.findOne` | provides `Collection/<name>` |
141
+ | `useCountCollectionQuery` | `api.database.count` | provides `Collection/<name>` |
142
+ | `useInsertOneMutation` | `api.database.insertOne` | invalidates `Collection/<name>` |
143
+ | `useUpdateOneMutation` | `api.database.updateOne` | invalidates `Collection/<name>` |
144
+ | `useReplaceOneMutation` | `api.database.replaceOne` | invalidates `Collection/<name>` |
145
+ | `useDeleteOneMutation` | `api.database.deleteOne` | invalidates `Collection/<name>` |
146
+ | `useFindTermsQuery` | `api.database.findTerms` | provides `DatabaseTaxonomyTerms/<taxonomyName>` |
147
+ | `useFindTermsChildrenQuery` | `api.database.findTermsChildren` | provides `DatabaseTaxonomyTerms/<taxonomyName>` |
148
+ | `useFindTermTreeQuery` | `api.database.findTermTree` | provides `DatabaseTaxonomyTerms/<taxonomyName>` |
149
+ | `useFindTaxonomyTreeQuery` | `api.database.findTaxonomyTree` | provides `DatabaseTaxonomyTerms/ANY` |
150
+ | `useGetApiKeysQuery` | `api.apikeys.getApiKeys` | provides `ApiKey/LIST` |
151
+ | `useRegenerateApiKeysMutation` | `api.apikeys.regenerateApiKeys` | invalidates `ApiKey/LIST` |
152
+ | `useGetPublicFileQuery` | `api.files.getPublicFile` | provides `Files/PUBLIC:<publicId>` |
153
+
154
+ ### Hub surface — `norbix.hub.*`
155
+
156
+ | Hook | Wraps | Cache tags |
157
+ |---|---|---|
158
+ | `useGetAccountProfileQuery` | `hub.account.getAccountProfile` | provides `AccountProfile/CURRENT` |
159
+ | `useGetAccountStatusQuery` | `hub.account.getAccountStatus` | provides `AccountProfile/STATUS` |
160
+ | `useUpdateAccountProfileMutation` | `hub.account.updateAccountProfile` | invalidates `AccountProfile/CURRENT` |
161
+ | `useGetProjectsQuery` | `hub.account.getProjects` | provides `AccountProfile/PROJECTS` |
162
+ | `useGetProjectQuery` | `hub.account.getProject` | provides `AccountProfile/PROJECT/<id>` |
163
+ | `useCreateProjectMutation` | `hub.account.createProject` | invalidates `AccountProfile/PROJECTS` |
164
+ | `useDeleteProjectMutation` | `hub.account.deleteProject` | invalidates `AccountProfile/PROJECTS` |
165
+ | `useListRegionsQuery` | `hub.regions.list` | provides `Regions` |
166
+ | `useUpdateProjectRegionsMutation` | `hub.regions.updateProjectRegions` | invalidates `Regions` + `Projects` |
167
+ | `useGetDatabaseSchemasQuery` | `hub.database.getDatabaseSchemas` | provides `Schema/LIST` |
168
+ | `useGetDatabaseSchemaQuery` | `hub.database.getDatabaseSchema` | provides `Schema/<id>` |
169
+ | `useSaveDatabaseSchemaMutation` | `hub.database.saveDatabaseSchema` | invalidates `Schema/LIST` |
170
+ | `useRenameDatabaseSchemaMutation` | `hub.database.renameDatabaseSchema` | invalidates `Schema/LIST` + `Schema/<id>` |
171
+ | `usePublishDatabaseSchemaMutation` | `hub.database.publishDatabaseSchema` | invalidates `Schema` + `Collection` |
172
+ | `useDeleteDatabaseSchemaMutation` | `hub.database.deleteDatabaseSchema` | invalidates `Schema/LIST` + `Collection` |
173
+ | `useGetEmailTemplatesQuery` | `hub.notifications.getEmailTemplates` | provides `Notification/EMAIL_TEMPLATE_LIST` |
174
+ | `useGetEmailTemplateQuery` | `hub.notifications.getEmailTemplate` | provides `Notification/EMAIL_TEMPLATE/<id>` |
175
+ | `useCreateEmailTemplateMutation` | `hub.notifications.createEmailTemplate` | invalidates `Notification/EMAIL_TEMPLATE_LIST` |
176
+ | `useUpdateEmailTemplateMutation` | `hub.notifications.updateEmailTemplate` | invalidates list + per-id |
177
+ | `useDeleteEmailTemplateMutation` | `hub.notifications.deleteEmailTemplate` | invalidates list |
178
+ | `useTestFilesIntegrationMutation` | `hub.files.testFilesIntegration` | — |
179
+ | `useMakeFilePublicMutation` | `hub.files.makeFilePublic` | invalidates `Files` |
180
+ | `useMakeFilePrivateMutation` | `hub.files.makeFilePrivate` | invalidates `Files` |
181
+ | `useMakeFolderPublicMutation` | `hub.files.makeFolderPublic` | invalidates `Files` |
182
+ | `useMakeFolderPrivateMutation` | `hub.files.makeFolderPrivate` | invalidates `Files` |
183
+
184
+ > **Need a hook we don't ship?** Two options. (1) Drop down to the SDK with `useNorbix()` for a one-off call. (2) Add a new file under `src/hooks/api/` or `src/hooks/hub/`, follow the pattern of the others, and open a PR.
185
+
186
+ ## Public file links
187
+
188
+ A file, or a whole folder prefix, can be made readable by anyone holding its
189
+ link. Publishing is a Hub action and needs the signed-in client; *reading* the
190
+ link needs nothing at all.
191
+
192
+ ```tsx
193
+ const [publish] = useMakeFilePublicMutation();
194
+ const [unpublish] = useMakeFilePrivateMutation();
195
+
196
+ await publish({ filesIntegrationId: 'nbin_1', path: 'docs/invoice.pdf' });
197
+
198
+ // `name` is the file's name for a file link, or the path inside the folder
199
+ // for a folder link — its slashes stay slashes.
200
+ const { data: bytes } = useGetPublicFileQuery({
201
+ publicId: 'nbpf_abc',
202
+ name: '2026/q1/report.pdf',
203
+ });
204
+ ```
205
+
206
+ `useMakeFilePrivateMutation` is refused while a folder above the file is
207
+ public — switch the folder off with `useMakeFolderPrivateMutation` instead.
208
+
209
+ The four publish hooks invalidate the `Files` tag, so a listing re-reads and
210
+ picks up the new `isPublic` / `publicUrl` on each file and the `publicFolders`
211
+ on the page. `useGetPublicFileQuery` caches under its own
212
+ `Files/PUBLIC:<publicId>` id, so two different links never share one entry;
213
+ making a file private again invalidates `Files`, a different tag, so call
214
+ `refetch()` if a stale entry would matter to you.
215
+
216
+ ## Working with terms
217
+
218
+ A **taxonomy** is a named tree of **terms** (labels). A term can have one parent (a clean hierarchy) or several parents (the same item under many categories). There is one read hook per scenario — pick the one that matches what you want:
219
+
220
+ | I want to… | Hook | `data` holds |
221
+ | --- | --- | --- |
222
+ | Get a taxonomy's terms as a flat list | `useFindTermsQuery` | a paginated `list` of terms |
223
+ | Get only the children of one term | `useFindTermsChildrenQuery` | a `list` of child terms (direct + multi-parent) |
224
+ | Get a taxonomy's terms as a ready-made tree | `useFindTermTreeQuery` | a `tree` of nested term nodes |
225
+ | Get the taxonomy structure (e.g. Countries → Cities) | `useFindTaxonomyTreeQuery` | a `tree` of taxonomy nodes |
226
+
227
+ All four share the same cache tag, so a write to a taxonomy refreshes every term view automatically. The examples below all use one example `services` taxonomy shaped like this:
228
+
229
+ ```text
230
+ Indoors
231
+ └─ Air conditioning
232
+ └─ Wall-mounted
233
+ Outdoors
234
+ └─ Solar panels
235
+ ```
236
+
237
+ ---
238
+
239
+ ### List a taxonomy's terms (flat)
240
+
241
+ **Goal:** show every term of `services` in a simple list, in display order.
242
+
243
+ ```tsx
244
+ const { data, isLoading } = useFindTermsQuery({ taxonomyName: 'services' });
245
+ ```
246
+
247
+ ```json
248
+ {
249
+ "list": {
250
+ "items": [
251
+ { "id": "term_indoors", "taxonomyName": "services", "parentId": null, "order": 1, "name": "Indoors" },
252
+ { "id": "term_air_con", "taxonomyName": "services", "parentId": "term_indoors", "order": 1, "name": "Air conditioning" },
253
+ { "id": "term_wall", "taxonomyName": "services", "parentId": "term_air_con", "order": 1, "name": "Wall-mounted" },
254
+ { "id": "term_outdoors", "taxonomyName": "services", "parentId": null, "order": 2, "name": "Outdoors" },
255
+ { "id": "term_solar", "taxonomyName": "services", "parentId": "term_outdoors","order": 1, "name": "Solar panels" }
256
+ ],
257
+ "hasMore": false, "hasPrevious": false, "startingAfter": null, "endingBefore": null
258
+ },
259
+ "responseStatus": { "isSuccess": true }
260
+ }
261
+ ```
262
+
263
+ The list is flat — every term is one row, with its `parentId` telling you where it sits. The nesting is not built for you here (use `useFindTermTreeQuery` for that).
264
+
265
+ ---
266
+
267
+ ### List only top-level terms (filtered)
268
+
269
+ **Goal:** show just the roots (no parent) — for the first level of a menu.
270
+
271
+ ```tsx
272
+ const { data } = useFindTermsQuery({
273
+ taxonomyName: 'services',
274
+ filter: '{ "parentId": null }',
275
+ });
276
+ ```
277
+
278
+ ```json
279
+ {
280
+ "list": {
281
+ "items": [
282
+ { "id": "term_indoors", "taxonomyName": "services", "parentId": null, "order": 1, "name": "Indoors" },
283
+ { "id": "term_outdoors", "taxonomyName": "services", "parentId": null, "order": 2, "name": "Outdoors" }
284
+ ],
285
+ "hasMore": false, "hasPrevious": false, "startingAfter": null, "endingBefore": null
286
+ },
287
+ "responseStatus": { "isSuccess": true }
288
+ }
289
+ ```
290
+
291
+ `filter` is an optional MongoDB filter, ANDed with the taxonomy. Use it to fetch one level at a time (lazy tree loading) or to find terms by any field.
292
+
293
+ ---
294
+
295
+ ### Get a term's children
296
+
297
+ **Goal:** the user expanded *Indoors* — load what is directly under it.
298
+
299
+ ```tsx
300
+ const { data } = useFindTermsChildrenQuery({
301
+ taxonomyName: 'services',
302
+ parentId: 'term_indoors',
303
+ });
304
+ ```
305
+
306
+ ```json
307
+ {
308
+ "list": {
309
+ "items": [
310
+ {
311
+ "id": "term_air_con",
312
+ "taxonomyName": "services",
313
+ "parentId": "term_indoors",
314
+ "order": 1,
315
+ "name": "Air conditioning",
316
+ "multiParents": [
317
+ { "taxonomyId": "tax_service_types", "parentId": "term_indoors", "name": "Indoors" },
318
+ { "taxonomyId": "tax_service_types", "parentId": "term_energy_efficient", "name": "Energy efficient" }
319
+ ]
320
+ }
321
+ ],
322
+ "hasMore": false, "hasPrevious": false
323
+ },
324
+ "responseStatus": { "isSuccess": true }
325
+ }
326
+ ```
327
+
328
+ This returns **both** direct children (their `parentId` is `term_indoors`) **and** multi-parent children (terms that list `term_indoors` in `multiParents`). Parent names are already resolved, so no second lookup.
329
+
330
+ ---
331
+
332
+ ### Multi-parent: one product in several categories
333
+
334
+ **Goal:** in a `products` taxonomy, a *Relaxing massage oil* belongs to *For couples*, *Gift ideas*, **and** *Body care*. Listing the children of **any** of those categories returns it.
335
+
336
+ ```tsx
337
+ const { data } = useFindTermsChildrenQuery({
338
+ taxonomyName: 'products',
339
+ parentId: 'term_gift_ideas',
340
+ });
341
+ ```
342
+
343
+ ```json
344
+ {
345
+ "list": {
346
+ "items": [
347
+ {
348
+ "id": "term_relaxing_oil",
349
+ "taxonomyName": "products",
350
+ "name": "Relaxing massage oil",
351
+ "multiParents": [
352
+ { "taxonomyId": "tax_categories", "parentId": "term_for_couples", "name": "For couples" },
353
+ { "taxonomyId": "tax_categories", "parentId": "term_gift_ideas", "name": "Gift ideas" },
354
+ { "taxonomyId": "tax_categories", "parentId": "term_body_care", "name": "Body care" }
355
+ ]
356
+ }
357
+ ],
358
+ "hasMore": false, "hasPrevious": false
359
+ },
360
+ "responseStatus": { "isSuccess": true }
361
+ }
362
+ ```
363
+
364
+ One product, three category links — no duplicate listings. The same product would also come back from the children of `term_for_couples` and `term_body_care`.
365
+
366
+ ---
367
+
368
+ ### Get the whole term tree in one call
369
+
370
+ **Goal:** render the full `services` tree at once, already nested.
371
+
372
+ ```tsx
373
+ const { data } = useFindTermTreeQuery({ taxonomyName: 'services' });
374
+ ```
375
+
376
+ ```json
377
+ {
378
+ "tree": [
379
+ {
380
+ "id": "term_indoors",
381
+ "name": "Indoors",
382
+ "order": 1,
383
+ "children": [
384
+ {
385
+ "id": "term_air_con",
386
+ "name": "Air conditioning",
387
+ "order": 1,
388
+ "children": [
389
+ { "id": "term_wall", "name": "Wall-mounted", "order": 1, "children": null }
390
+ ]
391
+ }
392
+ ]
393
+ },
394
+ {
395
+ "id": "term_outdoors",
396
+ "name": "Outdoors",
397
+ "order": 2,
398
+ "children": [
399
+ { "id": "term_solar", "name": "Solar panels", "order": 1, "children": null }
400
+ ]
401
+ }
402
+ ],
403
+ "responseStatus": { "isSuccess": true }
404
+ }
405
+ ```
406
+
407
+ Roots are in `tree`; each node carries its own `children`; a leaf has `children: null`. The tree arrives ready to render — no client-side tree building.
408
+
409
+ ---
410
+
411
+ ### Get only a sub-tree, capped by depth
412
+
413
+ **Goal:** start from *Indoors* and go at most 2 levels deep.
414
+
415
+ ```tsx
416
+ const { data } = useFindTermTreeQuery({
417
+ taxonomyName: 'services',
418
+ rootTermId: 'term_indoors',
419
+ depth: 2,
420
+ });
421
+ ```
422
+
423
+ ```json
424
+ {
425
+ "tree": [
426
+ {
427
+ "id": "term_indoors",
428
+ "name": "Indoors",
429
+ "order": 1,
430
+ "children": [
431
+ { "id": "term_air_con", "name": "Air conditioning", "order": 1, "children": null }
432
+ ]
433
+ }
434
+ ],
435
+ "responseStatus": { "isSuccess": true }
436
+ }
437
+ ```
438
+
439
+ With `depth: 2` you get *Indoors* (level 1) and *Air conditioning* (level 2); *Wall-mounted* (level 3) is cut off, so *Air conditioning* shows `children: null`.
440
+
441
+ ---
442
+
443
+ ### Get the taxonomy structure tree — without terms
444
+
445
+ **Goal:** see how taxonomies relate to each other (e.g. a `Cities` taxonomy whose parent is `Countries`), structure only.
446
+
447
+ ```tsx
448
+ const { data } = useFindTaxonomyTreeQuery({});
449
+ ```
450
+
451
+ ```json
452
+ {
453
+ "tree": [
454
+ {
455
+ "viewId": "txn_countries",
456
+ "taxonomyName": "Countries",
457
+ "taxonomySlug": "countries",
458
+ "parentId": null,
459
+ "children": [
460
+ { "viewId": "txn_cities", "taxonomyName": "Cities", "taxonomySlug": "cities", "parentId": "txn_countries", "children": null, "terms": null }
461
+ ],
462
+ "terms": null
463
+ }
464
+ ],
465
+ "responseStatus": { "isSuccess": true }
466
+ }
467
+ ```
468
+
469
+ This is the **taxonomy** tree, not the term tree: nodes are taxonomies. Every `terms` is `null` because we did not ask for terms.
470
+
471
+ ---
472
+
473
+ ### Get the taxonomy structure tree — with terms
474
+
475
+ **Goal:** same structure, but also pull each taxonomy's terms in the same call.
476
+
477
+ ```tsx
478
+ const { data } = useFindTaxonomyTreeQuery({ includeTerms: true });
479
+ ```
480
+
481
+ ```json
482
+ {
483
+ "tree": [
484
+ {
485
+ "viewId": "txn_countries",
486
+ "taxonomyName": "Countries",
487
+ "taxonomySlug": "countries",
488
+ "parentId": null,
489
+ "terms": [
490
+ { "id": "term_lt", "name": "Lithuania", "order": 1, "children": null },
491
+ { "id": "term_lv", "name": "Latvia", "order": 2, "children": null }
492
+ ],
493
+ "children": [
494
+ {
495
+ "viewId": "txn_cities",
496
+ "taxonomyName": "Cities",
497
+ "taxonomySlug": "cities",
498
+ "parentId": "txn_countries",
499
+ "terms": [
500
+ { "id": "term_vilnius", "name": "Vilnius", "order": 1, "children": null },
501
+ { "id": "term_kaunas", "name": "Kaunas", "order": 2, "children": null }
502
+ ],
503
+ "children": null
504
+ }
505
+ ]
506
+ }
507
+ ],
508
+ "responseStatus": { "isSuccess": true }
509
+ }
510
+ ```
511
+
512
+ Now each taxonomy node's `terms` holds that taxonomy's full term tree (same shape as `useFindTermTreeQuery`) — *Countries* carries its countries, *Cities* carries its cities.
513
+
514
+ > All four hooks also accept the usual RTK Query options (e.g. `{ skip: !ready }`), and an optional `databaseIntegrationId` in the argument to target a non-default database.
515
+
516
+ ## Common patterns
517
+
518
+ ### Skip a query until ready
519
+
520
+ ```tsx
521
+ const projectId = useSelector(selectCurrentProjectId);
522
+ const { data } = useGetProjectQuery({ id: projectId }, { skip: !projectId });
523
+ ```
524
+
525
+ ### Reset the entire cache after login or tenant switch
526
+
527
+ ```tsx
528
+ const dispatch = useDispatch();
529
+ const [login] = useLoginMutation();
530
+
531
+ async function handleLogin(creds) {
532
+ await login(creds).unwrap();
533
+ // The login mutation already invalidates the most-affected tags.
534
+ // For a hard reset across every cached endpoint, also do:
535
+ dispatch(norbixApi.util.resetApiState());
536
+ }
537
+ ```
538
+
539
+ ### Use the SDK directly when RTK Query is overkill
540
+
541
+ ```tsx
542
+ function ApiVersionBadge() {
543
+ const norbix = useNorbix();
544
+ const [v, setV] = useState<string>();
545
+ useEffect(() => {
546
+ norbix.api.echo.echo({}).then((r) => setV(r.gatewayVersion));
547
+ }, [norbix]);
548
+ return <small>API {v}</small>;
549
+ }
550
+ ```
551
+
552
+ ### Per-request scoping (SSR, multi-tenant)
553
+
554
+ ```tsx
555
+ const tenantNorbix = useMemo(() => norbix.with({ projectId, accountId }), [projectId, accountId]);
556
+
557
+ return (
558
+ <NorbixProvider client={tenantNorbix}>
559
+ <Workspace />
560
+ </NorbixProvider>
561
+ );
562
+ ```
563
+
564
+ For RTK Query to follow tenant changes, recreate the API slice or pass a tenant-aware `getClient` resolver to `createNorbixApi`.
565
+
566
+ ### Multi-region projects
567
+
568
+ A Norbix project spans one **primary region** plus any number of **additional regions**. Two concerns, two homes: *which region your requests target* is configured on the wrapped `norbix` client (this package inherits it — every hook just calls the client you passed to `createNorbixApi`), while *which regions a project spans* is managed through the two hooks below.
569
+
570
+ **Configure the target region on the wrapped client.**
571
+
572
+ ```ts
573
+ // 1. At construction
574
+ const norbix = new Norbix({ region: 'nb-eu-germany' });
575
+
576
+ // 2. Via environment — `new Norbix()` picks it up automatically
577
+ // NORBIX_REGION=nb-eu-germany
578
+
579
+ // 3. At runtime
580
+ norbix.setRegion('nb-eu-germany'); // all subsequent requests
581
+ norbix.setRegion(undefined); // clear — the backend uses the project's primary region
582
+ ```
583
+
584
+ When a region is set, every request carries the `nb-region` header, and the client itself composes the regional base URL (`https://nb-eu-germany.api.norbix.ai`) — but only when it is using the SDK's default base URLs; a custom `baseUrl` is never rewritten. There is no default region: with nothing set, no header is sent and the backend picks the project's primary region. The underlying SDK also accepts a per-call override (`norbix.hub.regions.list({}, { region: 'nb-eu-germany' })`); the shipped hooks don't expose that option, so for a one-off cross-region call drop down to the SDK via `useNorbix()`.
585
+
586
+ **Switching regions does not refetch by itself.** Same caveat as the tenant switch above: RTK Query keys its cache by endpoint + args, and the region is part of neither. After `norbix.setRegion(...)`, data cached from the old region stays in the store until something invalidates it. Reset the cache the same way you would after a tenant switch:
587
+
588
+ ```tsx
589
+ norbix.setRegion('nb-eu-germany');
590
+ dispatch(norbixApi.util.resetApiState()); // hard reset of every cached endpoint
591
+ // or, surgically:
592
+ dispatch(norbixApi.util.invalidateTags(['Regions', 'Projects']));
593
+ ```
594
+
595
+ **Manage the regions a project spans.**
596
+
597
+ ```tsx
598
+ import { useListRegionsQuery, useUpdateProjectRegionsMutation } from './norbix';
599
+
600
+ function RegionSettings({ projectId }: { projectId: string }) {
601
+ // GET /account/regions — the regions available to the account
602
+ const { data, isLoading } = useListRegionsQuery({});
603
+ // PATCH /account/projects/{projectId}/settings/regions
604
+ const [updateRegions, { isLoading: saving }] = useUpdateProjectRegionsMutation();
605
+
606
+ if (isLoading) return <p>Loading…</p>;
607
+
608
+ return (
609
+ <select
610
+ disabled={saving}
611
+ onChange={(e) =>
612
+ updateRegions({
613
+ projectId,
614
+ primaryRegion: e.target.value, // a region code, e.g. "nb-eu-germany"
615
+ additionalRegions: [],
616
+ })
617
+ }
618
+ >
619
+ {data?.items?.map((r) => (
620
+ <option key={r.id} value={r.id}>
621
+ {r.name ?? r.id}
622
+ </option>
623
+ ))}
624
+ </select>
625
+ );
626
+ }
627
+ ```
628
+
629
+ - `useListRegionsQuery` wraps `norbix.hub.regions.list` and **provides the `Regions` tag**. As with every query hook, a lazy variant — `useLazyListRegionsQuery` — is generated alongside it.
630
+ - `useUpdateProjectRegionsMutation` wraps `norbix.hub.regions.updateProjectRegions({ projectId, primaryRegion?, additionalRegions? })` and **invalidates `Regions` and `Projects`** — changing the regions a project spans updates the project DTO (`primaryRegion` / `additionalRegions`), so cached region lists *and* project queries refetch automatically once the mutation resolves.
631
+
632
+ > `hub.account` carries `getAccountRegions` / `updateProjectRegions` aliases for the same wire endpoints (`useGetAccountRegionsQuery` tags `Account`). The `hub.regions` hooks above are the canonical pair — they are the ones wired to the `Regions` tag.
633
+
634
+ ### Wire all the `*Integrations` modules in one line each
635
+
636
+ Almost every Hub module exposes the same integrations CRUD surface (`getXIntegrations`, `saveXIntegration`, `enableXIntegration`, ...). Instead of copy-pasting ~50 lines per module, use `buildIntegrationsEndpoints`. The package already uses it for `hub.database`; wire the other 8 in your app via `injectEndpoints`:
637
+
638
+ ```ts
639
+ import { norbixApi, buildIntegrationsEndpoints } from '@norbix.ai/react-redux';
640
+
641
+ norbixApi.injectEndpoints({
642
+ endpoints: (b) => ({
643
+ // Email integrations live on hub.notifications (not hub.email)
644
+ ...buildIntegrationsEndpoints(b, {
645
+ prefix: 'Email',
646
+ tag: 'EmailIntegrations',
647
+ namespace: (n) => n.hub.notifications,
648
+ include: { test: true, confirmHumanDelivery: true },
649
+ }),
650
+ // Push integrations
651
+ ...buildIntegrationsEndpoints(b, {
652
+ prefix: 'Push',
653
+ tag: 'PushIntegrations',
654
+ namespace: (n) => n.hub.notifications,
655
+ include: { test: true, confirmHumanDelivery: true },
656
+ }),
657
+ // Sms integrations
658
+ ...buildIntegrationsEndpoints(b, {
659
+ prefix: 'Sms',
660
+ tag: 'SmsIntegrations',
661
+ namespace: (n) => n.hub.notifications,
662
+ include: { test: true, confirmHumanDelivery: true },
663
+ }),
664
+ // Files integrations
665
+ ...buildIntegrationsEndpoints(b, {
666
+ prefix: 'Files',
667
+ tag: 'FilesIntegrations',
668
+ namespace: (n) => n.hub.files,
669
+ include: { test: true },
670
+ }),
671
+ // Payments integrations
672
+ ...buildIntegrationsEndpoints(b, {
673
+ prefix: 'Payments',
674
+ tag: 'PaymentIntegrations',
675
+ namespace: (n) => n.hub.payments,
676
+ include: { test: true, confirmHumanDelivery: true },
677
+ }),
678
+ // Code integrations
679
+ ...buildIntegrationsEndpoints(b, {
680
+ prefix: 'Code',
681
+ tag: 'CodeIntegrations',
682
+ namespace: (n) => n.hub.code ?? (n.hub as never),
683
+ include: { test: true },
684
+ }),
685
+ // Membership integrations
686
+ ...buildIntegrationsEndpoints(b, {
687
+ prefix: 'Membership',
688
+ tag: 'MembershipIntegrations',
689
+ namespace: (n) => n.hub.membership,
690
+ include: { test: false },
691
+ }),
692
+ // Logs integrations — uses the `Logging` SDK prefix, not `Logs`
693
+ ...buildIntegrationsEndpoints(b, {
694
+ prefix: 'Logging',
695
+ tag: 'LogsIntegrations',
696
+ namespace: (n) => n.hub.logs,
697
+ include: { test: false },
698
+ }),
699
+ }),
700
+ overrideExisting: false,
701
+ });
702
+ ```
703
+
704
+ This produces all hooks automatically — `useGetEmailIntegrationsQuery`, `useSaveEmailIntegrationMutation`, `useTestEmailIntegrationMutation`, `useConfirmEmailIntegrationHumanDeliveryMutation`, and the same for push/sms/files/payments/etc.
705
+
706
+ | Module | Prefix | Namespace | Tag | Notes |
707
+ |---|---|---|---|---|
708
+ | Email | `Email` | `n.hub.notifications` | `EmailIntegrations` | has `test` + `confirmHumanDelivery` |
709
+ | Push | `Push` | `n.hub.notifications` | `PushIntegrations` | has `test` + `confirmHumanDelivery` |
710
+ | Sms | `Sms` | `n.hub.notifications` | `SmsIntegrations` | has `test` + `confirmHumanDelivery` |
711
+ | Files | `Files` | `n.hub.files` | `FilesIntegrations` | has `test` |
712
+ | Payments | `Payments` | `n.hub.payments` | `PaymentIntegrations` | has `test` + `confirmHumanDelivery` |
713
+ | Code | `Code` | `n.hub.code` | `CodeIntegrations` | has `test` |
714
+ | Membership | `Membership` | `n.hub.membership` | `MembershipIntegrations` | no `test` |
715
+ | Logs | `Logging` *(uses `Logging`, not `Logs`)* | `n.hub.logs` | `LogsIntegrations` | no `test` |
716
+ | Database | `Database` | `n.hub.database` | `DatabaseIntegrations` | already wired in package |
717
+
718
+ **Trade-off.** The helper is terse but loses static request/response typing — generated hooks return `any` data, because endpoint keys are computed at runtime. If you need full types on a specific integration surface (autocompletion in your IDE), define those endpoints manually instead, following the canonical shape used by `hub.database` integrations in `src/hooks/hub/database.ts`.
719
+
720
+ ### Add your own endpoints with `injectEndpoints`
721
+
722
+ The package ships a curated set of hooks. When your app needs an endpoint we don't expose, you can add it without forking — `createNorbixApi` returns the standard RTK Query API object, so `injectEndpoints` works exactly as documented in the [RTK Query docs](https://redux-toolkit.js.org/rtk-query/api/created-api/code-splitting):
723
+
724
+ ```ts
725
+ // src/services/myCampaigns.ts
726
+ import { norbixApi } from '../norbix';
727
+ import type { useNorbix } from '@norbix.ai/react-redux';
728
+
729
+ export const myCampaignsService = norbixApi.injectEndpoints({
730
+ endpoints: (builder) => ({
731
+ getEmailCampaigns: builder.query({
732
+ query: (args) => (norbix) => norbix.hub.notifications
733
+ ? norbix.hub.notifications.getEmailTemplates(args) // example only
734
+ : Promise.resolve({}),
735
+ providesTags: [{ type: 'EmailCampaigns', id: 'LIST' }],
736
+ }),
737
+ // ...more app-specific endpoints
738
+ }),
739
+ overrideExisting: false,
740
+ });
741
+
742
+ export const { useGetEmailCampaignsQuery } = myCampaignsService;
743
+ ```
744
+
745
+ The injected endpoints share the same `baseQuery`, the same tag taxonomy, and the same Redux slice — they integrate fully with cache invalidation across the package's hooks and your own.
746
+
747
+ ### Unwrap response envelopes with `selectFromResult` or `transformResponse`
748
+
749
+ Norbix gateway responses are envelopes — `{ list: { items: [...] } }`, `{ user: {...} }`, etc. The hooks return the envelope shape (matching the SDK return type). To get just the inner array or item in a component, use `selectFromResult`:
750
+
751
+ ```tsx
752
+ const { users, isLoading } = useGetUsersQuery(args, {
753
+ selectFromResult: ({ data, isLoading }) => ({
754
+ users: data?.list?.result ?? [],
755
+ isLoading,
756
+ }),
757
+ });
758
+ ```
759
+
760
+ Or unwrap once at the endpoint level via `transformResponse` (use `injectEndpoints` to add a project-specific variant):
761
+
762
+ ```ts
763
+ norbixApi.injectEndpoints({
764
+ endpoints: (builder) => ({
765
+ getUsersList: builder.query<UserDto[], Partial<GetUsersRequest>>({
766
+ query: (args) => (norbix) => norbix.api.membership.getUsers(args),
767
+ transformResponse: (res) => res.list?.result ?? [],
768
+ providesTags: [{ type: 'MembershipUsers', id: 'LIST' }],
769
+ }),
770
+ }),
771
+ overrideExisting: false,
772
+ });
773
+ ```
774
+
775
+ ### Migrating from a hand-rolled `fetchBaseQuery` setup
776
+
777
+ If your app currently calls Norbix via `fetchBaseQuery({ baseUrl })` + a custom `prepareHeaders`, you're reinventing what the SDK already does (auth precedence, retries, error mapping, env loading, MCP-aligned shape). Migration sketch:
778
+
779
+ 1. **Replace** `fetchBaseQuery` setup with `createNorbixApi(() => norbix)`.
780
+ 2. **Mount** `<NorbixProvider client={norbix}>` once at app root, alongside the existing `<Provider store={store}>`.
781
+ 3. **Migrate per-service file**: rewrite each `api.injectEndpoints({ endpoints: builder => ({ getX: builder.query(...) }) })` from `url + method + body` to the `query: (args) => (norbix) => norbix.api.<module>.<method>(args)` shape. Keep the same `providesTags` / `invalidatesTags`.
782
+ 4. **Remove** the custom error interceptor — `createNorbixBaseQuery` already maps `NorbixError` to `SerializedNorbixError` with `code`, `status`, `message`, `fieldErrors`. Consume those in your error UI instead.
783
+ 5. **Keep** the same tag taxonomy — the package exports the full set (`Account`, `Projects`, `Regions`, `MembershipUsers`, `DatabaseSchemas`, ... 59 in total), so existing `providesTags` keep working.
784
+
785
+ ## How it works under the hood
786
+
787
+ `createNorbixApi(getClient)` returns an RTK Query API. Its `baseQuery` is a thin wrapper that calls a closure you pass at endpoint definition time:
788
+
789
+ ```ts
790
+ // inside @norbix.ai/react-redux
791
+ const baseQuery = async (call) => {
792
+ try {
793
+ return { data: await call(getClient()) };
794
+ } catch (err) {
795
+ return { error: serializeNorbixError(err) };
796
+ }
797
+ };
798
+ ```
799
+
800
+ Every endpoint is shaped like:
801
+
802
+ ```ts
803
+ getUsers: b.query({
804
+ query: (args) => (norbix) => norbix.api.membership.getUsers(args),
805
+ providesTags: [{ type: 'User', id: 'LIST' }],
806
+ }),
807
+ ```
808
+
809
+ The closure carries the args and runs against the live SDK. Types come from the SDK's own method signatures via small `Result<F>` / `Arg<F>` helpers — no DTO imports required.
810
+
811
+ ## Development
812
+
813
+ ```sh
814
+ npm install
815
+ npm run lint
816
+ npm run typecheck
817
+ npm test
818
+ npm run build
819
+ ```
820
+
821
+ Conventional commits are required. Pushes to `main` are released to npm by [semantic-release](https://github.com/semantic-release/semantic-release) with provenance enabled. `next` and `beta` branches publish prereleases.
822
+
823
+ ## License
824
+
825
+ [MIT](./LICENSE) © Norbix