create-nevela 0.5.2 → 0.5.3

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 (27) hide show
  1. package/index.mjs +18 -1
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +10 -0
  5. package/template/laravel/src/Concerns/Trashable.php +63 -0
  6. package/template/laravel/src/Console/GenerateCommand.php +43 -0
  7. package/template/laravel/src/Console/TrashCommand.php +33 -0
  8. package/template/laravel/src/Generator/ResourceGenerator.php +68 -1
  9. package/template/laravel/src/Http/ResourceRequest.php +33 -0
  10. package/template/laravel/src/Http/TrashController.php +190 -0
  11. package/template/laravel/src/Nevela.php +1 -1
  12. package/template/laravel/src/NevelaServiceProvider.php +19 -0
  13. package/template/laravel/src/Trash/Trash.php +151 -0
  14. package/template/laravel/src/Trash/TrashedScope.php +21 -0
  15. package/template/web/app/dashboard/layout.tsx +4 -2
  16. package/template/web/app/dashboard/trash/actions.ts +78 -0
  17. package/template/web/app/dashboard/trash/page.tsx +167 -0
  18. package/template/web/components/dashboard/dashboard-sidebar.tsx +5 -1
  19. package/template/web/components/dashboard/record-actions.tsx +14 -5
  20. package/template/web/components/dashboard/record-detail.tsx +3 -0
  21. package/template/web/components/dashboard/resource-table.tsx +6 -0
  22. package/template/web/components/dashboard/row-actions.tsx +12 -7
  23. package/template/web/components/dashboard/table-selection.tsx +27 -7
  24. package/template/web/components/dashboard/trash-actions.tsx +178 -0
  25. package/template/web/lib/trash-info.ts +13 -0
  26. package/template/web/lib/trash.ts +66 -0
  27. package/template/web/package.json +1 -1
@@ -7,6 +7,7 @@ import { ExternalLinkIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lu
7
7
  import { toast } from "sonner";
8
8
  import type { ClientResource } from "@flaredev/core";
9
9
  import { deleteRecordAction } from "@/app/dashboard/actions";
10
+ import { restoreRecordAction } from "@/app/dashboard/trash/actions";
10
11
  import {
11
12
  AlertDialog,
12
13
  AlertDialogCancel,
@@ -26,7 +27,9 @@ import {
26
27
  DropdownMenuTrigger,
27
28
  } from "@/components/ui/dropdown-menu";
28
29
  import { Spinner } from "@/components/ui/spinner";
30
+ import { keptFor, type TrashInfo } from "@/lib/trash-info";
29
31
  import { ResourceFormSheet, type FormRelations } from "./resource-form-sheet";
32
+ import { toastMovedToTrash } from "./trash-actions";
30
33
 
31
34
  /**
32
35
  * Edit and delete for one row. Edit opens a dialog when the row's record came with it
@@ -42,6 +45,7 @@ export function RowActions({
42
45
  detailHref,
43
46
  canUpdate = true,
44
47
  canDelete = true,
48
+ trash,
45
49
  }: {
46
50
  resource: ClientResource;
47
51
  id: string;
@@ -54,18 +58,21 @@ export function RowActions({
54
58
  detailHref: string;
55
59
  canUpdate?: boolean;
56
60
  canDelete?: boolean;
61
+ /** This resource's trash, when a deleted record goes there instead of going for good. */
62
+ trash?: TrashInfo;
57
63
  }) {
58
64
  const router = useRouter();
59
65
  const [confirming, setConfirming] = useState(false);
60
66
  const [editing, setEditing] = useState(false);
61
67
  const [pending, startTransition] = useTransition();
62
- const { name: resourceName, label } = resource;
68
+ const { name: resourceName, label, slug } = resource;
63
69
 
64
70
  function onDelete() {
65
71
  startTransition(async () => {
66
72
  const result = await deleteRecordAction(resourceName, id);
67
73
  if (result.ok) {
68
- toast.success(`${label} deleted.`);
74
+ if (trash) toastMovedToTrash(`${label} moved to the trash.`, () => restoreRecordAction(slug, id), () => router.refresh());
75
+ else toast.success(`${label} deleted.`);
69
76
  setConfirming(false);
70
77
  router.refresh();
71
78
  } else {
@@ -74,8 +81,6 @@ export function RowActions({
74
81
  });
75
82
  }
76
83
 
77
-
78
-
79
84
  return (
80
85
  <>
81
86
  <DropdownMenu>
@@ -139,14 +144,14 @@ export function RowActions({
139
144
  <AlertDialog open={confirming} onOpenChange={setConfirming}>
140
145
  <AlertDialogContent>
141
146
  <AlertDialogHeader>
142
- <AlertDialogTitle>Delete this {label.toLowerCase()}?</AlertDialogTitle>
143
- <AlertDialogDescription>This can&apos;t be undone.</AlertDialogDescription>
147
+ <AlertDialogTitle>{trash ? `Move this ${label.toLowerCase()} to the trash?` : `Delete this ${label.toLowerCase()}?`}</AlertDialogTitle>
148
+ <AlertDialogDescription>{trash ? `It can be restored from the trash ${keptFor(trash)}.` : "This can't be undone."}</AlertDialogDescription>
144
149
  </AlertDialogHeader>
145
150
  <AlertDialogFooter>
146
151
  <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
147
152
  <Button variant="destructive" onClick={onDelete} disabled={pending}>
148
153
  {pending && <Spinner data-icon="inline-start" />}
149
- Delete
154
+ {trash ? "Move to trash" : "Delete"}
150
155
  </Button>
151
156
  </AlertDialogFooter>
152
157
  </AlertDialogContent>
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
5
5
  import { DownloadIcon, Trash2Icon, XIcon } from "lucide-react";
6
6
  import { toast } from "sonner";
7
7
  import { deleteManyAction } from "@/app/dashboard/actions";
8
+ import { restoreManyAction } from "@/app/dashboard/trash/actions";
8
9
  import {
9
10
  AlertDialog,
10
11
  AlertDialogAction,
@@ -19,6 +20,8 @@ import { Button } from "@/components/ui/button";
19
20
  import { Checkbox } from "@/components/ui/checkbox";
20
21
  import { Spinner } from "@/components/ui/spinner";
21
22
  import { toCsv } from "@/lib/csv";
23
+ import { keptFor, type TrashInfo } from "@/lib/trash-info";
24
+ import { toastMovedToTrash } from "./trash-actions";
22
25
 
23
26
  interface SelectionValue {
24
27
  ids: string[];
@@ -94,13 +97,16 @@ interface BarProps {
94
97
  /** Rows as they're shown, for exporting what's selected without another round trip. */
95
98
  rows: Record<string, unknown>[];
96
99
  columns: { key: string; label: string }[];
100
+ /** The resource's address, and its trash when deleted records go there instead of going for good. */
101
+ slug: string;
102
+ trash?: TrashInfo;
97
103
  }
98
104
 
99
105
  /**
100
106
  * What you can do with the rows you've ticked. Appears only when something is selected,
101
107
  * pinned to the bottom so it's reachable in a long table.
102
108
  */
103
- export function SelectionBar({ resourceName, label, pluralLabel, canDelete, rows, columns }: BarProps) {
109
+ export function SelectionBar({ resourceName, label, pluralLabel, canDelete, rows, columns, slug, trash }: BarProps) {
104
110
  const { selected, clear } = useSelection();
105
111
  const router = useRouter();
106
112
  const [confirming, setConfirming] = useState(false);
@@ -117,7 +123,8 @@ export function SelectionBar({ resourceName, label, pluralLabel, canDelete, rows
117
123
 
118
124
  const remove = () => {
119
125
  startTransition(async () => {
120
- const result = await deleteManyAction(resourceName, [...selected]);
126
+ const ids = [...selected];
127
+ const result = await deleteManyAction(resourceName, ids);
121
128
  setConfirming(false);
122
129
  if (!result.ok) {
123
130
  toast.error(result.error);
@@ -126,8 +133,17 @@ export function SelectionBar({ resourceName, label, pluralLabel, canDelete, rows
126
133
  const { deleted, failed } = result.data;
127
134
  clear();
128
135
  router.refresh();
129
- if (failed > 0) toast.warning(`Deleted ${deleted}; ${failed} couldn't be deleted.`);
130
- else toast.success(`Deleted ${deleted} ${deleted === 1 ? label.toLowerCase() : pluralLabel.toLowerCase()}.`);
136
+ const what = `${deleted} ${deleted === 1 ? label.toLowerCase() : pluralLabel.toLowerCase()}`;
137
+ // The ones that wouldn't go aren't in the trash, and restoring passes over them.
138
+ const undo = async () => {
139
+ const result = await restoreManyAction(slug, ids);
140
+ if (!result.ok || result.data.refused === 0) return result;
141
+ return { ok: true as const, partly: `Restored ${result.data.restored}. ${result.data.refused} couldn't be: ${result.data.reason}` };
142
+ };
143
+ if (trash && deleted > 0) {
144
+ toastMovedToTrash(failed > 0 ? `${what} moved to the trash; ${failed} couldn't be deleted.` : `${what} moved to the trash.`, undo, () => router.refresh(), failed > 0);
145
+ } else if (failed > 0) toast.warning(`Deleted ${deleted}; ${failed} couldn't be deleted.`);
146
+ else toast.success(`Deleted ${what}.`);
131
147
  });
132
148
  };
133
149
 
@@ -156,15 +172,19 @@ export function SelectionBar({ resourceName, label, pluralLabel, canDelete, rows
156
172
  <AlertDialogContent>
157
173
  <AlertDialogHeader>
158
174
  <AlertDialogTitle>
159
- Delete {count} {noun}?
175
+ {trash ? `Move ${count} ${noun} to the trash?` : `Delete ${count} ${noun}?`}
160
176
  </AlertDialogTitle>
161
- <AlertDialogDescription>This can&apos;t be undone. Anything that belongs to them may be deleted too.</AlertDialogDescription>
177
+ <AlertDialogDescription>
178
+ {trash
179
+ ? `${count === 1 ? "It" : "They"} can be restored from the trash ${keptFor(trash)}.`
180
+ : "This can't be undone. Anything that belongs to them may be deleted too."}
181
+ </AlertDialogDescription>
162
182
  </AlertDialogHeader>
163
183
  <AlertDialogFooter>
164
184
  <AlertDialogCancel disabled={pending}>Keep them</AlertDialogCancel>
165
185
  <AlertDialogAction onClick={(event) => { event.preventDefault(); remove(); }} disabled={pending}>
166
186
  {pending && <Spinner data-icon="inline-start" />}
167
- Delete {count}
187
+ {trash ? `Move ${count} to trash` : `Delete ${count}`}
168
188
  </AlertDialogAction>
169
189
  </AlertDialogFooter>
170
190
  </AlertDialogContent>
@@ -0,0 +1,178 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useEffect, useState, useTransition } from "react";
5
+ import { RotateCcwIcon, Trash2Icon } from "lucide-react";
6
+ import { toast } from "sonner";
7
+ import { emptyTrashAction, purgeRecordAction, restoreRecordAction } from "@/app/dashboard/trash/actions";
8
+ import {
9
+ AlertDialog,
10
+ AlertDialogCancel,
11
+ AlertDialogContent,
12
+ AlertDialogDescription,
13
+ AlertDialogFooter,
14
+ AlertDialogHeader,
15
+ AlertDialogTitle,
16
+ } from "@/components/ui/alert-dialog";
17
+ import { Button } from "@/components/ui/button";
18
+ import { Spinner } from "@/components/ui/spinner";
19
+
20
+ /**
21
+ * The toast after a delete that went to the trash: it says where the record went, and
22
+ * offers to take it back. `undo` restores, and says so in `partly` when only some of
23
+ * several came back; `after` is what to do once it has. `warn` is for a delete that
24
+ * itself only partly worked.
25
+ */
26
+ export function toastMovedToTrash(
27
+ message: string,
28
+ undo: () => Promise<{ ok: true; partly?: string } | { ok: false; error: string }>,
29
+ after: () => void,
30
+ warn = false,
31
+ ) {
32
+ (warn ? toast.warning : toast.success)(message, {
33
+ action: {
34
+ label: "Undo",
35
+ onClick: async () => {
36
+ const result = await undo();
37
+ if (!result.ok) {
38
+ toast.error(result.error);
39
+ return;
40
+ }
41
+ if (result.partly) toast.warning(result.partly);
42
+ else toast.success("Restored.");
43
+ after();
44
+ },
45
+ },
46
+ });
47
+ }
48
+
49
+ /** How long a deleted record has left: "in 29 days", worked out on the reader's own clock. */
50
+ export function TimeLeft({ until }: { until: string | null }) {
51
+ const [text, setText] = useState<string | null>(null);
52
+
53
+ useEffect(() => {
54
+ if (!until) return;
55
+ const hours = (new Date(until).getTime() - Date.now()) / 3_600_000;
56
+ const relative = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
57
+ // Past its time and still here: it goes the next time the trash is swept.
58
+ if (hours <= 0) setText("soon");
59
+ else if (hours < 1) setText("in under an hour");
60
+ else setText(hours < 48 ? relative.format(Math.round(hours), "hour") : relative.format(Math.round(hours / 24), "day"));
61
+ }, [until]);
62
+
63
+ if (!until) return <span>Kept until removed</span>;
64
+ return (
65
+ <time dateTime={until} title={until} suppressHydrationWarning>
66
+ {text ?? new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeZone: "UTC" }).format(new Date(until))}
67
+ </time>
68
+ );
69
+ }
70
+
71
+ /** Restore, or delete forever, for one record in the trash. */
72
+ export function TrashRowActions({ slug, id, label, noun }: { slug: string; id: string; label: string; noun: string }) {
73
+ const router = useRouter();
74
+ const [confirming, setConfirming] = useState(false);
75
+ const [pending, startTransition] = useTransition();
76
+
77
+ function restore() {
78
+ startTransition(async () => {
79
+ const result = await restoreRecordAction(slug, id);
80
+ if (result.ok) {
81
+ toast.success(`${label} is back in its list.`);
82
+ router.refresh();
83
+ } else {
84
+ toast.error(result.error);
85
+ }
86
+ });
87
+ }
88
+
89
+ function purge() {
90
+ startTransition(async () => {
91
+ const result = await purgeRecordAction(slug, id);
92
+ if (result.ok) {
93
+ toast.success(`${label} is deleted for good.`);
94
+ setConfirming(false);
95
+ router.refresh();
96
+ } else {
97
+ setConfirming(false);
98
+ toast.error(result.error);
99
+ }
100
+ });
101
+ }
102
+
103
+ return (
104
+ <div className="flex items-center justify-end gap-1">
105
+ <Button variant="outline" size="sm" disabled={pending} onClick={restore}>
106
+ {pending && !confirming ? <Spinner data-icon="inline-start" /> : <RotateCcwIcon data-icon="inline-start" />}
107
+ Restore
108
+ </Button>
109
+ <Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" disabled={pending} onClick={() => setConfirming(true)}>
110
+ Delete forever
111
+ </Button>
112
+
113
+ <AlertDialog open={confirming} onOpenChange={setConfirming}>
114
+ <AlertDialogContent>
115
+ <AlertDialogHeader>
116
+ <AlertDialogTitle>Delete {label} forever?</AlertDialogTitle>
117
+ <AlertDialogDescription>This {noun} is removed for good, now. This can&apos;t be undone.</AlertDialogDescription>
118
+ </AlertDialogHeader>
119
+ <AlertDialogFooter>
120
+ <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
121
+ <Button variant="destructive" onClick={purge} disabled={pending}>
122
+ {pending && <Spinner data-icon="inline-start" />}
123
+ Delete forever
124
+ </Button>
125
+ </AlertDialogFooter>
126
+ </AlertDialogContent>
127
+ </AlertDialog>
128
+ </div>
129
+ );
130
+ }
131
+
132
+ /** Remove everything of one resource from the trash, after being asked twice. */
133
+ export function EmptyTrashButton({ slug, count, noun, plural }: { slug: string; count: number; noun: string; plural: string }) {
134
+ const router = useRouter();
135
+ const [confirming, setConfirming] = useState(false);
136
+ const [pending, startTransition] = useTransition();
137
+
138
+ function empty() {
139
+ startTransition(async () => {
140
+ const result = await emptyTrashAction(slug);
141
+ setConfirming(false);
142
+ if (!result.ok) {
143
+ toast.error(result.error);
144
+ return;
145
+ }
146
+ const { removed, kept } = result.data;
147
+ if (kept > 0) toast.warning(`Deleted ${removed} for good. ${kept} couldn't be: other deleted records still belong to them.`);
148
+ else toast.success(`Deleted ${removed.toLocaleString()} ${removed === 1 ? noun : plural} for good.`);
149
+ router.refresh();
150
+ });
151
+ }
152
+
153
+ return (
154
+ <>
155
+ <Button variant="outline" size="sm" className="text-destructive hover:text-destructive" onClick={() => setConfirming(true)}>
156
+ <Trash2Icon data-icon="inline-start" />
157
+ Empty
158
+ </Button>
159
+ <AlertDialog open={confirming} onOpenChange={setConfirming}>
160
+ <AlertDialogContent>
161
+ <AlertDialogHeader>
162
+ <AlertDialogTitle>
163
+ Delete {count === 1 ? `the one deleted ${noun}` : `all ${count.toLocaleString()} deleted ${plural}`} forever?
164
+ </AlertDialogTitle>
165
+ <AlertDialogDescription>Everything of this kind in the trash is removed for good, now. None of it can be restored afterwards.</AlertDialogDescription>
166
+ </AlertDialogHeader>
167
+ <AlertDialogFooter>
168
+ <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
169
+ <Button variant="destructive" onClick={empty} disabled={pending}>
170
+ {pending && <Spinner data-icon="inline-start" />}
171
+ Delete forever
172
+ </Button>
173
+ </AlertDialogFooter>
174
+ </AlertDialogContent>
175
+ </AlertDialog>
176
+ </>
177
+ );
178
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * What a delete button needs to know to say truthfully what pressing it does. Kept apart
3
+ * from lib/trash.ts, which talks to Laravel and so can't be loaded in the browser.
4
+ */
5
+ export interface TrashInfo {
6
+ /** How many days a deleted record can be restored, or null when it is kept until someone removes it. */
7
+ days: number | null;
8
+ }
9
+
10
+ /** "for 30 days", or "until someone removes it for good". */
11
+ export function keptFor(trash: TrashInfo): string {
12
+ return trash.days === null ? "until someone removes it for good" : `for ${trash.days} ${trash.days === 1 ? "day" : "days"}`;
13
+ }
@@ -0,0 +1,66 @@
1
+ import { cache } from "react";
2
+ import { laravel } from "./laravel";
3
+ import type { TrashInfo } from "./trash-info";
4
+
5
+ /**
6
+ * The trash, as Laravel serves it (`/_nevela/trash`).
7
+ *
8
+ * A deleted record is kept, hidden, and can be restored for a number of days. Which
9
+ * resources have a trash is Laravel's to say: one whose table hasn't been migrated yet
10
+ * still deletes for good, and so does every resource of an app whose API is from before
11
+ * the trash existed.
12
+ */
13
+
14
+ export interface TrashBucket {
15
+ name: string;
16
+ label: string;
17
+ pluralLabel: string;
18
+ slug: string;
19
+ icon: string | null;
20
+ /** How many of this resource's records are in the trash. */
21
+ count: number;
22
+ }
23
+
24
+ export interface TrashedRecord {
25
+ id: string;
26
+ /** What a list would call it: its title. */
27
+ label: string;
28
+ deletedAt: string | null;
29
+ /** When it is removed for good, or null when it is kept until someone removes it. */
30
+ expiresAt: string | null;
31
+ }
32
+
33
+ export interface TrashList {
34
+ data: TrashedRecord[];
35
+ meta: { page: number; perPage: number; total: number; totalPages: number };
36
+ }
37
+
38
+ /**
39
+ * Which resources have a trash this person may use. Asked once for a page, however many
40
+ * delete buttons are on it; nothing is counted. Null when the API has no trash at all.
41
+ */
42
+ const trashStatus = cache(async (): Promise<{ days: number | null; resources: string[] } | null> => {
43
+ const response = await laravel("_nevela/trash/_status");
44
+ return response.ok ? (response.body as { days: number | null; resources: string[] }) : null;
45
+ });
46
+
47
+ /** For a resource's delete buttons: its trash, or undefined when deleting it is for good. */
48
+ export async function trashFor(slug: string): Promise<TrashInfo | undefined> {
49
+ const status = await trashStatus();
50
+ return status?.resources.includes(slug) ? { days: status.days } : undefined;
51
+ }
52
+
53
+ /**
54
+ * Everything in the trash, by resource, for the Trash page. Opening it is also what
55
+ * removes anything past its time, where no scheduler does that overnight.
56
+ */
57
+ export async function trashBuckets(): Promise<{ days: number | null; resources: TrashBucket[] } | null> {
58
+ const response = await laravel("_nevela/trash");
59
+ return response.ok ? (response.body as { days: number | null; resources: TrashBucket[] }) : null;
60
+ }
61
+
62
+ export async function trashedRecords(slug: string, page?: string): Promise<TrashList | null> {
63
+ const query = new URLSearchParams(page ? { page } : {});
64
+ const response = await laravel(`_nevela/trash/${encodeURIComponent(slug)}?${query}`);
65
+ return response.ok ? (response.body as TrashList) : null;
66
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nevela-web",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {