@carlonicora/nextjs-jsonapi 1.80.0 → 1.82.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.
Files changed (29) hide show
  1. package/dist/{BlockNoteEditor-3M5PD3BZ.mjs → BlockNoteEditor-N2L7X6OY.mjs} +2 -2
  2. package/dist/{BlockNoteEditor-YLTPJPTV.js → BlockNoteEditor-OH44RND6.js} +6 -6
  3. package/dist/{BlockNoteEditor-YLTPJPTV.js.map → BlockNoteEditor-OH44RND6.js.map} +1 -1
  4. package/dist/billing/index.js +299 -299
  5. package/dist/billing/index.mjs +1 -1
  6. package/dist/{chunk-4NOQNTFI.js → chunk-EZYSORIZ.js} +288 -60
  7. package/dist/chunk-EZYSORIZ.js.map +1 -0
  8. package/dist/{chunk-NQV5RDCK.mjs → chunk-KZRM55JX.mjs} +1398 -1170
  9. package/dist/chunk-KZRM55JX.mjs.map +1 -0
  10. package/dist/client/index.js +2 -2
  11. package/dist/client/index.mjs +1 -1
  12. package/dist/components/index.d.mts +26 -3
  13. package/dist/components/index.d.ts +26 -3
  14. package/dist/components/index.js +6 -2
  15. package/dist/components/index.js.map +1 -1
  16. package/dist/components/index.mjs +5 -1
  17. package/dist/contexts/index.js +2 -2
  18. package/dist/contexts/index.mjs +1 -1
  19. package/package.json +1 -1
  20. package/src/components/forms/CommonDeleter.tsx +4 -2
  21. package/src/components/grids/ContentListGrid.tsx +86 -0
  22. package/src/components/grids/__tests__/ContentListGrid.test.tsx +153 -0
  23. package/src/components/grids/index.ts +1 -0
  24. package/src/components/index.ts +2 -0
  25. package/src/features/rbac/components/RbacByRoleContainer.tsx +270 -0
  26. package/src/features/rbac/index.ts +1 -0
  27. package/dist/chunk-4NOQNTFI.js.map +0 -1
  28. package/dist/chunk-NQV5RDCK.mjs.map +0 -1
  29. /package/dist/{BlockNoteEditor-3M5PD3BZ.mjs.map → BlockNoteEditor-N2L7X6OY.mjs.map} +0 -0
@@ -0,0 +1,153 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { render, screen, act } from "@testing-library/react";
3
+ import { ContentListGrid } from "../ContentListGrid";
4
+ import { DataListRetriever } from "../../../hooks";
5
+
6
+ vi.mock("../../tables/ContentTableSearch", () => ({
7
+ ContentTableSearch: () => <div data-testid="content-table-search">Search</div>,
8
+ }));
9
+
10
+ type Item = { id: string; title: string };
11
+
12
+ function createMockDataRetriever(overrides: Partial<DataListRetriever<Item>> = {}): DataListRetriever<Item> {
13
+ return {
14
+ ready: true,
15
+ setReady: vi.fn(),
16
+ isLoaded: true,
17
+ data: [],
18
+ search: vi.fn(),
19
+ refresh: vi.fn(),
20
+ addAdditionalParameter: vi.fn(),
21
+ removeAdditionalParameter: vi.fn(),
22
+ setRefreshedElement: vi.fn(),
23
+ removeElement: vi.fn(),
24
+ isSearch: false,
25
+ ...overrides,
26
+ } as DataListRetriever<Item>;
27
+ }
28
+
29
+ const mockModule = {
30
+ name: "items",
31
+ model: class MockItem {},
32
+ icon: ({ className }: { className?: string }) => <svg data-testid="module-icon" className={className} />,
33
+ } as any;
34
+
35
+ function ItemComponent({ item }: { item: Item }) {
36
+ return <div data-testid={`item-${item.id}`}>{item.title}</div>;
37
+ }
38
+
39
+ describe("ContentListGrid", () => {
40
+ let observerCallback: ((entries: IntersectionObserverEntry[]) => void) | null;
41
+ let observerInstance: { observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> } | null;
42
+
43
+ beforeEach(() => {
44
+ vi.clearAllMocks();
45
+ observerCallback = null;
46
+ observerInstance = null;
47
+ (globalThis as any).IntersectionObserver = class {
48
+ observe = vi.fn();
49
+ disconnect = vi.fn();
50
+ constructor(cb: any) {
51
+ observerCallback = cb;
52
+ observerInstance = { observe: this.observe, disconnect: this.disconnect };
53
+ }
54
+ };
55
+ });
56
+
57
+ it("renders the title and module icon", () => {
58
+ const data = createMockDataRetriever({
59
+ data: [{ id: "1", title: "Hello" }],
60
+ });
61
+ render(
62
+ <ContentListGrid<Item>
63
+ data={data}
64
+ tableGeneratorType={mockModule}
65
+ ItemComponent={ItemComponent}
66
+ title="My Items"
67
+ />,
68
+ );
69
+
70
+ expect(screen.getByText("My Items")).toBeInTheDocument();
71
+ expect(screen.getByTestId("module-icon")).toBeInTheDocument();
72
+ });
73
+
74
+ it("renders one ItemComponent per item with stable keys", () => {
75
+ const data = createMockDataRetriever({
76
+ data: [
77
+ { id: "a", title: "First" },
78
+ { id: "b", title: "Second" },
79
+ ],
80
+ });
81
+ render(<ContentListGrid<Item> data={data} tableGeneratorType={mockModule} ItemComponent={ItemComponent} />);
82
+
83
+ expect(screen.getByTestId("item-a")).toHaveTextContent("First");
84
+ expect(screen.getByTestId("item-b")).toHaveTextContent("Second");
85
+ });
86
+
87
+ it("renders the empty-state copy when there are no items", () => {
88
+ const data = createMockDataRetriever({ data: [] });
89
+ render(<ContentListGrid<Item> data={data} tableGeneratorType={mockModule} ItemComponent={ItemComponent} />);
90
+
91
+ expect(screen.getByText("No results.")).toBeInTheDocument();
92
+ });
93
+
94
+ it("hides search by default and shows it when allowSearch is true", () => {
95
+ const data = createMockDataRetriever({ data: [{ id: "1", title: "x" }] });
96
+ const { rerender } = render(
97
+ <ContentListGrid<Item> data={data} tableGeneratorType={mockModule} ItemComponent={ItemComponent} title="t" />,
98
+ );
99
+ expect(screen.queryByTestId("content-table-search")).not.toBeInTheDocument();
100
+
101
+ rerender(
102
+ <ContentListGrid<Item>
103
+ data={data}
104
+ tableGeneratorType={mockModule}
105
+ ItemComponent={ItemComponent}
106
+ title="t"
107
+ allowSearch
108
+ />,
109
+ );
110
+ expect(screen.getByTestId("content-table-search")).toBeInTheDocument();
111
+ });
112
+
113
+ it("does not render the sentinel when data.next is undefined", () => {
114
+ const data = createMockDataRetriever({ data: [{ id: "1", title: "x" }], next: undefined });
115
+ const { container } = render(
116
+ <ContentListGrid<Item> data={data} tableGeneratorType={mockModule} ItemComponent={ItemComponent} />,
117
+ );
118
+ // Sentinel is the only 1px-tall div inline-styled; assert via inline style probe.
119
+ const sentinel = container.querySelector('div[style*="height: 1px"]');
120
+ expect(sentinel).toBeNull();
121
+ });
122
+
123
+ it("calls data.next when the sentinel intersects", () => {
124
+ const next = vi.fn();
125
+ const data = createMockDataRetriever({
126
+ data: [{ id: "1", title: "x" }],
127
+ next,
128
+ });
129
+ render(<ContentListGrid<Item> data={data} tableGeneratorType={mockModule} ItemComponent={ItemComponent} />);
130
+
131
+ expect(observerInstance?.observe).toHaveBeenCalled();
132
+
133
+ act(() => {
134
+ observerCallback?.([{ isIntersecting: true } as IntersectionObserverEntry]);
135
+ });
136
+
137
+ expect(next).toHaveBeenCalledTimes(1);
138
+ });
139
+
140
+ it("uses gridClassName when supplied", () => {
141
+ const data = createMockDataRetriever({ data: [{ id: "1", title: "x" }] });
142
+ const { container } = render(
143
+ <ContentListGrid<Item>
144
+ data={data}
145
+ tableGeneratorType={mockModule}
146
+ ItemComponent={ItemComponent}
147
+ gridClassName="my-custom-grid"
148
+ />,
149
+ );
150
+ const grid = container.querySelector(".my-custom-grid");
151
+ expect(grid).not.toBeNull();
152
+ });
153
+ });
@@ -0,0 +1 @@
1
+ export * from "./ContentListGrid";
@@ -11,6 +11,7 @@ export * from "./forms";
11
11
  export * from "./navigations";
12
12
  export * from "./pages";
13
13
  export * from "./tables";
14
+ export * from "./grids";
14
15
  export * from "./fiscal";
15
16
  export { parseFiscalData } from "../utils/fiscal-utils";
16
17
 
@@ -31,6 +32,7 @@ export * from "../features/user/components";
31
32
  export * from "../features/oauth/components";
32
33
  export * from "../features/waitlist/components";
33
34
  export { RbacContainer } from "../features/rbac/components/RbacContainer";
35
+ export { RbacByRoleContainer } from "../features/rbac/components/RbacByRoleContainer";
34
36
  export { RbacPermissionCell } from "../features/rbac/components/RbacPermissionCell";
35
37
  export { RbacPermissionPicker } from "../features/rbac/components/RbacPermissionPicker";
36
38
 
@@ -0,0 +1,270 @@
1
+ "use client";
2
+
3
+ import { RoundPageContainer } from "@/components";
4
+ import { cn } from "@/lib/utils";
5
+ import { Loader2Icon } from "lucide-react";
6
+ import { useTranslations } from "next-intl";
7
+ import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
8
+ import { useRbacContext } from "../contexts/RbacContext";
9
+ import { ACTION_TYPES, ActionType, PermissionValue, type PermToken } from "../data/RbacTypes";
10
+ import RbacPermissionCell from "./RbacPermissionCell";
11
+ import { RbacPermissionPicker } from "./RbacPermissionPicker";
12
+
13
+ function findToken(tokens: PermToken[] | undefined, action: ActionType): PermToken | undefined {
14
+ if (!tokens) return undefined;
15
+ return tokens.find((t) => t.action === action);
16
+ }
17
+
18
+ function cellValue(tokens: PermToken[] | undefined, action: ActionType): PermissionValue | undefined {
19
+ const tok = findToken(tokens, action);
20
+ if (!tok) return undefined;
21
+ return tok.scope;
22
+ }
23
+
24
+ interface ActivePicker {
25
+ moduleId: string;
26
+ rowKey: string;
27
+ action: ActionType;
28
+ isRoleColumn: boolean;
29
+ anchor: HTMLElement;
30
+ }
31
+
32
+ interface CellButtonProps {
33
+ moduleId: string;
34
+ rowKey: string;
35
+ action: ActionType;
36
+ tokens: PermToken[] | undefined;
37
+ isRoleColumn: boolean;
38
+ onOpen: (picker: ActivePicker) => void;
39
+ }
40
+
41
+ const CellButton = memo(function CellButton({
42
+ moduleId,
43
+ rowKey,
44
+ action,
45
+ tokens,
46
+ isRoleColumn,
47
+ onOpen,
48
+ }: CellButtonProps) {
49
+ const ref = useRef<HTMLDivElement>(null);
50
+ const value = cellValue(tokens, action);
51
+
52
+ const handleClick = useCallback(() => {
53
+ if (!ref.current) return;
54
+ onOpen({ moduleId, rowKey, action, isRoleColumn, anchor: ref.current });
55
+ }, [onOpen, moduleId, rowKey, action, isRoleColumn]);
56
+
57
+ return (
58
+ <div ref={ref}>
59
+ <RbacPermissionCell value={value} isRoleColumn={isRoleColumn} onClick={handleClick} />
60
+ </div>
61
+ );
62
+ });
63
+
64
+ const ACTION_LABELS: Record<ActionType, string> = {
65
+ read: "Read",
66
+ create: "Create",
67
+ update: "Update",
68
+ delete: "Delete",
69
+ };
70
+
71
+ export default function RbacByRoleContainer() {
72
+ const t = useTranslations();
73
+ const { matrix, modulePaths, loading, error, roleNames, moduleNames, updateCell, clearCell } = useRbacContext();
74
+
75
+ const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
76
+ const [activePicker, setActivePicker] = useState<ActivePicker | null>(null);
77
+
78
+ const openPicker = useCallback((picker: ActivePicker) => {
79
+ setActivePicker(picker);
80
+ }, []);
81
+
82
+ const closePicker = useCallback(() => {
83
+ setActivePicker(null);
84
+ }, []);
85
+
86
+ const handleSelectRole = useCallback((id: string) => {
87
+ setSelectedRoleId(id);
88
+ setActivePicker(null);
89
+ }, []);
90
+
91
+ const sortedRoleIds = useMemo(() => {
92
+ if (!roleNames) return [];
93
+ return Object.keys(roleNames).sort((a, b) => (roleNames[a] ?? a).localeCompare(roleNames[b] ?? b));
94
+ }, [roleNames]);
95
+
96
+ const sortedModuleIds = useMemo(() => {
97
+ if (!matrix) return [];
98
+ return Object.keys(matrix).sort((a, b) => (moduleNames?.[a] ?? a).localeCompare(moduleNames?.[b] ?? b));
99
+ }, [matrix, moduleNames]);
100
+
101
+ useEffect(() => {
102
+ if (!selectedRoleId && sortedRoleIds.length > 0) {
103
+ setSelectedRoleId(sortedRoleIds[0]);
104
+ }
105
+ }, [selectedRoleId, sortedRoleIds]);
106
+
107
+ const activeValue = useMemo<PermissionValue | undefined>(() => {
108
+ if (!activePicker || !matrix) return undefined;
109
+ const block = matrix[activePicker.moduleId];
110
+ if (!block) return undefined;
111
+ const tokens =
112
+ activePicker.rowKey === "default" ? block.default : (block as Record<string, PermToken[]>)[activePicker.rowKey];
113
+ return cellValue(tokens, activePicker.action);
114
+ }, [activePicker, matrix]);
115
+
116
+ const activeSegments = useMemo<string[]>(() => {
117
+ if (!activePicker) return [];
118
+ return (modulePaths[activePicker.moduleId] as string[] | undefined) ?? [];
119
+ }, [activePicker, modulePaths]);
120
+
121
+ const handleSetValue = useCallback(
122
+ (value: PermissionValue) => {
123
+ if (!activePicker) return;
124
+ updateCell(activePicker.moduleId, activePicker.rowKey, activePicker.action, value);
125
+ },
126
+ [activePicker, updateCell],
127
+ );
128
+
129
+ const handleClear = useCallback(() => {
130
+ if (!activePicker || !activePicker.isRoleColumn) return;
131
+ clearCell(activePicker.moduleId, activePicker.rowKey, activePicker.action);
132
+ }, [activePicker, clearCell]);
133
+
134
+ if (loading) {
135
+ return (
136
+ <RoundPageContainer fullWidth>
137
+ <div className="flex h-full items-center justify-center">
138
+ <Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
139
+ </div>
140
+ </RoundPageContainer>
141
+ );
142
+ }
143
+
144
+ if (error) {
145
+ return (
146
+ <RoundPageContainer fullWidth>
147
+ <div className="flex h-full items-center justify-center">
148
+ <p className="text-destructive">{error}</p>
149
+ </div>
150
+ </RoundPageContainer>
151
+ );
152
+ }
153
+
154
+ if (!matrix || !selectedRoleId) return null;
155
+
156
+ return (
157
+ <RoundPageContainer fullWidth forceHeader>
158
+ <div className="flex h-full w-full">
159
+ <aside className="w-60 shrink-0 overflow-y-auto border-r bg-muted/20">
160
+ <ul className="py-1">
161
+ {sortedRoleIds.map((id) => (
162
+ <li key={id}>
163
+ <button
164
+ type="button"
165
+ onClick={() => handleSelectRole(id)}
166
+ aria-current={id === selectedRoleId ? "true" : undefined}
167
+ className={cn(
168
+ "block w-full px-4 py-1.5 text-left text-sm hover:bg-muted",
169
+ id === selectedRoleId && "bg-muted font-medium text-foreground",
170
+ id !== selectedRoleId && "text-muted-foreground",
171
+ )}
172
+ >
173
+ {roleNames?.[id] ?? id}
174
+ </button>
175
+ </li>
176
+ ))}
177
+ </ul>
178
+ </aside>
179
+
180
+ <section className="flex-1 overflow-y-auto p-4">
181
+ {sortedModuleIds.length > 0 ? (
182
+ <div className="rounded-lg border border-accent bg-card">
183
+ <div className="overflow-x-auto">
184
+ <table className="w-full text-sm">
185
+ <thead className="sticky top-0 z-10">
186
+ <tr className="border-b bg-muted/80 backdrop-blur-sm">
187
+ <th className="w-40 px-4 py-2 text-left text-xs font-medium text-muted-foreground">
188
+ {t("rbac.module")}
189
+ </th>
190
+ {ACTION_TYPES.map((action) => (
191
+ <th
192
+ key={action}
193
+ className="min-w-28 px-2 py-2 text-center text-xs font-medium text-muted-foreground"
194
+ >
195
+ {ACTION_LABELS[action]}
196
+ </th>
197
+ ))}
198
+ </tr>
199
+ </thead>
200
+ <tbody>
201
+ {sortedModuleIds.map((moduleId) => {
202
+ const block = matrix[moduleId];
203
+ if (!block) return null;
204
+ const defaultTokens = block.default ?? [];
205
+ const roleTokens = (block as Record<string, PermToken[]>)[selectedRoleId];
206
+ const moduleLabel = moduleNames?.[moduleId] ?? moduleId;
207
+
208
+ return (
209
+ <Fragment key={moduleId}>
210
+ <tr className="border-b bg-muted/40">
211
+ <td
212
+ colSpan={ACTION_TYPES.length + 1}
213
+ className="px-4 py-1.5 text-xs font-bold text-muted-foreground"
214
+ >
215
+ {moduleLabel}
216
+ </td>
217
+ </tr>
218
+ <tr className="border-b bg-muted/20">
219
+ <td className="px-4 py-1 text-xs text-muted-foreground">{t("rbac.defaults")}</td>
220
+ {ACTION_TYPES.map((action) => (
221
+ <td key={action} className="px-2 py-1">
222
+ <RbacPermissionCell value={cellValue(defaultTokens, action)} />
223
+ </td>
224
+ ))}
225
+ </tr>
226
+ <tr className="border-b last:border-b-0">
227
+ <td className="px-4 py-1 text-xs font-medium text-muted-foreground">
228
+ {roleNames?.[selectedRoleId] ?? selectedRoleId}
229
+ </td>
230
+ {ACTION_TYPES.map((action) => (
231
+ <td key={action} className="px-2 py-1">
232
+ <CellButton
233
+ moduleId={moduleId}
234
+ rowKey={selectedRoleId}
235
+ action={action}
236
+ tokens={roleTokens}
237
+ isRoleColumn={true}
238
+ onOpen={openPicker}
239
+ />
240
+ </td>
241
+ ))}
242
+ </tr>
243
+ </Fragment>
244
+ );
245
+ })}
246
+ </tbody>
247
+ </table>
248
+ </div>
249
+ </div>
250
+ ) : (
251
+ <p className="text-muted-foreground text-sm">{t("rbac.select_role_prompt")}</p>
252
+ )}
253
+ </section>
254
+ </div>
255
+
256
+ <RbacPermissionPicker
257
+ open={!!activePicker}
258
+ anchor={activePicker?.anchor ?? null}
259
+ value={activeValue}
260
+ isRoleColumn={activePicker?.isRoleColumn ?? false}
261
+ knownSegments={activeSegments}
262
+ onSetValue={handleSetValue}
263
+ onClear={activePicker?.isRoleColumn ? handleClear : undefined}
264
+ onClose={closePicker}
265
+ />
266
+ </RoundPageContainer>
267
+ );
268
+ }
269
+
270
+ export { RbacByRoleContainer };
@@ -3,6 +3,7 @@ export * from "./data";
3
3
 
4
4
  // Components
5
5
  export { RbacContainer } from "./components/RbacContainer";
6
+ export { RbacByRoleContainer } from "./components/RbacByRoleContainer";
6
7
  export { RbacPermissionCell } from "./components/RbacPermissionCell";
7
8
  export { RbacPermissionPicker } from "./components/RbacPermissionPicker";
8
9