@carlonicora/nextjs-jsonapi 1.59.0 → 1.60.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 (32) hide show
  1. package/dist/{BlockNoteEditor-LM45SVSD.js → BlockNoteEditor-56VMCPSG.js} +6 -6
  2. package/dist/{BlockNoteEditor-LM45SVSD.js.map → BlockNoteEditor-56VMCPSG.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-V46DP6BW.mjs → BlockNoteEditor-H7QM6EVJ.mjs} +2 -2
  4. package/dist/billing/index.js +299 -299
  5. package/dist/billing/index.mjs +1 -1
  6. package/dist/{chunk-FDTDSTD6.mjs → chunk-67522EQN.mjs} +2150 -2062
  7. package/dist/chunk-67522EQN.mjs.map +1 -0
  8. package/dist/{chunk-4QXIOFK5.js → chunk-JRKIV2DF.js} +213 -125
  9. package/dist/chunk-JRKIV2DF.js.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 -1
  13. package/dist/components/index.d.ts +26 -1
  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/dist/scripts/generate-web-module/templates/components/editor.template.d.ts.map +1 -1
  20. package/dist/scripts/generate-web-module/templates/components/editor.template.js +17 -39
  21. package/dist/scripts/generate-web-module/templates/components/editor.template.js.map +1 -1
  22. package/package.json +1 -1
  23. package/scripts/generate-web-module/templates/components/editor.template.ts +17 -39
  24. package/src/components/forms/CommonEditorDiscardDialog.tsx +40 -0
  25. package/src/components/forms/__tests__/CommonEditorDiscardDialog.test.tsx +43 -0
  26. package/src/components/forms/__tests__/useEditorDialog.test.ts +145 -0
  27. package/src/components/forms/index.ts +2 -0
  28. package/src/components/forms/useEditorDialog.ts +93 -0
  29. package/src/components/tables/ContentListTable.tsx +13 -14
  30. package/dist/chunk-4QXIOFK5.js.map +0 -1
  31. package/dist/chunk-FDTDSTD6.mjs.map +0 -1
  32. /package/dist/{BlockNoteEditor-V46DP6BW.mjs.map → BlockNoteEditor-H7QM6EVJ.mjs.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlonicora/nextjs-jsonapi",
3
- "version": "1.59.0",
3
+ "version": "1.60.0",
4
4
  "description": "Next.js JSON:API client with server/client support and caching",
5
5
  "author": "Carlo Nicora",
6
6
  "license": "GPL-3.0-or-later",
@@ -53,26 +53,9 @@ function ${names.pascalCase}EditorInternal({
53
53
  }: ${names.pascalCase}EditorProps) {
54
54
  const router = useRouter();
55
55
  const generateUrl = usePageUrlGenerator();
56
- const [open, setOpen] = useState<boolean>(false);
57
56
  const t = useTranslations();
58
57
  ${hasAuthor ? ` const { currentUser } = useCurrentUserContext<UserInterface>();` : ""}
59
58
 
60
- useEffect(() => {
61
- if (dialogOpen !== undefined) {
62
- setOpen(dialogOpen);
63
- }
64
- }, [dialogOpen]);
65
-
66
- useEffect(() => {
67
- if (typeof onDialogOpenChange === "function") {
68
- onDialogOpenChange(open);
69
- }
70
- }, [open, onDialogOpenChange]);
71
-
72
- useEffect(() => {
73
- if (forceShow) setOpen(true);
74
- }, [forceShow]);
75
-
76
59
  ${formSchema}
77
60
 
78
61
  ${defaultValues}
@@ -82,10 +65,19 @@ ${defaultValues}
82
65
  defaultValues: getDefaultValues(),
83
66
  });
84
67
 
68
+ const { dirtyFields } = form.formState;
69
+
70
+ const isFormDirty = useCallback(() => {
71
+ return Object.keys(dirtyFields).length > 0;
72
+ }, [dirtyFields]);
73
+
74
+ const { open, setOpen, handleOpenChange, discardDialogProps } = useEditorDialog(isFormDirty, {
75
+ dialogOpen, onDialogOpenChange, forceShow, onClose,
76
+ });
77
+
85
78
  useEffect(() => {
86
79
  if (!open) {
87
80
  form.reset(getDefaultValues());
88
- if (onClose) onClose();
89
81
  }
90
82
  }, [open]);
91
83
 
@@ -101,25 +93,9 @@ ${
101
93
 
102
94
  ${onSubmit}
103
95
 
104
- useEffect(() => {
105
- const handleKeyDown = (event: KeyboardEvent) => {
106
- if (event.key === "Escape" && open) {
107
- event.preventDefault();
108
- event.stopPropagation();
109
- }
110
- };
111
-
112
- if (open) {
113
- document.addEventListener("keydown", handleKeyDown, true);
114
- }
115
-
116
- return () => {
117
- document.removeEventListener("keydown", handleKeyDown, true);
118
- };
119
- }, [open]);
120
-
121
96
  return (
122
- <Dialog open={open} onOpenChange={setOpen}>
97
+ <>
98
+ <Dialog open={open} onOpenChange={handleOpenChange}>
123
99
  {dialogOpen === undefined && (trigger ? <DialogTrigger>{trigger}</DialogTrigger> : <CommonEditorTrigger isEdit={!!${names.camelCase}} />)}
124
100
  <DialogContent
125
101
  className="flex max-h-[70vh] max-w-3xl flex-col overflow-y-auto"
@@ -129,12 +105,14 @@ ${onSubmit}
129
105
  <form onSubmit={form.handleSubmit(onSubmit)} className="flex w-full flex-col gap-y-4">
130
106
  <div className="flex flex-col justify-between gap-x-4">
131
107
  ${formFields}
132
- <CommonEditorButtons form={form} setOpen={setOpen} isEdit={!!${names.camelCase}} />
108
+ <CommonEditorButtons form={form} setOpen={handleOpenChange} isEdit={!!${names.camelCase}} />
133
109
  </div>
134
110
  </form>
135
111
  </Form>
136
112
  </DialogContent>
137
113
  </Dialog>
114
+ <CommonEditorDiscardDialog {...discardDialogProps} />
115
+ </>
138
116
  );
139
117
  }
140
118
 
@@ -194,7 +172,7 @@ function generateImports(data: FrontendTemplateData): string {
194
172
  imports.push(`import { revalidatePaths } from "@/utils/revalidation";`);
195
173
 
196
174
  // Library component imports
197
- const componentImports: string[] = ["CommonEditorButtons", "CommonEditorHeader", "CommonEditorTrigger", "errorToast"];
175
+ const componentImports: string[] = ["CommonEditorButtons", "CommonEditorDiscardDialog", "CommonEditorHeader", "CommonEditorTrigger", "errorToast", "useEditorDialog"];
198
176
 
199
177
  // Check for field types that need specific components
200
178
  const hasContentField = fields.some((f) => f.isContentField || f.name === "content");
@@ -251,7 +229,7 @@ function generateImports(data: FrontendTemplateData): string {
251
229
  // Other imports
252
230
  imports.push(`import { zodResolver } from "@hookform/resolvers/zod";`);
253
231
  imports.push(`import { useTranslations } from "next-intl";`);
254
- imports.push(`import { ReactNode, useEffect, useState } from "react";`);
232
+ imports.push(`import { ReactNode, useCallback, useEffect } from "react";`);
255
233
  imports.push(`import { SubmitHandler, useForm } from "react-hook-form";`);
256
234
  imports.push(`import { v4 } from "uuid";`);
257
235
  imports.push(`import { z } from "zod";`);
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogAction,
7
+ AlertDialogCancel,
8
+ AlertDialogContent,
9
+ AlertDialogDescription,
10
+ AlertDialogFooter,
11
+ AlertDialogHeader,
12
+ AlertDialogTitle,
13
+ } from "../../shadcnui";
14
+
15
+ type CommonEditorDiscardDialogProps = {
16
+ open: boolean;
17
+ onOpenChange: (open: boolean) => void;
18
+ onDiscard: () => void;
19
+ };
20
+
21
+ export function CommonEditorDiscardDialog({ open, onOpenChange, onDiscard }: CommonEditorDiscardDialogProps) {
22
+ const t = useTranslations();
23
+
24
+ return (
25
+ <AlertDialog open={open} onOpenChange={onOpenChange}>
26
+ <AlertDialogContent>
27
+ <AlertDialogHeader>
28
+ <AlertDialogTitle>{t(`ui.dialogs.unsaved_changes_title`)}</AlertDialogTitle>
29
+ <AlertDialogDescription>{t(`ui.dialogs.unsaved_changes_description`)}</AlertDialogDescription>
30
+ </AlertDialogHeader>
31
+ <AlertDialogFooter>
32
+ <AlertDialogCancel>{t(`ui.buttons.cancel`)}</AlertDialogCancel>
33
+ <AlertDialogAction variant="destructive" onClick={onDiscard}>
34
+ {t(`ui.dialogs.unsaved_changes_discard`)}
35
+ </AlertDialogAction>
36
+ </AlertDialogFooter>
37
+ </AlertDialogContent>
38
+ </AlertDialog>
39
+ );
40
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { CommonEditorDiscardDialog } from "../CommonEditorDiscardDialog";
4
+
5
+ // Mock next-intl
6
+ vi.mock("next-intl", () => ({
7
+ useTranslations: () => (key: string) => key,
8
+ }));
9
+
10
+ describe("CommonEditorDiscardDialog", () => {
11
+ const defaultProps = {
12
+ open: true,
13
+ onOpenChange: vi.fn(),
14
+ onDiscard: vi.fn(),
15
+ };
16
+
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+ });
20
+
21
+ it("should render dialog content when open", () => {
22
+ render(<CommonEditorDiscardDialog {...defaultProps} />);
23
+ expect(screen.getByText("ui.dialogs.unsaved_changes_title")).toBeInTheDocument();
24
+ expect(screen.getByText("ui.dialogs.unsaved_changes_description")).toBeInTheDocument();
25
+ });
26
+
27
+ it("should render cancel and discard buttons", () => {
28
+ render(<CommonEditorDiscardDialog {...defaultProps} />);
29
+ expect(screen.getByText("ui.buttons.cancel")).toBeInTheDocument();
30
+ expect(screen.getByText("ui.dialogs.unsaved_changes_discard")).toBeInTheDocument();
31
+ });
32
+
33
+ it("should call onDiscard when discard button is clicked", () => {
34
+ render(<CommonEditorDiscardDialog {...defaultProps} />);
35
+ fireEvent.click(screen.getByText("ui.dialogs.unsaved_changes_discard"));
36
+ expect(defaultProps.onDiscard).toHaveBeenCalled();
37
+ });
38
+
39
+ it("should not render when closed", () => {
40
+ render(<CommonEditorDiscardDialog {...defaultProps} open={false} />);
41
+ expect(screen.queryByText("ui.dialogs.unsaved_changes_title")).not.toBeInTheDocument();
42
+ });
43
+ });
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { renderHook, act } from "@testing-library/react";
3
+ import { useEditorDialog } from "../useEditorDialog";
4
+
5
+ describe("useEditorDialog", () => {
6
+ beforeEach(() => {
7
+ vi.clearAllMocks();
8
+ });
9
+
10
+ describe("open state", () => {
11
+ it("should start closed by default", () => {
12
+ const { result } = renderHook(() => useEditorDialog(() => false));
13
+ expect(result.current.open).toBe(false);
14
+ });
15
+
16
+ it("should allow setting open state directly", () => {
17
+ const { result } = renderHook(() => useEditorDialog(() => false));
18
+ act(() => result.current.setOpen(true));
19
+ expect(result.current.open).toBe(true);
20
+ });
21
+ });
22
+
23
+ describe("handleOpenChange", () => {
24
+ it("should open dialog when called with true", () => {
25
+ const { result } = renderHook(() => useEditorDialog(() => false));
26
+ act(() => result.current.handleOpenChange(true));
27
+ expect(result.current.open).toBe(true);
28
+ });
29
+
30
+ it("should close dialog when form is not dirty", () => {
31
+ const { result } = renderHook(() => useEditorDialog(() => false));
32
+ act(() => result.current.setOpen(true));
33
+ act(() => result.current.handleOpenChange(false));
34
+ expect(result.current.open).toBe(false);
35
+ });
36
+
37
+ it("should show discard confirmation when form is dirty and closing", () => {
38
+ const { result } = renderHook(() => useEditorDialog(() => true));
39
+ act(() => result.current.setOpen(true));
40
+ act(() => result.current.handleOpenChange(false));
41
+ expect(result.current.open).toBe(true);
42
+ expect(result.current.discardDialogProps.open).toBe(true);
43
+ });
44
+ });
45
+
46
+ describe("discardDialogProps", () => {
47
+ it("should close both dialogs on discard", () => {
48
+ const { result } = renderHook(() => useEditorDialog(() => true));
49
+ act(() => result.current.setOpen(true));
50
+ act(() => result.current.handleOpenChange(false));
51
+ expect(result.current.discardDialogProps.open).toBe(true);
52
+ act(() => result.current.discardDialogProps.onDiscard());
53
+ expect(result.current.discardDialogProps.open).toBe(false);
54
+ expect(result.current.open).toBe(false);
55
+ });
56
+
57
+ it("should allow dismissing discard dialog via onOpenChange", () => {
58
+ const { result } = renderHook(() => useEditorDialog(() => true));
59
+ act(() => result.current.setOpen(true));
60
+ act(() => result.current.handleOpenChange(false));
61
+ act(() => result.current.discardDialogProps.onOpenChange(false));
62
+ expect(result.current.discardDialogProps.open).toBe(false);
63
+ expect(result.current.open).toBe(true);
64
+ });
65
+ });
66
+
67
+ describe("options.dialogOpen", () => {
68
+ it("should sync open state from external dialogOpen prop", () => {
69
+ const { result, rerender } = renderHook(({ dialogOpen }) => useEditorDialog(() => false, { dialogOpen }), {
70
+ initialProps: { dialogOpen: false },
71
+ });
72
+ expect(result.current.open).toBe(false);
73
+ rerender({ dialogOpen: true });
74
+ expect(result.current.open).toBe(true);
75
+ });
76
+ });
77
+
78
+ describe("options.onDialogOpenChange", () => {
79
+ it("should notify parent when open state changes", () => {
80
+ const onDialogOpenChange = vi.fn();
81
+ const { result } = renderHook(() => useEditorDialog(() => false, { onDialogOpenChange }));
82
+ act(() => result.current.setOpen(true));
83
+ expect(onDialogOpenChange).toHaveBeenCalledWith(true);
84
+ });
85
+ });
86
+
87
+ describe("options.forceShow", () => {
88
+ it("should open dialog when forceShow becomes true", () => {
89
+ const { result, rerender } = renderHook(({ forceShow }) => useEditorDialog(() => false, { forceShow }), {
90
+ initialProps: { forceShow: false },
91
+ });
92
+ expect(result.current.open).toBe(false);
93
+ rerender({ forceShow: true });
94
+ expect(result.current.open).toBe(true);
95
+ });
96
+ });
97
+
98
+ describe("options.onClose", () => {
99
+ it("should call onClose when dialog closes", () => {
100
+ const onClose = vi.fn();
101
+ const { result } = renderHook(() => useEditorDialog(() => false, { onClose }));
102
+ act(() => result.current.setOpen(true));
103
+ act(() => result.current.setOpen(false));
104
+ expect(onClose).toHaveBeenCalled();
105
+ });
106
+
107
+ it("should call onClose when discard is confirmed", () => {
108
+ const onClose = vi.fn();
109
+ const { result } = renderHook(() => useEditorDialog(() => true, { onClose }));
110
+ act(() => result.current.setOpen(true));
111
+ act(() => result.current.handleOpenChange(false));
112
+ act(() => result.current.discardDialogProps.onDiscard());
113
+ expect(onClose).toHaveBeenCalled();
114
+ });
115
+ });
116
+
117
+ describe("escape key handler", () => {
118
+ it("should trigger handleOpenChange(false) on Escape when open", () => {
119
+ const { result } = renderHook(() => useEditorDialog(() => true));
120
+ act(() => result.current.setOpen(true));
121
+
122
+ const event = new KeyboardEvent("keydown", {
123
+ key: "Escape",
124
+ bubbles: true,
125
+ cancelable: true,
126
+ });
127
+ act(() => document.dispatchEvent(event));
128
+
129
+ expect(result.current.discardDialogProps.open).toBe(true);
130
+ });
131
+
132
+ it("should not react to Escape when closed", () => {
133
+ const { result } = renderHook(() => useEditorDialog(() => true));
134
+
135
+ const event = new KeyboardEvent("keydown", {
136
+ key: "Escape",
137
+ bubbles: true,
138
+ cancelable: true,
139
+ });
140
+ act(() => document.dispatchEvent(event));
141
+
142
+ expect(result.current.discardDialogProps.open).toBe(false);
143
+ });
144
+ });
145
+ });
@@ -2,7 +2,9 @@ export * from "./CommonAssociationForm";
2
2
  export * from "./CommonDeleter";
3
3
  export * from "./CommonEditorButtons";
4
4
  export * from "./CommonEditorHeader";
5
+ export * from "./CommonEditorDiscardDialog";
5
6
  export * from "./CommonEditorTrigger";
7
+ export * from "./useEditorDialog";
6
8
  export * from "./DatePickerPopover";
7
9
  export * from "./DateRangeSelector";
8
10
  export * from "./FileUploader";
@@ -0,0 +1,93 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+
5
+ type UseEditorDialogOptions = {
6
+ dialogOpen?: boolean;
7
+ onDialogOpenChange?: (open: boolean) => void;
8
+ forceShow?: boolean;
9
+ onClose?: () => void;
10
+ };
11
+
12
+ type UseEditorDialogReturn = {
13
+ open: boolean;
14
+ setOpen: (open: boolean) => void;
15
+ handleOpenChange: (nextOpen: boolean) => void;
16
+ discardDialogProps: {
17
+ open: boolean;
18
+ onOpenChange: (open: boolean) => void;
19
+ onDiscard: () => void;
20
+ };
21
+ };
22
+
23
+ export function useEditorDialog(isFormDirty: () => boolean, options?: UseEditorDialogOptions): UseEditorDialogReturn {
24
+ const [open, setOpen] = useState<boolean>(false);
25
+ const [showDiscardConfirm, setShowDiscardConfirm] = useState<boolean>(false);
26
+
27
+ // Sync open state from external dialogOpen prop
28
+ useEffect(() => {
29
+ if (options?.dialogOpen !== undefined) {
30
+ setOpen(options.dialogOpen);
31
+ }
32
+ }, [options?.dialogOpen]);
33
+
34
+ // Notify parent when open state changes
35
+ useEffect(() => {
36
+ if (typeof options?.onDialogOpenChange === "function") {
37
+ options.onDialogOpenChange(open);
38
+ }
39
+ }, [open, options?.onDialogOpenChange]);
40
+
41
+ // Force show
42
+ useEffect(() => {
43
+ if (options?.forceShow) setOpen(true);
44
+ }, [options?.forceShow]);
45
+
46
+ // Call onClose when dialog closes
47
+ useEffect(() => {
48
+ if (!open) {
49
+ if (options?.onClose) options.onClose();
50
+ }
51
+ }, [open]);
52
+
53
+ const handleOpenChange = useCallback(
54
+ (nextOpen: boolean) => {
55
+ if (!nextOpen && isFormDirty()) {
56
+ setShowDiscardConfirm(true);
57
+ return;
58
+ }
59
+ setOpen(nextOpen);
60
+ },
61
+ [isFormDirty],
62
+ );
63
+
64
+ // Escape key handler
65
+ useEffect(() => {
66
+ const handleKeyDown = (event: KeyboardEvent) => {
67
+ if (event.key === "Escape" && open) {
68
+ event.preventDefault();
69
+ event.stopPropagation();
70
+ handleOpenChange(false);
71
+ }
72
+ };
73
+
74
+ if (open) {
75
+ document.addEventListener("keydown", handleKeyDown, true);
76
+ }
77
+
78
+ return () => {
79
+ document.removeEventListener("keydown", handleKeyDown, true);
80
+ };
81
+ }, [open, handleOpenChange]);
82
+
83
+ const discardDialogProps = {
84
+ open: showDiscardConfirm,
85
+ onOpenChange: setShowDiscardConfirm,
86
+ onDiscard: () => {
87
+ setShowDiscardConfirm(false);
88
+ setOpen(false);
89
+ },
90
+ };
91
+
92
+ return { open, setOpen, handleOpenChange, discardDialogProps };
93
+ }
@@ -101,20 +101,19 @@ export const ContentListTable = memo(function ContentListTable(props: ContentLis
101
101
  <div className="flex w-full items-center justify-between gap-x-2">
102
102
  {/* <div className="w-full">{fullWidth ? `` : props.title}</div> */}
103
103
  <div className="w-full">
104
- {fullWidth ? (
105
- <div
106
- className={
107
- "text-muted-foreground flex items-center gap-x-2 text-lg font-light whitespace-nowrap"
108
- }
109
- >
110
- {props.tableGeneratorType.icon && (
111
- <props.tableGeneratorType.icon className="text-primary h-6 w-6" />
112
- )}
113
- {props.title}
114
- </div>
115
- ) : (
116
- props.title
117
- )}
104
+ <div
105
+ className={cn(
106
+ "text-muted-foreground flex items-center gap-x-2 font-light whitespace-nowrap",
107
+ fullWidth ? `text-lg` : `text-sm`,
108
+ )}
109
+ >
110
+ {props.tableGeneratorType.icon && (
111
+ <props.tableGeneratorType.icon
112
+ className={cn(`text-primary`, fullWidth ? `h-6 w-6` : `h-4 w-4`)}
113
+ />
114
+ )}
115
+ {props.title}
116
+ </div>
118
117
  </div>
119
118
  {(props.functions || props.filters || allowSearch) && (
120
119
  <>