create-nevela 0.7.1 → 0.8.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 +1 -0
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +25 -3
- package/template/laravel/src/Access/Permissions.php +36 -5
- package/template/laravel/src/Concerns/Trashable.php +23 -0
- package/template/laravel/src/Console/AdoptCommand.php +139 -0
- package/template/laravel/src/Console/GenerateCommand.php +13 -3
- package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
- package/template/laravel/src/Console/SeedCommand.php +3 -1
- package/template/laravel/src/Console/TrashCommand.php +8 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +229 -29
- package/template/laravel/src/Http/ActionController.php +222 -0
- package/template/laravel/src/Http/PublicController.php +210 -0
- package/template/laravel/src/Http/PublicThrottle.php +48 -0
- package/template/laravel/src/Http/ResourceRequest.php +151 -2
- package/template/laravel/src/Http/TrashController.php +18 -2
- package/template/laravel/src/Http/UploadController.php +42 -1
- package/template/laravel/src/Media/Uploads.php +111 -0
- package/template/laravel/src/Nevela.php +54 -2
- package/template/laravel/src/NevelaServiceProvider.php +17 -1
- package/template/laravel/src/Support/Action.php +168 -0
- package/template/laravel/src/Support/Adoption.php +352 -0
- package/template/laravel/src/Support/Descriptor.php +59 -4
- package/template/laravel/src/Support/Fake.php +15 -1
- package/template/laravel/src/Support/Field.php +74 -3
- package/template/laravel/src/Support/Html.php +185 -0
- package/template/laravel/src/Support/PublicAccess.php +133 -0
- package/template/web/app/api/[...path]/route.ts +42 -9
- package/template/web/app/dashboard/actions.ts +34 -0
- package/template/web/app/globals.css +46 -0
- package/template/web/components/access/role-form.tsx +40 -5
- package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
- package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
- package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
- package/template/web/components/dashboard/fields/file-field.tsx +9 -5
- package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
- package/template/web/components/dashboard/record-actions.tsx +7 -2
- package/template/web/components/dashboard/record-detail.tsx +9 -2
- package/template/web/components/dashboard/resource-action.tsx +281 -0
- package/template/web/components/dashboard/resource-table.tsx +15 -2
- package/template/web/components/dashboard/row-actions.tsx +24 -0
- package/template/web/components/dashboard/table-selection.tsx +19 -1
- package/template/web/lib/access.ts +3 -1
- package/template/web/lib/dashboard.ts +10 -0
- package/template/web/lib/extras.ts +66 -0
- package/template/web/lib/files.ts +14 -0
- package/template/web/lib/laravel.ts +60 -0
- package/template/web/lib/rich.ts +79 -0
- package/template/web/package.json +1 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useState, useTransition } from "react";
|
|
5
|
+
import {
|
|
6
|
+
ArchiveIcon,
|
|
7
|
+
BanIcon,
|
|
8
|
+
BellIcon,
|
|
9
|
+
CheckIcon,
|
|
10
|
+
CopyIcon,
|
|
11
|
+
DownloadIcon,
|
|
12
|
+
FlagIcon,
|
|
13
|
+
LockIcon,
|
|
14
|
+
MailIcon,
|
|
15
|
+
MegaphoneIcon,
|
|
16
|
+
PauseIcon,
|
|
17
|
+
PlayIcon,
|
|
18
|
+
RefreshCwIcon,
|
|
19
|
+
SendIcon,
|
|
20
|
+
StarIcon,
|
|
21
|
+
UnlockIcon,
|
|
22
|
+
UploadIcon,
|
|
23
|
+
XIcon,
|
|
24
|
+
ZapIcon,
|
|
25
|
+
type LucideIcon,
|
|
26
|
+
} from "lucide-react";
|
|
27
|
+
import { toast } from "sonner";
|
|
28
|
+
import { field as build, type StoredField } from "@flaredev/core";
|
|
29
|
+
import { runResourceAction } from "@/app/dashboard/actions";
|
|
30
|
+
import {
|
|
31
|
+
AlertDialog,
|
|
32
|
+
AlertDialogCancel,
|
|
33
|
+
AlertDialogContent,
|
|
34
|
+
AlertDialogDescription,
|
|
35
|
+
AlertDialogFooter,
|
|
36
|
+
AlertDialogHeader,
|
|
37
|
+
AlertDialogTitle,
|
|
38
|
+
} from "@/components/ui/alert-dialog";
|
|
39
|
+
import { Button } from "@/components/ui/button";
|
|
40
|
+
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
|
|
41
|
+
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
|
42
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
43
|
+
import type { ActionField, ResourceAction } from "@/lib/extras";
|
|
44
|
+
import { FieldWidget } from "./fields/field-widget";
|
|
45
|
+
|
|
46
|
+
const ICONS: Record<string, LucideIcon> = {
|
|
47
|
+
archive: ArchiveIcon,
|
|
48
|
+
ban: BanIcon,
|
|
49
|
+
bell: BellIcon,
|
|
50
|
+
check: CheckIcon,
|
|
51
|
+
copy: CopyIcon,
|
|
52
|
+
download: DownloadIcon,
|
|
53
|
+
flag: FlagIcon,
|
|
54
|
+
lock: LockIcon,
|
|
55
|
+
mail: MailIcon,
|
|
56
|
+
megaphone: MegaphoneIcon,
|
|
57
|
+
pause: PauseIcon,
|
|
58
|
+
play: PlayIcon,
|
|
59
|
+
refresh: RefreshCwIcon,
|
|
60
|
+
send: SendIcon,
|
|
61
|
+
star: StarIcon,
|
|
62
|
+
unlock: UnlockIcon,
|
|
63
|
+
upload: UploadIcon,
|
|
64
|
+
x: XIcon,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The icon an action names, or a general one for "do something". */
|
|
68
|
+
export function actionIcon(name: string | undefined): LucideIcon {
|
|
69
|
+
return (name && ICONS[name]) || ZapIcon;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What an action is run on: one record, the rows selected, or nothing in particular (the list as a whole). */
|
|
73
|
+
export interface ActionTarget {
|
|
74
|
+
id?: string;
|
|
75
|
+
ids?: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const words = (key: string) => {
|
|
79
|
+
const spaced = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
|
|
80
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** A field an action asks for, as the form's widgets know fields. */
|
|
84
|
+
function widgetField(key: string, def: ActionField): StoredField & { label: string } {
|
|
85
|
+
const options = { required: def.required !== false, ...(def.maxLength ? { maxLength: def.maxLength } : {}) };
|
|
86
|
+
const made =
|
|
87
|
+
def.kind === "enum"
|
|
88
|
+
? build.enum((def.options?.length ? def.options : [""]) as [string, ...string[]], { required: options.required })
|
|
89
|
+
: def.kind === "string" && def.format === "email"
|
|
90
|
+
? build.email(options)
|
|
91
|
+
: def.kind === "string" && def.format === "url"
|
|
92
|
+
? build.url(options)
|
|
93
|
+
: def.kind === "string" && def.format === "tel"
|
|
94
|
+
? build.tel(options)
|
|
95
|
+
: def.kind === "string"
|
|
96
|
+
? build.string(options)
|
|
97
|
+
: def.kind === "text"
|
|
98
|
+
? build.text(options)
|
|
99
|
+
: def.kind === "int"
|
|
100
|
+
? build.int({ required: options.required })
|
|
101
|
+
: def.kind === "float"
|
|
102
|
+
? build.float({ required: options.required })
|
|
103
|
+
: def.kind === "boolean"
|
|
104
|
+
? build.boolean({ required: options.required })
|
|
105
|
+
: def.kind === "date"
|
|
106
|
+
? build.date({ required: options.required })
|
|
107
|
+
: build.datetime({ required: options.required });
|
|
108
|
+
return { ...made, label: def.label ?? words(key) } as StoredField & { label: string };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What the widgets hold while typing, as the API wants it: a number as a number, nothing as nothing. */
|
|
112
|
+
function asInput(fields: Record<string, ActionField>, values: Record<string, string | boolean>): Record<string, unknown> {
|
|
113
|
+
const input: Record<string, unknown> = {};
|
|
114
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
115
|
+
const value = values[key];
|
|
116
|
+
if (def.kind === "boolean") input[key] = value === true;
|
|
117
|
+
else if (value === undefined || value === "") continue;
|
|
118
|
+
else if (def.kind === "int" || def.kind === "float") input[key] = Number(value);
|
|
119
|
+
else input[key] = value;
|
|
120
|
+
}
|
|
121
|
+
return input;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Running a resource's own actions from wherever their buttons are: a record's page, a
|
|
126
|
+
* row's menu, beside New, the selection bar.
|
|
127
|
+
*
|
|
128
|
+
* `start` does what the action calls for first. One with something to fill in opens a
|
|
129
|
+
* sheet; one with a question asks it, in the dialog Delete uses; one with neither runs.
|
|
130
|
+
* Then the message its class returned is the toast, and the page is asked for again.
|
|
131
|
+
* `dialogs` is the sheet and the dialog, to be rendered once by whoever holds the buttons.
|
|
132
|
+
*/
|
|
133
|
+
export function useResourceActions(resourceName: string, nouns: { one: string; many: string }, onDone?: () => void) {
|
|
134
|
+
const router = useRouter();
|
|
135
|
+
const [current, setCurrent] = useState<{ action: ResourceAction; target: ActionTarget } | null>(null);
|
|
136
|
+
const [values, setValues] = useState<Record<string, string | boolean>>({});
|
|
137
|
+
const [issues, setIssues] = useState<Record<string, string>>({});
|
|
138
|
+
const [pending, startTransition] = useTransition();
|
|
139
|
+
|
|
140
|
+
function run(action: ResourceAction, target: ActionTarget, input: Record<string, unknown>) {
|
|
141
|
+
startTransition(async () => {
|
|
142
|
+
const result = await runResourceAction(resourceName, action.key, target, input);
|
|
143
|
+
if (result.ok) {
|
|
144
|
+
toast.success(result.data.message);
|
|
145
|
+
setCurrent(null);
|
|
146
|
+
onDone?.();
|
|
147
|
+
router.refresh();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const byField: Record<string, string> = {};
|
|
151
|
+
for (const issue of result.issues ?? []) byField[issue.path] ??= issue.message;
|
|
152
|
+
// A problem with something that was filled in is shown where it was filled in.
|
|
153
|
+
if (action.fields && Object.keys(byField).some((key) => key in action.fields!)) {
|
|
154
|
+
setIssues(byField);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
toast.error(result.error);
|
|
158
|
+
setCurrent(null);
|
|
159
|
+
// It may have stopped applying while the page was open: show the page as it is now.
|
|
160
|
+
if (result.status === 409 || result.status === 404) router.refresh();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function start(action: ResourceAction, target: ActionTarget = {}) {
|
|
165
|
+
setIssues({});
|
|
166
|
+
setValues(Object.fromEntries(Object.entries(action.fields ?? {}).map(([key, def]) => [key, def.kind === "boolean" ? false : ""])));
|
|
167
|
+
if (!action.fields && !action.confirm) run(action, target, {});
|
|
168
|
+
else setCurrent({ action, target });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const action = current?.action;
|
|
172
|
+
const count = current?.target.ids?.length;
|
|
173
|
+
// "the 1 article you selected", "the 3 articles you selected"
|
|
174
|
+
const selected = count ? ` This is for the ${count} ${count === 1 ? nouns.one : nouns.many} you selected.` : "";
|
|
175
|
+
const asking = Boolean(action && !action.fields);
|
|
176
|
+
const filling = Boolean(action?.fields);
|
|
177
|
+
|
|
178
|
+
const dialogs = (
|
|
179
|
+
<>
|
|
180
|
+
<AlertDialog open={asking} onOpenChange={(open) => !open && !pending && setCurrent(null)}>
|
|
181
|
+
<AlertDialogContent>
|
|
182
|
+
<AlertDialogHeader>
|
|
183
|
+
<AlertDialogTitle>{action?.label}</AlertDialogTitle>
|
|
184
|
+
<AlertDialogDescription>
|
|
185
|
+
{action?.confirm}
|
|
186
|
+
{selected}
|
|
187
|
+
</AlertDialogDescription>
|
|
188
|
+
</AlertDialogHeader>
|
|
189
|
+
<AlertDialogFooter>
|
|
190
|
+
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
|
|
191
|
+
<Button onClick={() => current && run(current.action, current.target, {})} disabled={pending}>
|
|
192
|
+
{pending && <Spinner data-icon="inline-start" />}
|
|
193
|
+
{action?.label}
|
|
194
|
+
</Button>
|
|
195
|
+
</AlertDialogFooter>
|
|
196
|
+
</AlertDialogContent>
|
|
197
|
+
</AlertDialog>
|
|
198
|
+
|
|
199
|
+
<Sheet open={filling} onOpenChange={(open) => !open && !pending && setCurrent(null)}>
|
|
200
|
+
<SheetContent className="flex w-full flex-col gap-0 sm:max-w-md">
|
|
201
|
+
<SheetHeader>
|
|
202
|
+
<SheetTitle>{action?.label}</SheetTitle>
|
|
203
|
+
<SheetDescription>
|
|
204
|
+
{action?.confirm ?? `Fill this in, then ${action?.label.toLowerCase()}.`}
|
|
205
|
+
{selected}
|
|
206
|
+
</SheetDescription>
|
|
207
|
+
</SheetHeader>
|
|
208
|
+
<form
|
|
209
|
+
className="flex min-h-0 flex-1 flex-col"
|
|
210
|
+
onSubmit={(event) => {
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
if (current?.action.fields) run(current.action, current.target, asInput(current.action.fields, values));
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
|
|
216
|
+
{Object.entries(action?.fields ?? {}).map(([key, def]) => {
|
|
217
|
+
const widget = widgetField(key, def);
|
|
218
|
+
const id = `action-${action?.key}-${key}`;
|
|
219
|
+
return (
|
|
220
|
+
<Field key={key} data-invalid={issues[key] ? true : undefined} orientation={def.kind === "boolean" ? "horizontal" : "vertical"}>
|
|
221
|
+
<FieldLabel htmlFor={id}>
|
|
222
|
+
{widget.label}
|
|
223
|
+
{def.required !== false && def.kind !== "boolean" && <span className="text-destructive"> *</span>}
|
|
224
|
+
</FieldLabel>
|
|
225
|
+
<FieldWidget
|
|
226
|
+
id={id}
|
|
227
|
+
name={key}
|
|
228
|
+
resourceName={resourceName}
|
|
229
|
+
fieldKey={key}
|
|
230
|
+
field={widget}
|
|
231
|
+
value={values[key] ?? ""}
|
|
232
|
+
onChange={(value) => setValues((before) => ({ ...before, [key]: value }))}
|
|
233
|
+
invalid={Boolean(issues[key])}
|
|
234
|
+
disabled={pending}
|
|
235
|
+
/>
|
|
236
|
+
{issues[key] && <FieldError>{issues[key]}</FieldError>}
|
|
237
|
+
</Field>
|
|
238
|
+
);
|
|
239
|
+
})}
|
|
240
|
+
</div>
|
|
241
|
+
<SheetFooter className="flex-row justify-end border-t">
|
|
242
|
+
<Button type="button" variant="outline" onClick={() => setCurrent(null)} disabled={pending}>
|
|
243
|
+
Cancel
|
|
244
|
+
</Button>
|
|
245
|
+
<Button type="submit" disabled={pending}>
|
|
246
|
+
{pending && <Spinner data-icon="inline-start" />}
|
|
247
|
+
{action?.label}
|
|
248
|
+
</Button>
|
|
249
|
+
</SheetFooter>
|
|
250
|
+
</form>
|
|
251
|
+
</SheetContent>
|
|
252
|
+
</Sheet>
|
|
253
|
+
</>
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return { start, pending, dialogs };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Buttons for a resource's actions: on a record's page (with the record's id), or beside
|
|
261
|
+
* New for actions on the list as a whole (with none).
|
|
262
|
+
*/
|
|
263
|
+
export function ActionButtons({ resourceName, actions, id }: { resourceName: string; actions: ResourceAction[]; id?: string }) {
|
|
264
|
+
// Buttons here are for one record or for the list as a whole: never for a selection, so no noun is needed.
|
|
265
|
+
const { start, pending, dialogs } = useResourceActions(resourceName, { one: "record", many: "records" });
|
|
266
|
+
if (actions.length === 0) return null;
|
|
267
|
+
return (
|
|
268
|
+
<>
|
|
269
|
+
{actions.map((action) => {
|
|
270
|
+
const Icon = actionIcon(action.icon);
|
|
271
|
+
return (
|
|
272
|
+
<Button key={action.key} variant="outline" disabled={pending} onClick={() => start(action, id ? { id } : {})}>
|
|
273
|
+
<Icon data-icon="inline-start" />
|
|
274
|
+
{action.label}
|
|
275
|
+
</Button>
|
|
276
|
+
);
|
|
277
|
+
})}
|
|
278
|
+
{dialogs}
|
|
279
|
+
</>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
@@ -3,6 +3,8 @@ import { FileCell } from "@/components/dashboard/fields/file-cell";
|
|
|
3
3
|
import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
|
|
4
4
|
import { clientResource, formatValue, optionLabel, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
|
|
5
5
|
import { isSortable } from "@/lib/resource/query";
|
|
6
|
+
import { privateFileUrl } from "@/lib/files";
|
|
7
|
+
import { isRich, richToText } from "@/lib/rich";
|
|
6
8
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
7
9
|
import { Badge } from "@/components/ui/badge";
|
|
8
10
|
import { Button } from "@/components/ui/button";
|
|
@@ -16,7 +18,8 @@ import {
|
|
|
16
18
|
PaginationPrevious,
|
|
17
19
|
} from "@/components/ui/pagination";
|
|
18
20
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
19
|
-
import { resourcePath, adminPermissions, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
|
|
21
|
+
import { resourcePath, adminPermissions, allowedActions, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
|
|
22
|
+
import { actionApplies } from "@/lib/extras";
|
|
20
23
|
import { site } from "@/lib/site";
|
|
21
24
|
import { trashFor } from "@/lib/trash";
|
|
22
25
|
import { cn } from "@/lib/utils";
|
|
@@ -26,6 +29,7 @@ import { isInlineEditable } from "./editable";
|
|
|
26
29
|
import { InlineCell } from "./inline-cell";
|
|
27
30
|
import type { RelationMeta } from "./fields/field-widget";
|
|
28
31
|
import { BulkCreateButton } from "./bulk-create-grid";
|
|
32
|
+
import { ActionButtons } from "./resource-action";
|
|
29
33
|
import { ResourceInsights } from "./resource-insights";
|
|
30
34
|
import { ImportDialog } from "./import-dialog";
|
|
31
35
|
import { LocalTime } from "./local-time";
|
|
@@ -96,6 +100,9 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
const overlayForms = site.dashboard.forms === "sheet";
|
|
103
|
+
// The resource's own actions this person may run: on the list as a whole, and on a record.
|
|
104
|
+
const actions = await allowedActions(resource.name);
|
|
105
|
+
const onRecords = actions.filter((action) => action.on === "record");
|
|
99
106
|
const rowIds = rows.map((row) => String(row.id));
|
|
100
107
|
const exportColumns = [{ key: "id", label: "Id" }, ...fields.filter(([, def]) => def.kind !== "file").map(([key, def]) => ({ key, label: def.label }))];
|
|
101
108
|
|
|
@@ -115,6 +122,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
115
122
|
<div className="flex flex-wrap items-center gap-2">
|
|
116
123
|
<ColumnMenu columns={allColumns.map(({ key, label }) => ({ key, label }))} visible={columns.map((column) => column.key)} />
|
|
117
124
|
<ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
|
|
125
|
+
<ActionButtons resourceName={resource.name} actions={actions.filter((action) => action.on === "collection")} />
|
|
118
126
|
{permissions.create && <ImportDialog resource={forClient} />}
|
|
119
127
|
{permissions.create && <BulkCreateButton resource={forClient} relations={relations} />}
|
|
120
128
|
{permissions.create &&
|
|
@@ -213,10 +221,13 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
213
221
|
} else if (column.def.kind === "enum" && value != null) {
|
|
214
222
|
const tone = statusTone(value);
|
|
215
223
|
content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
|
|
224
|
+
} else if (isRich(column.def) && typeof value === "string") {
|
|
225
|
+
// The start of it, as words: markup is noise in a table.
|
|
226
|
+
content = richToText(value, 120);
|
|
216
227
|
} else if (column.def.kind === "file" && typeof value === "string" && value) {
|
|
217
228
|
// A thumbnail for an image, a filename for anything else — the raw
|
|
218
229
|
// object key is noise in a table.
|
|
219
|
-
content = <FileCell resourceName={resource.name} fieldKey={column.key} value={value} />;
|
|
230
|
+
content = <FileCell resourceName={resource.name} fieldKey={column.key} value={value} download={privateFileUrl(resource.slug, String(row.id), column.key)} />;
|
|
220
231
|
} else if (column.def.kind === "multiselect" && Array.isArray(value) && value.length) {
|
|
221
232
|
const def = column.def;
|
|
222
233
|
content = (
|
|
@@ -304,6 +315,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
304
315
|
canUpdate={permissions.update}
|
|
305
316
|
canDelete={permissions.delete}
|
|
306
317
|
trash={trash}
|
|
318
|
+
actions={onRecords.filter((action) => actionApplies(action, row))}
|
|
307
319
|
/>
|
|
308
320
|
</TableCell>
|
|
309
321
|
</TableRow>
|
|
@@ -398,6 +410,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
398
410
|
label={resource.label}
|
|
399
411
|
pluralLabel={resource.pluralLabel}
|
|
400
412
|
canDelete={permissions.delete}
|
|
413
|
+
actions={onRecords.filter((action) => action.many)}
|
|
401
414
|
rows={rows}
|
|
402
415
|
columns={exportColumns}
|
|
403
416
|
slug={resource.slug}
|
|
@@ -27,7 +27,9 @@ import {
|
|
|
27
27
|
DropdownMenuTrigger,
|
|
28
28
|
} from "@/components/ui/dropdown-menu";
|
|
29
29
|
import { Spinner } from "@/components/ui/spinner";
|
|
30
|
+
import type { ResourceAction } from "@/lib/extras";
|
|
30
31
|
import { keptFor, type TrashInfo } from "@/lib/trash-info";
|
|
32
|
+
import { actionIcon, useResourceActions } from "./resource-action";
|
|
31
33
|
import { ResourceFormSheet, type FormRelations } from "./resource-form-sheet";
|
|
32
34
|
import { toastMovedToTrash } from "./trash-actions";
|
|
33
35
|
|
|
@@ -46,6 +48,7 @@ export function RowActions({
|
|
|
46
48
|
canUpdate = true,
|
|
47
49
|
canDelete = true,
|
|
48
50
|
trash,
|
|
51
|
+
actions = [],
|
|
49
52
|
}: {
|
|
50
53
|
resource: ClientResource;
|
|
51
54
|
id: string;
|
|
@@ -60,8 +63,11 @@ export function RowActions({
|
|
|
60
63
|
canDelete?: boolean;
|
|
61
64
|
/** This resource's trash, when a deleted record goes there instead of going for good. */
|
|
62
65
|
trash?: TrashInfo;
|
|
66
|
+
/** The resource's own actions that this person may run and that apply to this row. */
|
|
67
|
+
actions?: ResourceAction[];
|
|
63
68
|
}) {
|
|
64
69
|
const router = useRouter();
|
|
70
|
+
const own = useResourceActions(resource.name, { one: resource.label.toLowerCase(), many: resource.pluralLabel.toLowerCase() });
|
|
65
71
|
const [confirming, setConfirming] = useState(false);
|
|
66
72
|
const [editing, setEditing] = useState(false);
|
|
67
73
|
const [pending, startTransition] = useTransition();
|
|
@@ -99,6 +105,22 @@ export function RowActions({
|
|
|
99
105
|
</DropdownMenuItem>
|
|
100
106
|
</DropdownMenuGroup>
|
|
101
107
|
<DropdownMenuSeparator />
|
|
108
|
+
{actions.length > 0 && (
|
|
109
|
+
<>
|
|
110
|
+
<DropdownMenuGroup>
|
|
111
|
+
{actions.map((action) => {
|
|
112
|
+
const Icon = actionIcon(action.icon);
|
|
113
|
+
return (
|
|
114
|
+
<DropdownMenuItem key={action.key} disabled={own.pending} onSelect={() => own.start(action, { id })}>
|
|
115
|
+
<Icon />
|
|
116
|
+
{action.label}
|
|
117
|
+
</DropdownMenuItem>
|
|
118
|
+
);
|
|
119
|
+
})}
|
|
120
|
+
</DropdownMenuGroup>
|
|
121
|
+
{(canUpdate || canDelete) && <DropdownMenuSeparator />}
|
|
122
|
+
</>
|
|
123
|
+
)}
|
|
102
124
|
{canUpdate && (
|
|
103
125
|
<DropdownMenuGroup>
|
|
104
126
|
{record ? (
|
|
@@ -128,6 +150,8 @@ export function RowActions({
|
|
|
128
150
|
</DropdownMenuContent>
|
|
129
151
|
</DropdownMenu>
|
|
130
152
|
|
|
153
|
+
{own.dialogs}
|
|
154
|
+
|
|
131
155
|
{record && canUpdate && (
|
|
132
156
|
<ResourceFormSheet
|
|
133
157
|
resource={resource}
|
|
@@ -20,7 +20,9 @@ import { Button } from "@/components/ui/button";
|
|
|
20
20
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
21
21
|
import { Spinner } from "@/components/ui/spinner";
|
|
22
22
|
import { toCsv } from "@/lib/csv";
|
|
23
|
+
import { actionApplies, type ResourceAction } from "@/lib/extras";
|
|
23
24
|
import { keptFor, type TrashInfo } from "@/lib/trash-info";
|
|
25
|
+
import { actionIcon, useResourceActions } from "./resource-action";
|
|
24
26
|
import { toastMovedToTrash } from "./trash-actions";
|
|
25
27
|
|
|
26
28
|
interface SelectionValue {
|
|
@@ -100,15 +102,18 @@ interface BarProps {
|
|
|
100
102
|
/** The resource's address, and its trash when deleted records go there instead of going for good. */
|
|
101
103
|
slug: string;
|
|
102
104
|
trash?: TrashInfo;
|
|
105
|
+
/** The resource's own actions that can be run on several records at once, and that this person may run. */
|
|
106
|
+
actions?: ResourceAction[];
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
/**
|
|
106
110
|
* What you can do with the rows you've ticked. Appears only when something is selected,
|
|
107
111
|
* pinned to the bottom so it's reachable in a long table.
|
|
108
112
|
*/
|
|
109
|
-
export function SelectionBar({ resourceName, label, pluralLabel, canDelete, rows, columns, slug, trash }: BarProps) {
|
|
113
|
+
export function SelectionBar({ resourceName, label, pluralLabel, canDelete, rows, columns, slug, trash, actions = [] }: BarProps) {
|
|
110
114
|
const { selected, clear } = useSelection();
|
|
111
115
|
const router = useRouter();
|
|
116
|
+
const own = useResourceActions(resourceName, { one: label.toLowerCase(), many: pluralLabel.toLowerCase() }, clear);
|
|
112
117
|
const [confirming, setConfirming] = useState(false);
|
|
113
118
|
const [pending, startTransition] = useTransition();
|
|
114
119
|
const count = selected.size;
|
|
@@ -157,6 +162,17 @@ export function SelectionBar({ resourceName, label, pluralLabel, canDelete, rows
|
|
|
157
162
|
<DownloadIcon data-icon="inline-start" />
|
|
158
163
|
Export
|
|
159
164
|
</Button>
|
|
165
|
+
{actions.map((action) => {
|
|
166
|
+
const Icon = actionIcon(action.icon);
|
|
167
|
+
// Offered when it applies to at least one of the rows ticked. Laravel passes over the rest.
|
|
168
|
+
const applicable = rows.filter((row) => selected.has(String(row.id)) && actionApplies(action, row)).map((row) => String(row.id));
|
|
169
|
+
return (
|
|
170
|
+
<Button key={action.key} variant="ghost" size="sm" disabled={own.pending || applicable.length === 0} onClick={() => own.start(action, { ids: applicable })}>
|
|
171
|
+
<Icon data-icon="inline-start" />
|
|
172
|
+
{action.label}
|
|
173
|
+
</Button>
|
|
174
|
+
);
|
|
175
|
+
})}
|
|
160
176
|
{canDelete && (
|
|
161
177
|
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={() => setConfirming(true)}>
|
|
162
178
|
<Trash2Icon data-icon="inline-start" />
|
|
@@ -168,6 +184,8 @@ export function SelectionBar({ resourceName, label, pluralLabel, canDelete, rows
|
|
|
168
184
|
</Button>
|
|
169
185
|
</div>
|
|
170
186
|
|
|
187
|
+
{own.dialogs}
|
|
188
|
+
|
|
171
189
|
<AlertDialog open={confirming} onOpenChange={setConfirming}>
|
|
172
190
|
<AlertDialogContent>
|
|
173
191
|
<AlertDialogHeader>
|
|
@@ -51,8 +51,10 @@ export interface PermissionFeature {
|
|
|
51
51
|
/** The first half of a permission: "products". */
|
|
52
52
|
key: string;
|
|
53
53
|
name: string;
|
|
54
|
-
/** The second halves that mean something for it: create, view, edit, delete. */
|
|
54
|
+
/** The second halves that mean something for it: create, view, edit, delete, then the resource's own actions. */
|
|
55
55
|
actions: string[];
|
|
56
|
+
/** What a resource's own actions are called, by second half: { accept: "Accept" }. */
|
|
57
|
+
labels?: Record<string, string>;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
export interface PermissionModule {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { actionsOf, type ResourceAction } from "@/lib/extras";
|
|
1
2
|
import { cache } from "react";
|
|
2
3
|
import { notFound, redirect } from "next/navigation";
|
|
3
4
|
import { can, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
|
|
@@ -96,6 +97,15 @@ export async function adminPermissions(resourceName: string): Promise<Record<Exc
|
|
|
96
97
|
return { read: read!, create: create!, update: update!, delete: remove! };
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The resource's own actions that the current person may run, for drawing their buttons.
|
|
102
|
+
* Each is a permission by name. Laravel checks it again when the button is pressed.
|
|
103
|
+
*/
|
|
104
|
+
export async function allowedActions(resourceName: string): Promise<ResourceAction[]> {
|
|
105
|
+
const { session, may } = await dashboardSession();
|
|
106
|
+
return session ? actionsOf(resourceName).filter((action) => may(action.permission)) : [];
|
|
107
|
+
}
|
|
108
|
+
|
|
99
109
|
const stores = new Map<string, ResourceStore>();
|
|
100
110
|
|
|
101
111
|
/** CRUD store for a resource by name (404 for unknown names). */
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// What a resource has beyond its fields, which Flare's descriptor has no place for.
|
|
2
|
+
//
|
|
3
|
+
// Written by `php nevela generate` into resources/index.ts as `extras`, by resource
|
|
4
|
+
// name, from the descriptors in the Laravel app. Read through here, on the server: a
|
|
5
|
+
// client component is handed what it needs of it as props.
|
|
6
|
+
|
|
7
|
+
import * as registry from "@/resources";
|
|
8
|
+
|
|
9
|
+
/** A field an action asks for, as the descriptor has it. */
|
|
10
|
+
export interface ActionField {
|
|
11
|
+
kind: "string" | "text" | "int" | "float" | "boolean" | "date" | "datetime" | "enum";
|
|
12
|
+
format?: string;
|
|
13
|
+
options?: string[];
|
|
14
|
+
required?: boolean;
|
|
15
|
+
label?: string;
|
|
16
|
+
maxLength?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Something that can be done to a record, or to the list, beyond creating, editing and
|
|
21
|
+
* deleting: the descriptor's "actions" section. What it does is a class in the Laravel
|
|
22
|
+
* app; this is what the dashboard needs to offer it.
|
|
23
|
+
*/
|
|
24
|
+
export interface ResourceAction {
|
|
25
|
+
/** Its name in the descriptor and in the address: "accept". */
|
|
26
|
+
key: string;
|
|
27
|
+
/** The permission that allows it: "internship_applications.accept". */
|
|
28
|
+
permission: string;
|
|
29
|
+
label: string;
|
|
30
|
+
/** A lucide icon name. */
|
|
31
|
+
icon?: string;
|
|
32
|
+
/** Where its button is: on one record, or on the list as a whole. */
|
|
33
|
+
on: "record" | "collection";
|
|
34
|
+
/** A question asked before it runs. */
|
|
35
|
+
confirm?: string;
|
|
36
|
+
/** Field values a record must have for the action to apply to it. */
|
|
37
|
+
when?: Record<string, string | number | boolean | null>;
|
|
38
|
+
/** What it asks for, in a sheet. */
|
|
39
|
+
fields?: Record<string, ActionField>;
|
|
40
|
+
/** A record action that can also be run on the rows selected in the table. */
|
|
41
|
+
many?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ResourceExtras {
|
|
45
|
+
actions?: Record<string, Omit<ResourceAction, "key">>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** An app generated before there were any has no `extras` in its registry, and none to have. */
|
|
49
|
+
const extras = ((registry as { extras?: unknown }).extras ?? {}) as Record<string, ResourceExtras>;
|
|
50
|
+
|
|
51
|
+
/** A resource's actions, in the order the descriptor gives them. */
|
|
52
|
+
export function actionsOf(resourceName: string): ResourceAction[] {
|
|
53
|
+
return Object.entries(extras[resourceName]?.actions ?? {}).map(([key, action]) => ({ ...action, key }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether an action applies to a record as it is now. Laravel asks the same before running it. */
|
|
57
|
+
export function actionApplies(action: ResourceAction, record: Record<string, unknown>): boolean {
|
|
58
|
+
return Object.entries(action.when ?? {}).every(([field, wanted]) => {
|
|
59
|
+
const value = record[field] ?? null;
|
|
60
|
+
if (wanted === null) return value === null;
|
|
61
|
+
if (value === null) return false;
|
|
62
|
+
// A yes/no value may arrive as 1 or 0.
|
|
63
|
+
if (typeof wanted === "boolean") return (value === true || value === 1 || value === "1") === wanted;
|
|
64
|
+
return String(value) === String(wanted);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -17,5 +17,19 @@ export function fileUrl(key: string, rendition?: string): string {
|
|
|
17
17
|
return `/api/_nevela/files/${path.split("/").map(encodeURIComponent).join("/")}`;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Whether a key is a private file's. Such a file has no address of its own: asked for by
|
|
22
|
+
* key, Laravel answers that there is no such file. It is fetched through its record.
|
|
23
|
+
*/
|
|
24
|
+
export const isPrivateKey = (key: string) => key.startsWith("private/");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where a private file is fetched from: the record it belongs to and the field it is in.
|
|
28
|
+
* Laravel hands it over only to someone signed in who may view that record.
|
|
29
|
+
*/
|
|
30
|
+
export function privateFileUrl(resourceSlug: string, id: string, fieldKey: string): string {
|
|
31
|
+
return `/api/_nevela/private/${encodeURIComponent(resourceSlug)}/${encodeURIComponent(id)}/${encodeURIComponent(fieldKey)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
/** "products/image/2026/10/<uuid>-kettle.webp" → "kettle.webp" */
|
|
21
35
|
export const fileName = (key: string) => key.slice(key.lastIndexOf("/") + 1).replace(/^[0-9a-f-]{36}-/, "");
|
|
@@ -34,6 +34,17 @@ export async function callerHeaders(): Promise<Record<string, string>> {
|
|
|
34
34
|
if (agent) sent["X-Nevela-User-Agent"] = agent.slice(0, 500);
|
|
35
35
|
const address = (incoming.get("x-forwarded-for") ?? "").split(",")[0]?.trim() || incoming.get("x-real-ip");
|
|
36
36
|
if (address) sent["X-Nevela-Ip"] = address.replace(/^::ffff:/, "");
|
|
37
|
+
// That one is for showing: the first entry of X-Forwarded-For is whatever the request
|
|
38
|
+
// came with, and anyone can send one. The address a visitor is *counted* by (the public
|
|
39
|
+
// routes' throttle) has to be one they couldn't have made up. Each proxy adds the
|
|
40
|
+
// address it was called from to the end of the list, so with one proxy in front of this
|
|
41
|
+
// app the last entry is the visitor as that proxy saw them. NEVELA_TRUSTED_PROXIES says
|
|
42
|
+
// how many proxies are in front: 1 unless set, and 0 when this app is reached directly,
|
|
43
|
+
// in which case nobody's word is taken and visitors are counted together.
|
|
44
|
+
const proxies = Number.parseInt(process.env.NEVELA_TRUSTED_PROXIES ?? "1", 10);
|
|
45
|
+
const chain = (incoming.get("x-forwarded-for") ?? "").split(",").map((part) => part.trim()).filter(Boolean);
|
|
46
|
+
const counted = Number.isInteger(proxies) && proxies > 0 ? chain[chain.length - proxies] : undefined;
|
|
47
|
+
sent["X-Nevela-Client"] = counted ? counted.replace(/^::ffff:/, "") : "unknown";
|
|
37
48
|
// Proves to Laravel that this is the dashboard speaking for a browser, and not someone
|
|
38
49
|
// calling the API with made-up headers. The same value is in the Laravel app's .env.
|
|
39
50
|
if (process.env.NEVELA_PROXY_SECRET) sent["X-Nevela-Proxy-Secret"] = process.env.NEVELA_PROXY_SECRET;
|
|
@@ -80,3 +91,52 @@ export async function laravel(path: string, { method = "GET", body, token }: Lar
|
|
|
80
91
|
}
|
|
81
92
|
return { status: response.status, ok: response.ok, body: parsed };
|
|
82
93
|
}
|
|
94
|
+
|
|
95
|
+
export interface PublicRequest {
|
|
96
|
+
/** How long an answer may be reused, in seconds. Default 300. `false` keeps it until one of its tags is revalidated. */
|
|
97
|
+
revalidate?: number | false;
|
|
98
|
+
/** What makes it stale. `resourceTag("Post")` from lib/cache: the dashboard revalidates it when a post is created, changed, deleted or restored. */
|
|
99
|
+
tags?: string[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read what a resource's "public" section opens, for a page anyone can visit.
|
|
104
|
+
*
|
|
105
|
+
* ```ts
|
|
106
|
+
* const { body } = await laravelPublic("posts?sort=-publishedAt", { tags: [resourceTag("Post")] });
|
|
107
|
+
* ```
|
|
108
|
+
*
|
|
109
|
+
* Unlike `laravel()` it carries nothing of the person asking: no token and none of their
|
|
110
|
+
* headers. The answer is the same for everyone, so it is cached and shared between
|
|
111
|
+
* visitors, and a published post appears as soon as the dashboard saves it, because
|
|
112
|
+
* saving revalidates the resource's tag.
|
|
113
|
+
*
|
|
114
|
+
* `path` is under `{api}/public/`. To send a form (a public create), post to
|
|
115
|
+
* `/api/public/<slug>` from the browser: see app/api/[...path]/route.ts.
|
|
116
|
+
*/
|
|
117
|
+
export async function laravelPublic(path: string, { revalidate = 300, tags = [] }: PublicRequest = {}): Promise<LaravelResponse> {
|
|
118
|
+
let response: Response;
|
|
119
|
+
try {
|
|
120
|
+
response = await fetch(`${API_URL}/public/${path.replace(/^\/+/, "")}`, {
|
|
121
|
+
headers: {
|
|
122
|
+
Accept: "application/json",
|
|
123
|
+
// The site reading its own data, to show to many: Laravel doesn't count it against
|
|
124
|
+
// a visitor's allowance, which it otherwise would against this server's address.
|
|
125
|
+
...(process.env.NEVELA_PROXY_SECRET ? { "X-Nevela-Proxy-Secret": process.env.NEVELA_PROXY_SECRET } : {}),
|
|
126
|
+
},
|
|
127
|
+
next: { revalidate, tags },
|
|
128
|
+
});
|
|
129
|
+
} catch {
|
|
130
|
+
return { status: 502, ok: false, body: { error: "Can't reach the API." } };
|
|
131
|
+
}
|
|
132
|
+
const text = response.status === 204 ? "" : await response.text();
|
|
133
|
+
let parsed: unknown = null;
|
|
134
|
+
if (text) {
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(text);
|
|
137
|
+
} catch {
|
|
138
|
+
parsed = { error: `Laravel answered ${response.status} with something that isn't JSON.` };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { status: response.status, ok: response.ok, body: parsed };
|
|
142
|
+
}
|