@outseta/react 0.3.6 → 0.4.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.
@@ -30,11 +30,14 @@ export interface OutsetaUser {
30
30
  [key: string]: unknown;
31
31
  }
32
32
 
33
+ export type OutsetaWidgetMode = "popup" | "embed";
34
+ export type AuthWidgetMode = "login" | "register" | "login|register";
35
+
33
36
  export interface AuthWidgetOptions {
34
- widgetMode?: "login" | "register" | "login|register";
37
+ widgetMode?: AuthWidgetMode;
35
38
  authenticationCallbackUrl?: string;
36
- mode?: "popup" | "embed";
37
- registrationDefaults?: string;
39
+ mode?: OutsetaWidgetMode;
40
+ registrationDefaults?: string | Record<string, unknown>;
38
41
  [key: string]: unknown;
39
42
  }
40
43
 
@@ -46,11 +49,28 @@ export interface ProfileWidgetOptions {
46
49
  | "purchaseAddOn"
47
50
  | "passwordChange"
48
51
  | "teamMembers";
49
- mode?: "popup" | "embed";
52
+ mode?: OutsetaWidgetMode;
50
53
  stateProps?: Record<string, unknown>;
51
54
  [key: string]: unknown;
52
55
  }
53
56
 
57
+ export interface SupportWidgetOptions {
58
+ mode?: OutsetaWidgetMode;
59
+ [key: string]: unknown;
60
+ }
61
+
62
+ export interface EmailListWidgetOptions {
63
+ emailListUid: string;
64
+ mode?: OutsetaWidgetMode;
65
+ [key: string]: unknown;
66
+ }
67
+
68
+ export interface LeadCaptureWidgetOptions {
69
+ formUid: string;
70
+ mode?: OutsetaWidgetMode;
71
+ [key: string]: unknown;
72
+ }
73
+
54
74
  export interface OutsetaEmbed {
55
75
  setAccessToken: (token: string) => void;
56
76
  getAccessToken: () => Promise<string | null>;
@@ -63,6 +83,15 @@ export interface OutsetaEmbed {
63
83
  profile: {
64
84
  open: (options?: ProfileWidgetOptions) => void;
65
85
  };
86
+ support?: {
87
+ open: (options?: SupportWidgetOptions) => void;
88
+ };
89
+ emailList?: {
90
+ open: (options: EmailListWidgetOptions) => void;
91
+ };
92
+ leadCapture?: {
93
+ open: (options: LeadCaptureWidgetOptions) => void;
94
+ };
66
95
  }
67
96
 
68
97
  declare global {
@@ -0,0 +1,13 @@
1
+ import type { MouseEventHandler } from "react";
2
+
3
+ export function composeButtonClick(
4
+ onClick: MouseEventHandler<HTMLButtonElement> | undefined,
5
+ action: () => void,
6
+ ): MouseEventHandler<HTMLButtonElement> {
7
+ return (event) => {
8
+ onClick?.(event);
9
+ if (!event.defaultPrevented) {
10
+ action();
11
+ }
12
+ };
13
+ }
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  useMutation,
4
4
  useQuery
5
- } from '@tanstack/react-query';
5
+ } from '../../query';
6
6
  import type {
7
7
  MutationFunction,
8
8
  QueryFunction,
@@ -11,7 +11,7 @@ import type {
11
11
  UseMutationResult,
12
12
  UseQueryOptions,
13
13
  UseQueryResult
14
- } from '@tanstack/react-query';
14
+ } from '../../query';
15
15
 
16
16
  import type {
17
17
  Activity,
@@ -1,13 +1,13 @@
1
1
  // @ts-nocheck
2
2
  import {
3
3
  useQuery
4
- } from '@tanstack/react-query';
4
+ } from '../../query';
5
5
  import type {
6
6
  QueryFunction,
7
7
  QueryKey,
8
8
  UseQueryOptions,
9
9
  UseQueryResult
10
- } from '@tanstack/react-query';
10
+ } from '../../query';
11
11
 
12
12
  import type {
13
13
  Definition
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  useMutation,
4
4
  useQuery
5
- } from '@tanstack/react-query';
5
+ } from '../../query';
6
6
  import type {
7
7
  MutationFunction,
8
8
  QueryFunction,
@@ -11,7 +11,7 @@ import type {
11
11
  UseMutationResult,
12
12
  UseQueryOptions,
13
13
  UseQueryResult
14
- } from '@tanstack/react-query';
14
+ } from '../../query';
15
15
 
16
16
  import type {
17
17
  DiscountCoupon,
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  useMutation,
4
4
  useQuery
5
- } from '@tanstack/react-query';
5
+ } from '../../query';
6
6
  import type {
7
7
  MutationFunction,
8
8
  QueryFunction,
@@ -11,7 +11,7 @@ import type {
11
11
  UseMutationResult,
12
12
  UseQueryOptions,
13
13
  UseQueryResult
14
- } from '@tanstack/react-query';
14
+ } from '../../query';
15
15
 
16
16
  import type {
17
17
  Account,
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  useMutation,
4
4
  useQuery
5
- } from '@tanstack/react-query';
5
+ } from '../../query';
6
6
  import type {
7
7
  MutationFunction,
8
8
  QueryFunction,
@@ -11,7 +11,7 @@ import type {
11
11
  UseMutationResult,
12
12
  UseQueryOptions,
13
13
  UseQueryResult
14
- } from '@tanstack/react-query';
14
+ } from '../../query';
15
15
 
16
16
  import type {
17
17
  BroadcastCampaign,
@@ -3785,6 +3785,11 @@ export type MessageAllOf = {
3785
3785
  * @nullable
3786
3786
  */
3787
3787
  Subject?: string | null;
3788
+ /**
3789
+ * @maxLength 250
3790
+ * @nullable
3791
+ */
3792
+ PreviewText?: string | null;
3788
3793
  /** @nullable */
3789
3794
  Body?: string | null;
3790
3795
  /** @nullable */
@@ -1,12 +1,12 @@
1
1
  // @ts-nocheck
2
2
  import {
3
3
  useMutation
4
- } from '@tanstack/react-query';
4
+ } from '../../query';
5
5
  import type {
6
6
  MutationFunction,
7
7
  UseMutationOptions,
8
8
  UseMutationResult
9
- } from '@tanstack/react-query';
9
+ } from '../../query';
10
10
 
11
11
  import type {
12
12
  AuthGetTokenParams,
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  useMutation,
4
4
  useQuery
5
- } from '@tanstack/react-query';
5
+ } from '../../query';
6
6
  import type {
7
7
  MutationFunction,
8
8
  QueryFunction,
@@ -11,7 +11,7 @@ import type {
11
11
  UseMutationResult,
12
12
  UseQueryOptions,
13
13
  UseQueryResult
14
- } from '@tanstack/react-query';
14
+ } from '../../query';
15
15
 
16
16
  import type {
17
17
  Article,
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ export type { OutsetaCredentials } from "@outseta/api-client";
4
4
  // Outseta embed components and hooks
5
5
  export * from "./components/index.js";
6
6
 
7
- // Generated React Query hooks and schema types
7
+ // Generated React hooks and schema types
8
8
  export * from "./generated/activity/activity.js";
9
9
  export * from "./generated/billing/billing.js";
10
10
  export * from "./generated/crm/crm.js";
package/src/mutator.ts CHANGED
@@ -9,11 +9,11 @@ type RequestConfig<T = unknown> = {
9
9
  };
10
10
 
11
11
  /**
12
- * Orval react-query mutator.
12
+ * Orval mutator for the generated React hooks.
13
13
  *
14
14
  * First arg is a request config object `{ url, method, params, signal }`.
15
- * Second arg is a client from `createClient()` (provided via the React
16
- * context/provider).
15
+ * Second arg is a client from `createClient()` (passed through each hook's
16
+ * `request` option).
17
17
  */
18
18
  export const customFetch = async <T>(
19
19
  config: RequestConfig,
@@ -0,0 +1,276 @@
1
+ import { act, renderHook, waitFor } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { customFetch } from "./mutator.js";
5
+ import { useMutation, useQuery } from "./query.js";
6
+
7
+ afterEach(() => {
8
+ vi.restoreAllMocks();
9
+ });
10
+
11
+ describe("useQuery", () => {
12
+ it("fetches on mount and exposes the resolved data", async () => {
13
+ const queryFn = vi.fn().mockResolvedValue("hello");
14
+
15
+ const { result } = renderHook(() =>
16
+ useQuery({ queryKey: ["greeting"], queryFn }),
17
+ );
18
+
19
+ // Pending + fetching on the first commit.
20
+ expect(result.current.isLoading).toBe(true);
21
+ expect(result.current.data).toBeUndefined();
22
+
23
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
24
+
25
+ expect(result.current.data).toBe("hello");
26
+ expect(result.current.error).toBeNull();
27
+ expect(result.current.isFetching).toBe(false);
28
+ expect(queryFn).toHaveBeenCalledTimes(1);
29
+ });
30
+
31
+ it("passes an AbortSignal and the queryKey to the query function", async () => {
32
+ const queryFn = vi.fn().mockResolvedValue(42);
33
+
34
+ renderHook(() => useQuery({ queryKey: ["items", { page: 2 }], queryFn }));
35
+
36
+ await waitFor(() => expect(queryFn).toHaveBeenCalled());
37
+
38
+ const context = queryFn.mock.calls[0][0];
39
+ expect(context.signal).toBeInstanceOf(AbortSignal);
40
+ expect(context.queryKey).toEqual(["items", { page: 2 }]);
41
+ });
42
+
43
+ it("captures a rejection as an error state", async () => {
44
+ const error = new Error("boom");
45
+ const queryFn = vi.fn().mockRejectedValue(error);
46
+
47
+ const { result } = renderHook(() =>
48
+ useQuery({ queryKey: ["fails"], queryFn }),
49
+ );
50
+
51
+ await waitFor(() => expect(result.current.isError).toBe(true));
52
+
53
+ expect(result.current.error).toBe(error);
54
+ expect(result.current.data).toBeUndefined();
55
+ expect(result.current.isSuccess).toBe(false);
56
+ });
57
+
58
+ it("does not fetch while disabled", () => {
59
+ const queryFn = vi.fn();
60
+
61
+ const { result } = renderHook(() =>
62
+ useQuery({ queryKey: ["off"], queryFn, enabled: false }),
63
+ );
64
+
65
+ expect(queryFn).not.toHaveBeenCalled();
66
+ expect(result.current.isLoading).toBe(false);
67
+ expect(result.current.isPending).toBe(true);
68
+ });
69
+
70
+ it("re-runs the query via refetch", async () => {
71
+ const queryFn = vi
72
+ .fn()
73
+ .mockResolvedValueOnce("first")
74
+ .mockResolvedValueOnce("second");
75
+
76
+ const { result } = renderHook(() =>
77
+ useQuery({ queryKey: ["refetch"], queryFn }),
78
+ );
79
+
80
+ await waitFor(() => expect(result.current.data).toBe("first"));
81
+
82
+ await act(async () => {
83
+ await result.current.refetch();
84
+ });
85
+
86
+ expect(result.current.data).toBe("second");
87
+ expect(queryFn).toHaveBeenCalledTimes(2);
88
+ });
89
+
90
+ it("refetches when the query key changes", async () => {
91
+ const queryFn = vi.fn(
92
+ (context: { queryKey: ReadonlyArray<unknown> }) =>
93
+ Promise.resolve(context.queryKey[1]),
94
+ );
95
+
96
+ const { result, rerender } = renderHook(
97
+ ({ id }: { id: number }) => useQuery({ queryKey: ["item", id], queryFn }),
98
+ { initialProps: { id: 1 } },
99
+ );
100
+
101
+ await waitFor(() => expect(result.current.data).toBe(1));
102
+
103
+ rerender({ id: 2 });
104
+
105
+ await waitFor(() => expect(result.current.data).toBe(2));
106
+ expect(queryFn).toHaveBeenCalledTimes(2);
107
+ });
108
+
109
+ it("applies the select transform", async () => {
110
+ const queryFn = vi.fn().mockResolvedValue({ items: [1, 2, 3] });
111
+
112
+ const { result } = renderHook(() =>
113
+ useQuery({
114
+ queryKey: ["select"],
115
+ queryFn,
116
+ select: (data: { items: number[] }) => data.items.length,
117
+ }),
118
+ );
119
+
120
+ await waitFor(() => expect(result.current.data).toBe(3));
121
+ });
122
+
123
+ it("seeds state from initialData without an immediate fetch flag", async () => {
124
+ const queryFn = vi.fn().mockResolvedValue("fresh");
125
+
126
+ const { result } = renderHook(() =>
127
+ useQuery({ queryKey: ["seed"], queryFn, initialData: "seeded" }),
128
+ );
129
+
130
+ expect(result.current.isSuccess).toBe(true);
131
+ expect(result.current.data).toBe("seeded");
132
+
133
+ // It still revalidates on mount.
134
+ await waitFor(() => expect(result.current.data).toBe("fresh"));
135
+ });
136
+ });
137
+
138
+ describe("useMutation", () => {
139
+ it("runs the mutation and surfaces the result", async () => {
140
+ const mutationFn = vi.fn().mockResolvedValue({ id: 1 });
141
+ const onSuccess = vi.fn();
142
+ const onSettled = vi.fn();
143
+
144
+ const { result } = renderHook(() =>
145
+ useMutation({ mutationFn, onSuccess, onSettled }),
146
+ );
147
+
148
+ expect(result.current.isIdle).toBe(true);
149
+
150
+ act(() => {
151
+ result.current.mutate({ name: "acme" });
152
+ });
153
+
154
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
155
+
156
+ expect(result.current.data).toEqual({ id: 1 });
157
+ expect(result.current.variables).toEqual({ name: "acme" });
158
+ expect(mutationFn).toHaveBeenCalledWith({ name: "acme" });
159
+ expect(onSuccess).toHaveBeenCalledWith({ id: 1 }, { name: "acme" }, undefined);
160
+ expect(onSettled).toHaveBeenCalledWith(
161
+ { id: 1 },
162
+ null,
163
+ { name: "acme" },
164
+ undefined,
165
+ );
166
+ });
167
+
168
+ it("invokes per-call callbacks in addition to hook-level ones", async () => {
169
+ const mutationFn = vi.fn().mockResolvedValue("ok");
170
+ const hookOnSuccess = vi.fn();
171
+ const callOnSuccess = vi.fn();
172
+
173
+ const { result } = renderHook(() =>
174
+ useMutation({ mutationFn, onSuccess: hookOnSuccess }),
175
+ );
176
+
177
+ await act(async () => {
178
+ await result.current.mutateAsync("payload", { onSuccess: callOnSuccess });
179
+ });
180
+
181
+ expect(hookOnSuccess).toHaveBeenCalledOnce();
182
+ expect(callOnSuccess).toHaveBeenCalledOnce();
183
+ });
184
+
185
+ it("captures errors and rejects mutateAsync, but not mutate", async () => {
186
+ const error = new Error("nope");
187
+ const mutationFn = vi.fn().mockRejectedValue(error);
188
+ const onError = vi.fn();
189
+
190
+ const { result } = renderHook(() => useMutation({ mutationFn, onError }));
191
+
192
+ await act(async () => {
193
+ await expect(result.current.mutateAsync("x")).rejects.toThrow("nope");
194
+ });
195
+
196
+ expect(result.current.isError).toBe(true);
197
+ expect(result.current.error).toBe(error);
198
+ expect(onError).toHaveBeenCalledWith(error, "x", undefined);
199
+
200
+ // `mutate` is fire-and-forget: it must not throw an unhandled rejection.
201
+ await act(async () => {
202
+ result.current.mutate("y");
203
+ await Promise.resolve();
204
+ });
205
+ await waitFor(() => expect(result.current.error).toBe(error));
206
+ });
207
+
208
+ it("resets back to the idle state", async () => {
209
+ const mutationFn = vi.fn().mockResolvedValue("done");
210
+
211
+ const { result } = renderHook(() => useMutation({ mutationFn }));
212
+
213
+ await act(async () => {
214
+ await result.current.mutateAsync("v");
215
+ });
216
+ expect(result.current.isSuccess).toBe(true);
217
+
218
+ act(() => {
219
+ result.current.reset();
220
+ });
221
+
222
+ expect(result.current.isIdle).toBe(true);
223
+ expect(result.current.data).toBeUndefined();
224
+ expect(result.current.error).toBeNull();
225
+ });
226
+ });
227
+
228
+ describe("integration with the request mutator", () => {
229
+ it("drives a query through customFetch and a fake client", async () => {
230
+ const payload = [{ Uid: "deal-1" }];
231
+ const client = vi.fn(async () =>
232
+ new Response(JSON.stringify(payload), {
233
+ status: 200,
234
+ headers: { "content-type": "application/json" },
235
+ }),
236
+ );
237
+
238
+ const { result } = renderHook(() =>
239
+ useQuery({
240
+ queryKey: ["/api/v1/crm/deals"],
241
+ queryFn: ({ signal }) =>
242
+ customFetch(
243
+ { url: "/api/v1/crm/deals", method: "GET", signal },
244
+ client,
245
+ ),
246
+ }),
247
+ );
248
+
249
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
250
+
251
+ expect(result.current.data).toEqual(payload);
252
+ expect(client).toHaveBeenCalledOnce();
253
+ expect(client).toHaveBeenCalledWith(
254
+ "/api/v1/crm/deals",
255
+ expect.anything(),
256
+ );
257
+ });
258
+
259
+ it("turns a non-ok response into an error", async () => {
260
+ const client = vi.fn(async () => new Response("nope", { status: 500 }));
261
+
262
+ const { result } = renderHook(() =>
263
+ useQuery({
264
+ queryKey: ["/api/v1/crm/deals", "500"],
265
+ queryFn: ({ signal }) =>
266
+ customFetch(
267
+ { url: "/api/v1/crm/deals", method: "GET", signal },
268
+ client,
269
+ ),
270
+ }),
271
+ );
272
+
273
+ await waitFor(() => expect(result.current.isError).toBe(true));
274
+ expect(result.current.error).toBeInstanceOf(Error);
275
+ });
276
+ });