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
@@ -0,0 +1,151 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Trash;
4
+
5
+ use Illuminate\Database\Eloquent\Model;
6
+ use Illuminate\Database\QueryException;
7
+ use Illuminate\Support\Carbon;
8
+ use Illuminate\Support\Facades\Gate;
9
+ use Nevela\Laravel\Nevela;
10
+ use Nevela\Laravel\Support\Descriptor;
11
+
12
+ /**
13
+ * What a delete leaves behind, and what becomes of it.
14
+ *
15
+ * A deleted record is kept, hidden from every list, count and export, for a number of
16
+ * days (config: nevela.trash.days, 30 unless changed). In that time it can be restored
17
+ * from the dashboard's Trash page. After it, the record is removed for good: by
18
+ * `nevela:trash`, which the scheduler runs each night, and whenever the trash is opened.
19
+ */
20
+ final class Trash
21
+ {
22
+ /** How many days a deleted record can be brought back, or null when it is kept until someone removes it. */
23
+ public static function days(): ?int
24
+ {
25
+ $days = config('nevela.trash.days', 30);
26
+
27
+ return is_numeric($days) && (int) $days > 0 ? (int) $days : null;
28
+ }
29
+
30
+ /**
31
+ * The resources that have a trash, with the model each one's records are.
32
+ *
33
+ * A resource is left out when its model doesn't keep deleted records: the app hasn't
34
+ * migrated yet, or its model was changed not to.
35
+ *
36
+ * @return list<array{descriptor: Descriptor, model: class-string<Model>}>
37
+ */
38
+ public static function resources(): array
39
+ {
40
+ $found = [];
41
+ foreach (Nevela::all() as $descriptor) {
42
+ $model = "App\\Models\\{$descriptor->name}";
43
+ if (class_exists($model) && method_exists($model, 'trashes') && $model::trashes()) {
44
+ $found[] = ['descriptor' => $descriptor, 'model' => $model];
45
+ }
46
+ }
47
+ usort($found, fn ($a, $b) => strcmp($a['descriptor']->pluralLabel, $b['descriptor']->pluralLabel));
48
+
49
+ return $found;
50
+ }
51
+
52
+ /** @return array{descriptor: Descriptor, model: class-string<Model>}|null */
53
+ public static function resource(string $slug): ?array
54
+ {
55
+ foreach (self::resources() as $resource) {
56
+ if ($resource['descriptor']->slug === $slug) {
57
+ return $resource;
58
+ }
59
+ }
60
+
61
+ return null;
62
+ }
63
+
64
+ /**
65
+ * Whether the signed-in person may do something to a deleted record.
66
+ *
67
+ * Laravel's names for these are `restore` and `forceDelete`, and a generated policy
68
+ * has both. A policy written before the trash existed has neither, so it is asked
69
+ * what it would say to `delete`: whoever could delete a record can deal with it here.
70
+ */
71
+ public static function may(string $ability, Model $record): bool
72
+ {
73
+ $policy = Gate::getPolicyFor($record);
74
+
75
+ return Gate::allows($policy !== null && method_exists($policy, $ability) ? $ability : 'delete', $record);
76
+ }
77
+
78
+ /**
79
+ * Whether they may see a resource's trash at all: whoever may delete, restore or remove
80
+ * for good. Asked of a record that is nobody's in particular. A policy that keeps
81
+ * restoring for administrators still lets the people who delete see what they deleted.
82
+ */
83
+ public static function maySee(string $model): bool
84
+ {
85
+ $blank = new $model;
86
+
87
+ return self::may('delete', $blank) || self::may('restore', $blank) || self::may('forceDelete', $blank);
88
+ }
89
+
90
+ /** When a record deleted at `$deletedAt` is removed for good, or null when it is kept until someone does. */
91
+ public static function expires(?Carbon $deletedAt): ?Carbon
92
+ {
93
+ $days = self::days();
94
+
95
+ return $days === null || $deletedAt === null ? null : $deletedAt->copy()->addDays($days);
96
+ }
97
+
98
+ /**
99
+ * Remove a deleted record for good. False when the database won't let it go because
100
+ * other records, themselves in the trash, still belong to it.
101
+ */
102
+ public static function purge(Model $record): bool
103
+ {
104
+ try {
105
+ $record->forceDelete();
106
+
107
+ return true;
108
+ } catch (QueryException $e) {
109
+ // 23000 (and Postgres's 23503): a foreign key still points at this row.
110
+ if (str_starts_with((string) $e->getCode(), '23')) {
111
+ return false;
112
+ }
113
+ throw $e;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Remove everything whose time in the trash has run out.
119
+ *
120
+ * @param class-string<Model>|null $only One resource's model, or all of them
121
+ * @return array<string, int> How many were removed, by resource name
122
+ */
123
+ public static function purgeExpired(?string $only = null): array
124
+ {
125
+ $days = self::days();
126
+ if ($days === null) {
127
+ return [];
128
+ }
129
+ $cutoff = now()->subDays($days);
130
+ $removed = [];
131
+ foreach (self::resources() as $resource) {
132
+ $model = $resource['model'];
133
+ if ($only !== null && $only !== $model) {
134
+ continue;
135
+ }
136
+ $count = 0;
137
+ // One at a time, so a record something still belongs to is left and the rest go.
138
+ $model::onlyTrashed()->where((new $model)->getQualifiedDeletedAtColumn(), '<', $cutoff)->orderBy((new $model)->getKeyName())
139
+ ->chunkById(200, function ($records) use (&$count) {
140
+ foreach ($records as $record) {
141
+ $count += self::purge($record) ? 1 : 0;
142
+ }
143
+ });
144
+ if ($count > 0) {
145
+ $removed[$resource['descriptor']->name] = $count;
146
+ }
147
+ }
148
+
149
+ return $removed;
150
+ }
151
+ }
@@ -0,0 +1,21 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Trash;
4
+
5
+ use Illuminate\Database\Eloquent\Builder;
6
+ use Illuminate\Database\Eloquent\Model;
7
+ use Illuminate\Database\Eloquent\SoftDeletingScope;
8
+
9
+ /**
10
+ * Laravel's soft-delete scope, which hides deleted rows from every query, applied only
11
+ * to a table that has the column to hide them by. See Concerns\Trashable.
12
+ */
13
+ final class TrashedScope extends SoftDeletingScope
14
+ {
15
+ public function apply(Builder $builder, Model $model): void
16
+ {
17
+ if ($model::trashes()) {
18
+ parent::apply($builder, $model);
19
+ }
20
+ }
21
+ }
@@ -4,7 +4,7 @@ import { Toaster } from "@/components/ui/sonner";
4
4
  import { DashboardHeader, type Theme } from "@/components/dashboard/dashboard-header";
5
5
  import { DashboardSidebar } from "@/components/dashboard/dashboard-sidebar";
6
6
  import { dashboardLinks } from "@/lib/dashboard-nav";
7
- import { dashboardSession, visibleResources } from "@/lib/dashboard";
7
+ import { allResources, dashboardSession, mayDo, visibleResources } from "@/lib/dashboard";
8
8
  import { fileUrl } from "@/lib/files";
9
9
  import { requireSession } from "@/lib/session";
10
10
  import { site } from "@/lib/site";
@@ -34,10 +34,12 @@ export default async function DashboardLayout({ children }: { children: React.Re
34
34
  // The screens for managing people, for those whose roles allow it.
35
35
  const { isAdmin, may } = await dashboardSession();
36
36
  const access = { users: may("users.view"), roles: may("roles.view") };
37
+ // The trash is for those who may delete: whoever can put a record there can deal with it there.
38
+ const trash = (await Promise.all(allResources().map((resource) => mayDo(resource, "delete")))).some(Boolean);
37
39
 
38
40
  return (
39
41
  <SidebarProvider defaultOpen={!collapsed}>
40
- <DashboardSidebar appName={site.name} resources={resources} links={dashboardLinks} isAdmin={isAdmin} access={access} user={account} />
42
+ <DashboardSidebar appName={site.name} resources={resources} links={dashboardLinks} isAdmin={isAdmin} access={access} trash={trash} user={account} />
41
43
  <SidebarInset>
42
44
  <DashboardHeader user={account} initialTheme={theme} notices={[]} />
43
45
  <main className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">{children}</main>
@@ -0,0 +1,78 @@
1
+ "use server";
2
+
3
+ import { revalidatePath } from "next/cache";
4
+ import { revalidateResource } from "@/lib/cache";
5
+ import { allResources, resourcePath } from "@/lib/dashboard";
6
+ import { laravel } from "@/lib/laravel";
7
+ import { getSession } from "@/lib/session";
8
+
9
+ /**
10
+ * What can be done with a deleted record: bring it back, or remove it for good. Laravel
11
+ * decides who may, by the resource's own policy, and its answer is passed back as it is.
12
+ */
13
+
14
+ export type TrashResult<T = null> = { ok: true; data: T } | { ok: false; error: string };
15
+
16
+ /** Both places a change shows: the trash it left or stayed in, and the list it came from. */
17
+ function revalidate(slug: string) {
18
+ const resource = allResources().find((item) => item.slug === slug);
19
+ if (resource) {
20
+ try {
21
+ revalidateResource({ resource: resource.name, action: "create", id: "" });
22
+ } catch (error) {
23
+ console.error(`[nevela] ${resource.name} trash cache invalidation failed:`, error);
24
+ }
25
+ revalidatePath(resourcePath(resource));
26
+ }
27
+ revalidatePath("/dashboard/trash");
28
+ }
29
+
30
+ async function send<T>(slug: string, path: string, method: "POST" | "DELETE"): Promise<TrashResult<T>> {
31
+ if (!(await getSession())) return { ok: false, error: "Sign in to continue." };
32
+ const response = await laravel(path, { method });
33
+ if (!response.ok) {
34
+ const body = (response.body ?? {}) as { error?: string };
35
+ return { ok: false, error: body.error ?? `Something went wrong (${response.status}).` };
36
+ }
37
+ revalidate(slug);
38
+ return { ok: true, data: response.body as T };
39
+ }
40
+
41
+ /** Back into its list, as it was. */
42
+ export async function restoreRecordAction(slug: string, id: string): Promise<TrashResult<{ id: string }>> {
43
+ return send(slug, `_nevela/trash/${encodeURIComponent(slug)}/${encodeURIComponent(id)}/restore`, "POST");
44
+ }
45
+
46
+ /**
47
+ * Several back at once: what "Undo" does after deleting a selection. Ids that were never
48
+ * deleted (the ones the delete itself refused) aren't in the trash, and are passed over.
49
+ */
50
+ export async function restoreManyAction(slug: string, ids: string[]): Promise<TrashResult<{ restored: number; refused: number; reason?: string }>> {
51
+ if (!(await getSession())) return { ok: false, error: "Sign in to continue." };
52
+ if (ids.length > 500) return { ok: false, error: "That's more than 500 records. Restore them from the trash instead." };
53
+ let restored = 0;
54
+ let refused = 0;
55
+ let reason: string | undefined;
56
+ for (const id of ids) {
57
+ const response = await laravel(`_nevela/trash/${encodeURIComponent(slug)}/${encodeURIComponent(id)}/restore`, { method: "POST" });
58
+ if (response.ok) restored++;
59
+ else if (response.status !== 404) {
60
+ refused++;
61
+ reason ??= ((response.body ?? {}) as { error?: string }).error ?? `Something went wrong (${response.status}).`;
62
+ }
63
+ }
64
+ if (restored > 0) revalidate(slug);
65
+ if (restored === 0) return { ok: false, error: reason ?? "Nothing to restore: these aren't in the trash any more." };
66
+ // Some came back and some were refused: both are said, so "Restored" is never the whole answer when it isn't.
67
+ return { ok: true, data: { restored, refused, reason } };
68
+ }
69
+
70
+ /** Gone for good. */
71
+ export async function purgeRecordAction(slug: string, id: string): Promise<TrashResult> {
72
+ return send(slug, `_nevela/trash/${encodeURIComponent(slug)}/${encodeURIComponent(id)}`, "DELETE");
73
+ }
74
+
75
+ /** Everything of one resource that is in the trash, for good. */
76
+ export async function emptyTrashAction(slug: string): Promise<TrashResult<{ removed: number; kept: number }>> {
77
+ return send(slug, `_nevela/trash/${encodeURIComponent(slug)}?confirm=${encodeURIComponent(slug)}`, "DELETE");
78
+ }
@@ -0,0 +1,167 @@
1
+ import Link from "next/link";
2
+ import { LocalTime } from "@/components/dashboard/local-time";
3
+ import { PageHeader } from "@/components/dashboard/page-header";
4
+ import { resourceIcon } from "@/components/dashboard/resource-icon";
5
+ import { EmptyTrashButton, TimeLeft, TrashRowActions } from "@/components/dashboard/trash-actions";
6
+ import { Badge } from "@/components/ui/badge";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
9
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
10
+ import { requireDashboard } from "@/lib/dashboard";
11
+ import { trashBuckets, trashedRecords } from "@/lib/trash";
12
+ import { keptFor } from "@/lib/trash-info";
13
+ import { cn } from "@/lib/utils";
14
+
15
+ export const metadata = { title: "Trash" };
16
+
17
+ type Params = { in?: string; page?: string };
18
+
19
+ /**
20
+ * Deleted records, by resource: restore one, or remove it for good.
21
+ *
22
+ * Whose trash a person sees is Laravel's to say, by each resource's own policy. Someone
23
+ * who may delete products and nothing else sees deleted products and nothing else.
24
+ */
25
+ export default async function TrashPage({ searchParams }: { searchParams: Promise<Params> }) {
26
+ await requireDashboard("/dashboard/trash");
27
+ const params = await searchParams;
28
+ const trash = await trashBuckets();
29
+ const buckets = trash?.resources ?? [];
30
+ // The one asked for, or the first with something in it.
31
+ const current = buckets.find((bucket) => bucket.slug === params.in) ?? buckets.find((bucket) => bucket.count > 0) ?? buckets[0];
32
+ let list = current ? await trashedRecords(current.slug, params.page) : null;
33
+ // The last record of the last page was just restored or removed: show the page before it.
34
+ if (current && list && list.data.length === 0 && list.meta.total > 0) list = await trashedRecords(current.slug, String(list.meta.totalPages));
35
+ const records = list?.data ?? [];
36
+ const meta = list?.meta ?? { page: 1, perPage: 25, total: 0, totalPages: 1 };
37
+ const href = (slug: string, page?: number) => `/dashboard/trash?${new URLSearchParams({ in: slug, ...(page && page > 1 ? { page: String(page) } : {}) })}`;
38
+
39
+ return (
40
+ <>
41
+ <PageHeader
42
+ title="Trash"
43
+ description={
44
+ trash
45
+ ? `Deleted records are kept here ${keptFor(trash)}${trash.days === null ? "." : ", then removed for good."} Until then, they can be restored.`
46
+ : "Deleted records, to restore or to remove for good."
47
+ }
48
+ crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Trash" }]}
49
+ actions={current && meta.total > 0 && <EmptyTrashButton slug={current.slug} count={meta.total} noun={current.label.toLowerCase()} plural={current.pluralLabel.toLowerCase()} />}
50
+ />
51
+
52
+ {!trash ? (
53
+ <Empty className="rounded-xl border border-dashed">
54
+ <EmptyHeader>
55
+ <EmptyTitle>This app has no trash yet</EmptyTitle>
56
+ <EmptyDescription>
57
+ Its Laravel side is from before Nevela had one. Run <code>nevela upgrade</code>, then <code>nevela migrate</code>.
58
+ </EmptyDescription>
59
+ </EmptyHeader>
60
+ </Empty>
61
+ ) : buckets.length === 0 ? (
62
+ <Empty className="rounded-xl border border-dashed">
63
+ <EmptyHeader>
64
+ <EmptyTitle>Nothing here for you</EmptyTitle>
65
+ <EmptyDescription>
66
+ The trash holds what you may delete, once its table keeps deleted records. After an upgrade, <code>nevela migrate</code> is what gives each table
67
+ its trash.
68
+ </EmptyDescription>
69
+ </EmptyHeader>
70
+ </Empty>
71
+ ) : (
72
+ <>
73
+ <nav aria-label="Deleted records, by resource" className="flex flex-wrap gap-2">
74
+ {buckets.map((bucket) => {
75
+ const Icon = resourceIcon(bucket.icon ?? undefined);
76
+ const active = bucket.slug === current?.slug;
77
+ return (
78
+ <Button key={bucket.slug} variant={active ? "secondary" : "ghost"} size="sm" asChild>
79
+ <Link href={href(bucket.slug)} aria-current={active ? "page" : undefined} className={cn(!active && "text-muted-foreground")}>
80
+ <Icon data-icon="inline-start" />
81
+ {bucket.pluralLabel}
82
+ <Badge variant={bucket.count > 0 ? "default" : "outline"} className="tabular-nums">
83
+ {bucket.count.toLocaleString()}
84
+ </Badge>
85
+ </Link>
86
+ </Button>
87
+ );
88
+ })}
89
+ </nav>
90
+
91
+ {current && records.length === 0 ? (
92
+ <Empty className="rounded-xl border border-dashed">
93
+ <EmptyHeader>
94
+ <EmptyTitle>No deleted {current.pluralLabel.toLowerCase()}</EmptyTitle>
95
+ <EmptyDescription>When one is deleted, it waits here until it is restored or its time runs out.</EmptyDescription>
96
+ </EmptyHeader>
97
+ </Empty>
98
+ ) : (
99
+ current && (
100
+ <div className="overflow-x-auto rounded-xl border">
101
+ <Table>
102
+ <TableHeader>
103
+ <TableRow>
104
+ <TableHead>{current.label}</TableHead>
105
+ <TableHead>Deleted</TableHead>
106
+ <TableHead>Removed for good</TableHead>
107
+ <TableHead className="w-56">
108
+ <span className="sr-only">Actions</span>
109
+ </TableHead>
110
+ </TableRow>
111
+ </TableHeader>
112
+ <TableBody>
113
+ {records.map((record) => (
114
+ <TableRow key={record.id}>
115
+ <TableCell className="max-w-80 truncate font-medium">{record.label}</TableCell>
116
+ <TableCell className="text-sm text-muted-foreground tabular-nums">
117
+ <LocalTime value={record.deletedAt} />
118
+ </TableCell>
119
+ <TableCell className="text-sm text-muted-foreground tabular-nums">
120
+ <TimeLeft until={record.expiresAt} />
121
+ </TableCell>
122
+ <TableCell>
123
+ <TrashRowActions slug={current.slug} id={record.id} label={record.label} noun={current.label.toLowerCase()} />
124
+ </TableCell>
125
+ </TableRow>
126
+ ))}
127
+ </TableBody>
128
+ </Table>
129
+ </div>
130
+ )
131
+ )}
132
+
133
+ {current && meta.total > 0 && (
134
+ <div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
135
+ <p className="tabular-nums">
136
+ {(meta.page - 1) * meta.perPage + 1}–{Math.min(meta.page * meta.perPage, meta.total)} of {meta.total.toLocaleString()} deleted{" "}
137
+ {meta.total === 1 ? current.label.toLowerCase() : current.pluralLabel.toLowerCase()}
138
+ </p>
139
+ {meta.totalPages > 1 && (
140
+ <div className="flex items-center gap-2">
141
+ {meta.page > 1 ? (
142
+ <Button variant="outline" size="sm" asChild>
143
+ <Link href={href(current.slug, meta.page - 1)}>Previous</Link>
144
+ </Button>
145
+ ) : (
146
+ <Button variant="outline" size="sm" disabled>
147
+ Previous
148
+ </Button>
149
+ )}
150
+ {meta.page < meta.totalPages ? (
151
+ <Button variant="outline" size="sm" asChild>
152
+ <Link href={href(current.slug, meta.page + 1)}>Next</Link>
153
+ </Button>
154
+ ) : (
155
+ <Button variant="outline" size="sm" disabled>
156
+ Next
157
+ </Button>
158
+ )}
159
+ </div>
160
+ )}
161
+ </div>
162
+ )}
163
+ </>
164
+ )}
165
+ </>
166
+ );
167
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { usePathname, useSearchParams } from "next/navigation";
5
- import { ActivityIcon, LayoutDashboardIcon, ShieldCheckIcon, UserCircleIcon, UsersIcon } from "lucide-react";
5
+ import { ActivityIcon, LayoutDashboardIcon, ShieldCheckIcon, Trash2Icon, UserCircleIcon, UsersIcon } from "lucide-react";
6
6
  import { ChevronsUpDownIcon } from "lucide-react";
7
7
  import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
8
8
  import {
@@ -64,6 +64,7 @@ export function DashboardSidebar({
64
64
  links = [],
65
65
  isAdmin = false,
66
66
  access = { users: false, roles: false },
67
+ trash = false,
67
68
  user,
68
69
  }: {
69
70
  appName: string;
@@ -74,6 +75,8 @@ export function DashboardSidebar({
74
75
  isAdmin?: boolean;
75
76
  /** Which of the screens for managing people this person may open. */
76
77
  access?: { users: boolean; roles: boolean };
78
+ /** Whether this person may delete anything, and so has a trash to look in. */
79
+ trash?: boolean;
77
80
  user: DashboardUser;
78
81
  }) {
79
82
  const pathname = usePathname();
@@ -83,6 +86,7 @@ export function DashboardSidebar({
83
86
  ...ACCOUNT_LINKS,
84
87
  ...(access.users ? [{ href: "/dashboard/access/users", label: "Users", icon: UsersIcon }] : []),
85
88
  ...(access.roles ? [{ href: "/dashboard/access/roles", label: "Roles", icon: ShieldCheckIcon }] : []),
89
+ ...(trash ? [{ href: "/dashboard/trash", label: "Trash", icon: Trash2Icon }] : []),
86
90
  ...(isAdmin ? ADMIN_LINKS : []),
87
91
  ];
88
92
 
@@ -7,6 +7,7 @@ import { PencilIcon, Trash2Icon } from "lucide-react";
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,
@@ -18,7 +19,9 @@ import {
18
19
  } from "@/components/ui/alert-dialog";
19
20
  import { Button } from "@/components/ui/button";
20
21
  import { Spinner } from "@/components/ui/spinner";
22
+ import { keptFor, type TrashInfo } from "@/lib/trash-info";
21
23
  import { ResourceFormSheet, type FormRelations } from "./resource-form-sheet";
24
+ import { toastMovedToTrash } from "./trash-actions";
22
25
 
23
26
  interface Props {
24
27
  resource: ClientResource;
@@ -31,10 +34,12 @@ interface Props {
31
34
  canDelete: boolean;
32
35
  /** Whether editing opens a sheet (site.dashboard.forms) or goes to the form page. */
33
36
  overlayForms: boolean;
37
+ /** This resource's trash, when a deleted record goes there instead of going for good. */
38
+ trash?: TrashInfo;
34
39
  }
35
40
 
36
41
  /** Edit and delete, at the top of a record's own page. Deleting goes back to the list. */
37
- export function RecordActions({ resource, id, record, relations, listHref, editHref, canUpdate, canDelete, overlayForms }: Props) {
42
+ export function RecordActions({ resource, id, record, relations, listHref, editHref, canUpdate, canDelete, overlayForms, trash }: Props) {
38
43
  const router = useRouter();
39
44
  const [editing, setEditing] = useState(false);
40
45
  const [confirming, setConfirming] = useState(false);
@@ -47,7 +52,9 @@ export function RecordActions({ resource, id, record, relations, listHref, editH
47
52
  toast.error(result.error);
48
53
  return;
49
54
  }
50
- toast.success(`${resource.label} deleted.`);
55
+ // Undone, the record has a page again, and that is where to go back to.
56
+ if (trash) toastMovedToTrash(`${resource.label} moved to the trash.`, () => restoreRecordAction(resource.slug, id), () => router.push(`${listHref}/${id}`));
57
+ else toast.success(`${resource.label} deleted.`);
51
58
  // The record this page is about is gone, so the list is the only place left to be.
52
59
  router.push(listHref);
53
60
  router.refresh();
@@ -96,14 +103,16 @@ export function RecordActions({ resource, id, record, relations, listHref, editH
96
103
  <AlertDialog open={confirming} onOpenChange={setConfirming}>
97
104
  <AlertDialogContent>
98
105
  <AlertDialogHeader>
99
- <AlertDialogTitle>Delete this {resource.label.toLowerCase()}?</AlertDialogTitle>
100
- <AlertDialogDescription>This can&apos;t be undone. Anything that belongs to it may be deleted too.</AlertDialogDescription>
106
+ <AlertDialogTitle>{trash ? `Move this ${resource.label.toLowerCase()} to the trash?` : `Delete this ${resource.label.toLowerCase()}?`}</AlertDialogTitle>
107
+ <AlertDialogDescription>
108
+ {trash ? `It can be restored from the trash ${keptFor(trash)}.` : "This can't be undone. Anything that belongs to it may be deleted too."}
109
+ </AlertDialogDescription>
101
110
  </AlertDialogHeader>
102
111
  <AlertDialogFooter>
103
112
  <AlertDialogCancel disabled={pending}>Keep it</AlertDialogCancel>
104
113
  <Button variant="destructive" onClick={remove} disabled={pending}>
105
114
  {pending && <Spinner data-icon="inline-start" />}
106
- Delete
115
+ {trash ? "Move to trash" : "Delete"}
107
116
  </Button>
108
117
  </AlertDialogFooter>
109
118
  </AlertDialogContent>
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
7
7
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
8
8
  import { adminPermissions, allResources, dashboardStore, requireAccess, resourcePath } from "@/lib/dashboard";
9
9
  import { site } from "@/lib/site";
10
+ import { trashFor } from "@/lib/trash";
10
11
  import { LocalTime } from "./local-time";
11
12
  import { PageHeader } from "./page-header";
12
13
  import { RecordActions } from "./record-actions";
@@ -31,6 +32,7 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
31
32
  if (!result.ok) notFound();
32
33
  const record = result.data;
33
34
  const permissions = await adminPermissions(resource.name);
35
+ const trash = permissions.delete ? await trashFor(resource.slug) : undefined;
34
36
 
35
37
  const all = allResources();
36
38
  const byName = new Map(all.map((item) => [item.name, item]));
@@ -90,6 +92,7 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
90
92
  canUpdate={permissions.update}
91
93
  canDelete={permissions.delete}
92
94
  overlayForms={site.dashboard.forms === "sheet"}
95
+ trash={trash}
93
96
  />
94
97
  }
95
98
  />
@@ -18,6 +18,7 @@ import {
18
18
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
19
19
  import { resourcePath, adminPermissions, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
20
20
  import { site } from "@/lib/site";
21
+ import { trashFor } from "@/lib/trash";
21
22
  import { cn } from "@/lib/utils";
22
23
  import { ColumnMenu } from "./column-menu";
23
24
  import { ExportButton } from "./export-button";
@@ -51,6 +52,8 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
51
52
  const forClient = clientResource(resource);
52
53
  const basePath = resourcePath(resource);
53
54
  const permissions = await adminPermissions(resource.name);
55
+ // Whether a delete here can be taken back, so the buttons say what they will really do.
56
+ const trash = permissions.delete ? await trashFor(resource.slug) : undefined;
54
57
  const params = toSearchParams(searchParams);
55
58
  const store = dashboardStore(resource.name);
56
59
 
@@ -300,6 +303,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
300
303
  detailHref={resourcePath(resource, id)}
301
304
  canUpdate={permissions.update}
302
305
  canDelete={permissions.delete}
306
+ trash={trash}
303
307
  />
304
308
  </TableCell>
305
309
  </TableRow>
@@ -396,6 +400,8 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
396
400
  canDelete={permissions.delete}
397
401
  rows={rows}
398
402
  columns={exportColumns}
403
+ slug={resource.slug}
404
+ trash={trash}
399
405
  />
400
406
  </div>
401
407
  </TableSelection>