@carlonicora/nextjs-jsonapi 1.131.0 → 1.131.2
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/dist/{BlockNoteEditor-MCHL63TJ.js → BlockNoteEditor-5RUXGWLR.js} +9 -9
- package/dist/{BlockNoteEditor-MCHL63TJ.js.map → BlockNoteEditor-5RUXGWLR.js.map} +1 -1
- package/dist/{BlockNoteEditor-Q2GLKMFU.mjs → BlockNoteEditor-F4AKH532.mjs} +2 -2
- package/dist/billing/index.js +310 -310
- package/dist/billing/index.mjs +1 -1
- package/dist/{chunk-4TM6GOT3.mjs → chunk-BBBQGJEG.mjs} +134 -102
- package/dist/chunk-BBBQGJEG.mjs.map +1 -0
- package/dist/{chunk-PE6DSRTQ.js → chunk-T7F2EVPR.js} +266 -234
- package/dist/chunk-T7F2EVPR.js.map +1 -0
- package/dist/client/index.js +2 -2
- package/dist/client/index.mjs +1 -1
- package/dist/components/index.js +2 -2
- package/dist/components/index.mjs +1 -1
- package/dist/contexts/index.js +2 -2
- package/dist/contexts/index.mjs +1 -1
- package/dist/features/help/index.js +31 -31
- package/dist/features/help/index.mjs +1 -1
- package/package.json +1 -1
- package/src/components/forms/__tests__/useEditorDialog.test.ts +66 -0
- package/src/components/forms/useEditorDialog.ts +25 -0
- package/src/features/notification/contexts/__tests__/NotificationContext.spec.tsx +34 -8
- package/src/features/user/contexts/CurrentUserContext.tsx +43 -9
- package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +149 -6
- package/src/shadcnui/ui/sheet.tsx +7 -1
- package/dist/chunk-4TM6GOT3.mjs.map +0 -1
- package/dist/chunk-PE6DSRTQ.js.map +0 -1
- /package/dist/{BlockNoteEditor-Q2GLKMFU.mjs.map → BlockNoteEditor-F4AKH532.mjs.map} +0 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useAtom } from "jotai";
|
|
4
4
|
import { atomWithStorage } from "jotai/utils";
|
|
5
5
|
import { usePathname } from "next/navigation";
|
|
6
|
-
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
|
6
|
+
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
7
7
|
import { useSocketContext } from "../../../contexts/SocketContext";
|
|
8
8
|
import { Modules, rehydrate } from "../../../core";
|
|
9
9
|
import { Action, checkPermissions, ModuleWithPermissions } from "../../../permissions";
|
|
@@ -66,13 +66,31 @@ export const CurrentUserProvider = ({ children }: { children: React.ReactNode })
|
|
|
66
66
|
return descriptor?.get?.call(Modules);
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
-
const currentUser =
|
|
69
|
+
const currentUser = useMemo(
|
|
70
|
+
() => (dehydratedUser ? rehydrate<UserInterface>(Modules.User, dehydratedUser) : null),
|
|
71
|
+
[dehydratedUser],
|
|
72
|
+
);
|
|
70
73
|
|
|
71
|
-
|
|
74
|
+
// Balances pushed by company:tokens_updated. Transient: a real user refresh is
|
|
75
|
+
// authoritative and clears this.
|
|
76
|
+
const [tokenOverride, setTokenOverride] = useState<{
|
|
77
|
+
availableMonthlyTokens: number;
|
|
78
|
+
availableExtraTokens: number;
|
|
79
|
+
} | null>(null);
|
|
80
|
+
|
|
81
|
+
const company = useMemo<CompanyInterface | null>(() => {
|
|
82
|
+
const c = currentUser?.company ?? null;
|
|
83
|
+
if (!c || !tokenOverride) return c;
|
|
84
|
+
return Object.assign(Object.create(Object.getPrototypeOf(c)), c, {
|
|
85
|
+
_availableMonthlyTokens: tokenOverride.availableMonthlyTokens,
|
|
86
|
+
_availableExtraTokens: tokenOverride.availableExtraTokens,
|
|
87
|
+
});
|
|
88
|
+
}, [currentUser, tokenOverride]);
|
|
72
89
|
|
|
73
90
|
const setUser = (user?: UserInterface): void => {
|
|
74
91
|
if (user) setDehydratedUser(user.dehydrate() as any);
|
|
75
92
|
else setDehydratedUser(null);
|
|
93
|
+
setTokenOverride(null);
|
|
76
94
|
};
|
|
77
95
|
|
|
78
96
|
const hasRole = (roleId: string): boolean => {
|
|
@@ -159,6 +177,7 @@ export const CurrentUserProvider = ({ children }: { children: React.ReactNode })
|
|
|
159
177
|
}
|
|
160
178
|
|
|
161
179
|
setDehydratedUser(fullUser.dehydrate() as any);
|
|
180
|
+
setTokenOverride(null);
|
|
162
181
|
}
|
|
163
182
|
} catch (error) {
|
|
164
183
|
console.error("Failed to refresh user data:", error);
|
|
@@ -185,7 +204,23 @@ export const CurrentUserProvider = ({ children }: { children: React.ReactNode })
|
|
|
185
204
|
return;
|
|
186
205
|
}
|
|
187
206
|
|
|
188
|
-
|
|
207
|
+
// Hot path: fires once per LLM call. Fully described by its payload — must
|
|
208
|
+
// never trigger an HTTP request.
|
|
209
|
+
const handleTokensUpdated = (data: {
|
|
210
|
+
companyId: string;
|
|
211
|
+
availableMonthlyTokens: number;
|
|
212
|
+
availableExtraTokens: number;
|
|
213
|
+
}) => {
|
|
214
|
+
if (data.companyId !== currentUser.company?.id) return;
|
|
215
|
+
setTokenOverride({
|
|
216
|
+
availableMonthlyTokens: data.availableMonthlyTokens,
|
|
217
|
+
availableExtraTokens: data.availableExtraTokens,
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// Rare (Stripe webhooks only) and genuinely changes features, modules and
|
|
222
|
+
// permissions — a payload cannot economically carry that, so refetch.
|
|
223
|
+
const handleSubscriptionUpdated = (data: { companyId: string; type: string }) => {
|
|
189
224
|
if (data.companyId === currentUser.company?.id && !isRefreshingRef.current) {
|
|
190
225
|
isRefreshingRef.current = true;
|
|
191
226
|
// Skip cookie update to prevent page reload - only update React state
|
|
@@ -195,13 +230,12 @@ export const CurrentUserProvider = ({ children }: { children: React.ReactNode })
|
|
|
195
230
|
}
|
|
196
231
|
};
|
|
197
232
|
|
|
198
|
-
|
|
199
|
-
socket.on("company:
|
|
200
|
-
socket.on("company:subscription_updated", handleCompanyUpdate);
|
|
233
|
+
socket.on("company:tokens_updated", handleTokensUpdated);
|
|
234
|
+
socket.on("company:subscription_updated", handleSubscriptionUpdated);
|
|
201
235
|
|
|
202
236
|
return () => {
|
|
203
|
-
socket.off("company:tokens_updated",
|
|
204
|
-
socket.off("company:subscription_updated",
|
|
237
|
+
socket.off("company:tokens_updated", handleTokensUpdated);
|
|
238
|
+
socket.off("company:subscription_updated", handleSubscriptionUpdated);
|
|
205
239
|
};
|
|
206
240
|
}, [socket, isConnected, currentUser?.company?.id]);
|
|
207
241
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { render } from "@testing-library/react";
|
|
2
|
+
import { render, act } from "@testing-library/react";
|
|
3
3
|
|
|
4
4
|
vi.mock("cookies-next", () => ({
|
|
5
5
|
getCookie: vi.fn(() => undefined),
|
|
@@ -9,14 +9,54 @@ vi.mock("next/navigation", () => ({
|
|
|
9
9
|
usePathname: () => "/",
|
|
10
10
|
}));
|
|
11
11
|
|
|
12
|
+
const socketMock = vi.hoisted(() => {
|
|
13
|
+
const handlers: Record<string, ((data: any) => void)[]> = {};
|
|
14
|
+
return {
|
|
15
|
+
socket: {
|
|
16
|
+
on: (event: string, handler: (data: any) => void) => {
|
|
17
|
+
(handlers[event] ??= []).push(handler);
|
|
18
|
+
},
|
|
19
|
+
off: (event: string, handler: (data: any) => void) => {
|
|
20
|
+
handlers[event] = (handlers[event] ?? []).filter((h) => h !== handler);
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
emit: (event: string, data: any) => {
|
|
24
|
+
(handlers[event] ?? []).forEach((h) => h(data));
|
|
25
|
+
},
|
|
26
|
+
reset: () => {
|
|
27
|
+
for (const key of Object.keys(handlers)) delete handlers[key];
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
12
32
|
vi.mock("../../../../contexts/SocketContext", () => ({
|
|
13
|
-
useSocketContext: () => ({ socket:
|
|
33
|
+
useSocketContext: () => ({ socket: socketMock.socket, isConnected: true }),
|
|
14
34
|
}));
|
|
15
35
|
|
|
16
|
-
vi.mock("../../../../core", () =>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
36
|
+
vi.mock("../../../../core", () => {
|
|
37
|
+
// Mirrors the real Company model: public getters over private backing fields.
|
|
38
|
+
// The token overlay writes the backing fields, so a plain object would not
|
|
39
|
+
// exercise it.
|
|
40
|
+
class FakeCompany {
|
|
41
|
+
id = "";
|
|
42
|
+
monthlyTokens = 0;
|
|
43
|
+
_availableMonthlyTokens = 0;
|
|
44
|
+
_availableExtraTokens = 0;
|
|
45
|
+
get availableMonthlyTokens() {
|
|
46
|
+
return this._availableMonthlyTokens ?? 0;
|
|
47
|
+
}
|
|
48
|
+
get availableExtraTokens() {
|
|
49
|
+
return this._availableExtraTokens ?? 0;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
Modules: { User: { name: "users" } },
|
|
54
|
+
rehydrate: (_module: unknown, data: any) => ({
|
|
55
|
+
...data,
|
|
56
|
+
company: data.company ? Object.assign(new FakeCompany(), data.company) : null,
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
59
|
+
});
|
|
20
60
|
|
|
21
61
|
vi.mock("../../../../permissions", () => ({
|
|
22
62
|
Action: { Read: "read", Write: "write", Create: "create", Update: "update", Delete: "delete" },
|
|
@@ -139,3 +179,106 @@ describe("CurrentUserProvider", () => {
|
|
|
139
179
|
expect(getByTestId("current-user-id").textContent).toBe("new");
|
|
140
180
|
});
|
|
141
181
|
});
|
|
182
|
+
|
|
183
|
+
describe("CurrentUserProvider token updates", () => {
|
|
184
|
+
const STORED_USER = {
|
|
185
|
+
id: "user-1",
|
|
186
|
+
roles: [],
|
|
187
|
+
modules: [],
|
|
188
|
+
company: {
|
|
189
|
+
id: "company-1",
|
|
190
|
+
monthlyTokens: 1000,
|
|
191
|
+
_availableMonthlyTokens: 900,
|
|
192
|
+
_availableExtraTokens: 50,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
function CompanyConsumer() {
|
|
197
|
+
const { company } = useCurrentUserContext();
|
|
198
|
+
return (
|
|
199
|
+
<div data-testid="balances">
|
|
200
|
+
{company ? `${(company as any).availableMonthlyTokens}/${(company as any).availableExtraTokens}` : "null"}
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
beforeEach(() => {
|
|
206
|
+
localStorage.clear();
|
|
207
|
+
socketMock.reset();
|
|
208
|
+
vi.clearAllMocks();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("patches balances from company:tokens_updated without refetching the user", async () => {
|
|
212
|
+
localStorage.setItem("user", JSON.stringify(STORED_USER));
|
|
213
|
+
|
|
214
|
+
const { getByTestId } = render(
|
|
215
|
+
<CurrentUserProvider>
|
|
216
|
+
<CompanyConsumer />
|
|
217
|
+
</CurrentUserProvider>,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
221
|
+
expect(getByTestId("balances").textContent).toBe("900/50");
|
|
222
|
+
|
|
223
|
+
await act(async () => {
|
|
224
|
+
socketMock.emit("company:tokens_updated", {
|
|
225
|
+
type: "company:tokens_updated",
|
|
226
|
+
companyId: "company-1",
|
|
227
|
+
availableMonthlyTokens: 750,
|
|
228
|
+
availableExtraTokens: 40,
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(getByTestId("balances").textContent).toBe("750/40");
|
|
233
|
+
expect(UserService.findFullUser).not.toHaveBeenCalled();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("ignores company:tokens_updated for a different company", async () => {
|
|
237
|
+
localStorage.setItem("user", JSON.stringify(STORED_USER));
|
|
238
|
+
|
|
239
|
+
const { getByTestId } = render(
|
|
240
|
+
<CurrentUserProvider>
|
|
241
|
+
<CompanyConsumer />
|
|
242
|
+
</CurrentUserProvider>,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
246
|
+
|
|
247
|
+
await act(async () => {
|
|
248
|
+
socketMock.emit("company:tokens_updated", {
|
|
249
|
+
type: "company:tokens_updated",
|
|
250
|
+
companyId: "someone-else",
|
|
251
|
+
availableMonthlyTokens: 1,
|
|
252
|
+
availableExtraTokens: 1,
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
expect(getByTestId("balances").textContent).toBe("900/50");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("still refetches the full user on company:subscription_updated", async () => {
|
|
260
|
+
localStorage.setItem("user", JSON.stringify(STORED_USER));
|
|
261
|
+
vi.mocked(UserService.findFullUser).mockResolvedValue({
|
|
262
|
+
...STORED_USER,
|
|
263
|
+
dehydrate: () => STORED_USER,
|
|
264
|
+
} as any);
|
|
265
|
+
|
|
266
|
+
render(
|
|
267
|
+
<CurrentUserProvider>
|
|
268
|
+
<CompanyConsumer />
|
|
269
|
+
</CurrentUserProvider>,
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
273
|
+
|
|
274
|
+
await act(async () => {
|
|
275
|
+
socketMock.emit("company:subscription_updated", {
|
|
276
|
+
type: "company:subscription_updated",
|
|
277
|
+
companyId: "company-1",
|
|
278
|
+
});
|
|
279
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(UserService.findFullUser).toHaveBeenCalledTimes(1);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -48,7 +48,13 @@ function SheetContent({
|
|
|
48
48
|
}) {
|
|
49
49
|
return (
|
|
50
50
|
<SheetPortal>
|
|
51
|
-
|
|
51
|
+
{/* forceRender: Base UI skips the backdrop entirely for a NESTED dialog
|
|
52
|
+
(`enabled: forceRender || !nested`). Without it a sheet opened from
|
|
53
|
+
inside another sheet has no backdrop, so nothing covers the parent
|
|
54
|
+
popup — which Base UI has marked inert. Clicks there are swallowed by
|
|
55
|
+
`inert` and never reach the dismiss logic, so the nested sheet cannot
|
|
56
|
+
be closed by clicking outside it. Non-nested sheets are unaffected. */}
|
|
57
|
+
<SheetOverlay forceRender />
|
|
52
58
|
<SheetPrimitive.Popup
|
|
53
59
|
data-slot="sheet-content"
|
|
54
60
|
data-side={side}
|