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.
- package/index.mjs +18 -1
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +10 -0
- package/template/laravel/src/Concerns/Trashable.php +63 -0
- package/template/laravel/src/Console/GenerateCommand.php +43 -0
- package/template/laravel/src/Console/TrashCommand.php +33 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +68 -1
- package/template/laravel/src/Http/ResourceRequest.php +33 -0
- package/template/laravel/src/Http/TrashController.php +190 -0
- package/template/laravel/src/Nevela.php +1 -1
- package/template/laravel/src/NevelaServiceProvider.php +19 -0
- package/template/laravel/src/Trash/Trash.php +151 -0
- package/template/laravel/src/Trash/TrashedScope.php +21 -0
- package/template/web/app/dashboard/layout.tsx +4 -2
- package/template/web/app/dashboard/trash/actions.ts +78 -0
- package/template/web/app/dashboard/trash/page.tsx +167 -0
- package/template/web/components/dashboard/dashboard-sidebar.tsx +5 -1
- package/template/web/components/dashboard/record-actions.tsx +14 -5
- package/template/web/components/dashboard/record-detail.tsx +3 -0
- package/template/web/components/dashboard/resource-table.tsx +6 -0
- package/template/web/components/dashboard/row-actions.tsx +12 -7
- package/template/web/components/dashboard/table-selection.tsx +27 -7
- package/template/web/components/dashboard/trash-actions.tsx +178 -0
- package/template/web/lib/trash-info.ts +13 -0
- package/template/web/lib/trash.ts +66 -0
- 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
|
-
|
|
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()}
|
|
100
|
-
<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>
|