create-nevela 0.5.2 → 0.6.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.
- 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
|
@@ -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
|
-
|
|
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()}
|
|
143
|
-
<AlertDialogDescription>This can
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
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>
|
|
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'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
|
+
}
|