Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,45 @@ return (

For RTK Query to follow tenant changes, recreate the API slice or pass a tenant-aware `getClient` resolver to `createNorbixApi`.

### Language (`Accept-Language`)

The language is a client setting: the hooks send whatever the wrapped core client sends. Set it with the provider prop (or `norbix.setLanguage(...)`); `Accept-Language` is the only language input (the old `cultureCode` field and `CultureCode` / `norbix-language` headers are gone).

```tsx
<NorbixProvider client={norbix} language="lt-LT, en;q=0.8">
<App />
</NorbixProvider>;

// switching later: change the prop (or call norbix.setLanguage('en')), then drop
// results read in the old language
dispatch(norbixApi.util.resetApiState());
```

Read every translation instead of one language with `allLanguages: true` on record and term reads, e.g. `useFindCollectionQuery({ collectionName: 'articles', allLanguages: true })`.

### Push for end users — `norbix.notifications.push` (core SDK with `notifications.push`)

| Hook | SDK call | Tags |
| ----------------------------------------------------------------- | --------------------- | -------------------------- |
| `useRegisterPushDeviceMutation({ provider, token, platform, … })` | `registerDevice` | invalidates `PushDevices` |
| `useListPushDevicesQuery()` | `listDevices` | provides `PushDevices` |
| `useRemovePushDeviceMutation(id)` | `removeDevice` | invalidates `PushDevices` |
| `useRemovePushDeviceByTokenMutation(token)` | `removeDeviceByToken` | invalidates `PushDevices` |
| `useListPushMessagesQuery({ unreadOnly, pageSize })` | `listMessages` | provides `PushMessages` |
| `useMarkPushMessageReadMutation(id)` | `markMessageRead` | invalidates `PushMessages` |
| `useMarkAllPushMessagesReadMutation()` | `markAllMessagesRead` | invalidates `PushMessages` |

```tsx
const [register] = norbixApi.useRegisterPushDeviceMutation();
await register({
provider: 'expo',
token: expoPushToken,
platform: 'ios',
installationId,
}).unwrap();
const { data } = norbixApi.useListPushMessagesQuery({ unreadOnly: true });
```

### Multi-region projects

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.
Expand Down
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"prepublishOnly": "npm run build"
},
"peerDependencies": {
"@norbix.ai/ts": ">=4.11.0",
"@norbix.ai/ts": ">=4.26.0",
"@reduxjs/toolkit": ">=2.0.0",
"react": ">=18.0.0",
"react-redux": ">=9.0.0"
Expand All @@ -68,7 +68,7 @@
"@commitlint/cli": "^21.2.2",
"@commitlint/config-conventional": "^21.2.2",
"@eslint/js": "^10.0.1",
"@norbix.ai/ts": "^4.25.0",
"@norbix.ai/ts": "^4.26.0",
"@reduxjs/toolkit": "^2.12.0",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/github": "^12.0.9",
Expand Down
2 changes: 2 additions & 0 deletions src/createNorbixApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export function createNorbixApi(
'PushTemplates',
'PushCampaigns',
'PushIntegrations',
'PushDevices',
'PushMessages',
// Sms
'Sms',
'SmsTemplates',
Expand Down
61 changes: 61 additions & 0 deletions src/hooks/api/push.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Norbix } from '@norbix.ai/ts';

import type { Builder } from '../index.js';
import type { Result } from '../types.js';

type Push = Norbix['notifications']['push'];
type RegisterDevice = Push['registerDevice'];
type ListDevices = Push['listDevices'];
type RemoveDevice = Push['removeDevice'];
type RemoveDeviceByToken = Push['removeDeviceByToken'];
type ListMessages = Push['listMessages'];
type MarkMessageRead = Push['markMessageRead'];
type MarkAllMessagesRead = Push['markAllMessagesRead'];

/**
* `notifications.push` — push for the signed-in project user (API host, user
* token), 1:1 with `norbix.notifications.push.*` in the core SDK.
*
* Tags:
* - `PushDevices` — the user's registered devices.
* - `PushMessages` — the user's push messages (read state included).
*/
export const apiPush = (b: Builder) => ({
registerPushDevice: b.mutation<Result<RegisterDevice>, Parameters<RegisterDevice>[0]>({
query: (device) => (norbix) => norbix.notifications.push.registerDevice(device),
invalidatesTags: ['PushDevices'],
}),

listPushDevices: b.query<Result<ListDevices>, void>({
query: () => (norbix) => norbix.notifications.push.listDevices(),
providesTags: ['PushDevices'],
}),

/** Arg: the device id (`pnd_…`). */
removePushDevice: b.mutation<Result<RemoveDevice>, string>({
query: (id) => (norbix) => norbix.notifications.push.removeDevice(id),
invalidatesTags: ['PushDevices'],
}),

/** Arg: the push token the device was registered with (e.g. on sign-out). */
removePushDeviceByToken: b.mutation<Result<RemoveDeviceByToken>, string>({
query: (token) => (norbix) => norbix.notifications.push.removeDeviceByToken(token),
invalidatesTags: ['PushDevices'],
}),

listPushMessages: b.query<Result<ListMessages>, Parameters<ListMessages>[0] | void>({
query: (args) => (norbix) => norbix.notifications.push.listMessages(args ?? {}),
providesTags: ['PushMessages'],
}),

/** Arg: the message id. */
markPushMessageRead: b.mutation<Result<MarkMessageRead>, string>({
query: (id) => (norbix) => norbix.notifications.push.markMessageRead(id),
invalidatesTags: ['PushMessages'],
}),

markAllPushMessagesRead: b.mutation<Result<MarkAllMessagesRead>, void>({
query: () => (norbix) => norbix.notifications.push.markAllMessagesRead(),
invalidatesTags: ['PushMessages'],
}),
});
4 changes: 4 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { apiDatabase } from './api/database.js';
import { apiFiles } from './api/files.js';
import { apiMembership } from './api/membership.js';
import { apiPublic } from './api/public.js';
import { apiPush } from './api/push.js';
import { hubAccount } from './hub/account.js';
import { hubAi } from './hub/ai.js';
import { hubApikeys } from './hub/apikeys.js';
Expand Down Expand Up @@ -88,6 +89,8 @@ export type AllTags =
| 'PushTemplates'
| 'PushCampaigns'
| 'PushIntegrations'
| 'PushDevices'
| 'PushMessages'
// ---- Sms ----
| 'Sms'
| 'SmsTemplates'
Expand Down Expand Up @@ -141,6 +144,7 @@ export function buildEndpoints(builder: Builder) {
...apiApikeys(builder),
...apiFiles(builder),
...apiPublic(builder),
...apiPush(builder),
// Hub surface (control plane)
...hubAccount(builder),
...hubAi(builder),
Expand Down
15 changes: 14 additions & 1 deletion src/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ const NorbixContext = createContext<Norbix | null>(null);
export interface NorbixProviderProps {
/** The Norbix SDK client instance. Construct it once at app startup. */
client: Norbix;
/**
* Language to ask for (`lt`, `lt-LT, en;q=0.8`), set on the client with
* `client.setLanguage(...)` so every hook's request carries it as
* `Accept-Language`. Omit it to leave the client's own setting alone.
* Cached results do not know the language they were read in: after a
* change, refetch with `dispatch(norbixApi.util.resetApiState())`.
*/
language?: string;
children?: ReactNode;
}

Expand Down Expand Up @@ -43,9 +51,14 @@ export interface NorbixProviderProps {
* }
* ```
*/
export function NorbixProvider({ client, children }: NorbixProviderProps) {
export function NorbixProvider({ client, language, children }: NorbixProviderProps) {
// Stable identity: re-render only when the client identity actually changes.
const value = useMemo(() => client, [client]);
// Set during render (not in an effect) so the first queries of the
// children already go out with the language.
if (language !== undefined && client.getLanguage() !== language) {
client.setLanguage(language || undefined);
}
return <NorbixContext.Provider value={value}>{children}</NorbixContext.Provider>;
}

Expand Down
144 changes: 144 additions & 0 deletions tests/push-language.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { Norbix } from '@norbix.ai/ts';
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import { apiPush } from '../src/hooks/api/push.js';
import { buildEndpoints } from '../src/hooks/index.js';
import { NorbixProvider, useNorbix } from '../src/provider.js';

function fakeBuilder() {
return {
query: (def: Record<string, unknown>) => ({ ...def, __kind: 'query' }),
mutation: (def: Record<string, unknown>) => ({ ...def, __kind: 'mutation' }),
} as never;
}

type Def = {
__kind: 'query' | 'mutation';
query: (args: unknown) => (norbix: unknown) => unknown;
providesTags?: unknown;
invalidatesTags?: unknown;
};

function fakeNorbix() {
const calls: Array<{ method: string; args: unknown[] }> = [];
const push = new Proxy(
{},
{
get:
(_t, method: string) =>
(...args: unknown[]) => {
calls.push({ method, args });
return Promise.resolve({ ok: true });
},
},
);
return { client: { notifications: { push } }, calls };
}

describe('push hooks (notifications.push)', () => {
const defs = apiPush(fakeBuilder()) as unknown as Record<string, Def>;

it('ships every push endpoint, queries and mutations as expected', () => {
expect(Object.keys(defs).sort()).toEqual([
'listPushDevices',
'listPushMessages',
'markAllPushMessagesRead',
'markPushMessageRead',
'registerPushDevice',
'removePushDevice',
'removePushDeviceByToken',
]);
expect(defs.listPushDevices!.__kind).toBe('query');
expect(defs.listPushMessages!.__kind).toBe('query');
expect(defs.registerPushDevice!.__kind).toBe('mutation');
expect(defs.markAllPushMessagesRead!.__kind).toBe('mutation');
});

it('are part of the full endpoint set', () => {
const all = buildEndpoints(fakeBuilder()) as unknown as Record<string, unknown>;
expect(all.registerPushDevice).toBeDefined();
expect(all.listPushMessages).toBeDefined();
});

it('call the matching SDK method with the hook argument', async () => {
const { client, calls } = fakeNorbix();
const device = { provider: 'expo', token: 't1', platform: 'ios' };
await defs.registerPushDevice!.query(device)(client);
await defs.listPushDevices!.query(undefined)(client);
await defs.removePushDevice!.query('pnd_1')(client);
await defs.removePushDeviceByToken!.query('t1')(client);
await defs.listPushMessages!.query({ unreadOnly: true })(client);
await defs.listPushMessages!.query(undefined)(client);
await defs.markPushMessageRead!.query('m1')(client);
await defs.markAllPushMessagesRead!.query(undefined)(client);
expect(calls).toEqual([
{ method: 'registerDevice', args: [device] },
{ method: 'listDevices', args: [] },
{ method: 'removeDevice', args: ['pnd_1'] },
{ method: 'removeDeviceByToken', args: ['t1'] },
{ method: 'listMessages', args: [{ unreadOnly: true }] },
{ method: 'listMessages', args: [{}] },
{ method: 'markMessageRead', args: ['m1'] },
{ method: 'markAllMessagesRead', args: [] },
]);
});

it('device writes invalidate PushDevices, message writes PushMessages', () => {
expect(defs.listPushDevices!.providesTags).toEqual(['PushDevices']);
expect(defs.registerPushDevice!.invalidatesTags).toEqual(['PushDevices']);
expect(defs.removePushDeviceByToken!.invalidatesTags).toEqual(['PushDevices']);
expect(defs.listPushMessages!.providesTags).toEqual(['PushMessages']);
expect(defs.markPushMessageRead!.invalidatesTags).toEqual(['PushMessages']);
expect(defs.markAllPushMessagesRead!.invalidatesTags).toEqual(['PushMessages']);
});

it('reach the real SDK routes', async () => {
const fetchImpl = vi.fn(
async () => new globalThis.Response('{}', { status: 200 }),
) as unknown as typeof globalThis.fetch;
const norbix = new Norbix({
projectId: 'p1',
bearerToken: 'jwt',
apiVersion: 'v3',
baseUrl: { api: 'https://api.example.test', hub: 'https://hub.example.test' },
fetch: fetchImpl,
});
await defs.markAllPushMessagesRead!.query(undefined)(norbix);
const [url, init] = (
fetchImpl as unknown as { mock: { calls: [string, { method?: string }][] } }
).mock.calls[0]!;
expect(url).toBe('https://api.example.test/v3/notifications/push/messages/read');
expect(init.method).toBe('POST');
});
});

describe('<NorbixProvider language>', () => {
function client() {
return new Norbix(
{ projectId: 'p1', apiKey: 'k', fetch: vi.fn() as unknown as typeof globalThis.fetch },
{ envSource: {} },
);
}

it('sets the client language so every hook request sends Accept-Language', () => {
const norbix = client();
renderHook(() => useNorbix(), {
wrapper: ({ children }) => (
<NorbixProvider client={norbix} language="lt-LT, en;q=0.8">
{children}
</NorbixProvider>
),
});
expect(norbix.getLanguage()).toBe('lt-LT, en;q=0.8');
});

it('leaves the client language alone when the prop is omitted', () => {
const norbix = client();
norbix.setLanguage('de');
renderHook(() => useNorbix(), {
wrapper: ({ children }) => <NorbixProvider client={norbix}>{children}</NorbixProvider>,
});
expect(norbix.getLanguage()).toBe('de');
});
});