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
|
@@ -22,6 +22,15 @@ use Throwable;
|
|
|
22
22
|
*/
|
|
23
23
|
final class Uploads
|
|
24
24
|
{
|
|
25
|
+
/** Who uploaded a file that came in through a public form and hasn't been used in a create yet. */
|
|
26
|
+
public const UNCLAIMED = 'public:unclaimed';
|
|
27
|
+
|
|
28
|
+
/** And one that has: it belongs to a record now, like any other. */
|
|
29
|
+
public const CLAIMED = 'public';
|
|
30
|
+
|
|
31
|
+
/** What every private file's key starts with. */
|
|
32
|
+
public const PRIVATE_PREFIX = 'private/';
|
|
33
|
+
|
|
25
34
|
/** @var array<string, Upload|null> Rows already looked up in this request, by key. */
|
|
26
35
|
private static array $loaded = [];
|
|
27
36
|
|
|
@@ -30,6 +39,27 @@ final class Uploads
|
|
|
30
39
|
return (string) config('nevela.uploads.disk', 'public');
|
|
31
40
|
}
|
|
32
41
|
|
|
42
|
+
/** Where private files are kept: a disk the web server doesn't serve. */
|
|
43
|
+
public static function privateDisk(): string
|
|
44
|
+
{
|
|
45
|
+
return (string) config('nevela.uploads.private_disk', 'local');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a key is a private file's. It is said by the key itself, so that nothing
|
|
50
|
+
* which hands out files by key has to look anything up to know it must not.
|
|
51
|
+
*/
|
|
52
|
+
public static function isPrivate(?string $key): bool
|
|
53
|
+
{
|
|
54
|
+
return is_string($key) && str_starts_with($key, self::PRIVATE_PREFIX);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Where a private file is on its disk, beside the originals Nevela keeps there. */
|
|
58
|
+
public static function privatePath(string $key): string
|
|
59
|
+
{
|
|
60
|
+
return 'nevela/'.$key;
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
public static function maxBytes(): int
|
|
34
64
|
{
|
|
35
65
|
return (int) config('nevela.uploads.max_bytes', 10 * 1024 * 1024);
|
|
@@ -73,6 +103,23 @@ final class Uploads
|
|
|
73
103
|
];
|
|
74
104
|
$profile = self::profile($field->profile);
|
|
75
105
|
|
|
106
|
+
// A private file is stored as it came, where the web server doesn't reach. Nothing
|
|
107
|
+
// is made from it: a thumbnail would be a copy of it on a disk anyone can read.
|
|
108
|
+
if ($field->private) {
|
|
109
|
+
$key = self::PRIVATE_PREFIX."{$base}.{$extension}";
|
|
110
|
+
self::write(Storage::disk(self::privateDisk()), self::privatePath($key), $bytes);
|
|
111
|
+
$size = str_starts_with($mime, 'image/') ? @getimagesizefromstring($bytes) : false;
|
|
112
|
+
|
|
113
|
+
return self::remember(Upload::create(['disk' => self::privateDisk()] + $row + [
|
|
114
|
+
'key' => $key,
|
|
115
|
+
'mime' => $mime,
|
|
116
|
+
'size' => strlen($bytes),
|
|
117
|
+
'width' => $size ? $size[0] : null,
|
|
118
|
+
'height' => $size ? $size[1] : null,
|
|
119
|
+
'optimised' => false,
|
|
120
|
+
]));
|
|
121
|
+
}
|
|
122
|
+
|
|
76
123
|
if ($field->isImage() && in_array($mime, ImageOptimizer::OPTIMISABLE, true)) {
|
|
77
124
|
$written = [];
|
|
78
125
|
$originals = Storage::disk((string) config('nevela.uploads.originals_disk', 'local'));
|
|
@@ -193,6 +240,11 @@ final class Uploads
|
|
|
193
240
|
return null;
|
|
194
241
|
}
|
|
195
242
|
$upload = self::find($key);
|
|
243
|
+
// A private file has no address of its own. It is fetched through the record it
|
|
244
|
+
// belongs to (see privateRef), by someone who may view that record.
|
|
245
|
+
if (self::isPrivate($key)) {
|
|
246
|
+
return ['key' => $key, 'url' => null, 'private' => true, 'name' => $upload?->name, 'mime' => $upload?->mime, 'size' => $upload?->size];
|
|
247
|
+
}
|
|
196
248
|
if ($upload === null) {
|
|
197
249
|
return ['key' => $key, 'url' => self::url($key)];
|
|
198
250
|
}
|
|
@@ -219,6 +271,65 @@ final class Uploads
|
|
|
219
271
|
];
|
|
220
272
|
}
|
|
221
273
|
|
|
274
|
+
/**
|
|
275
|
+
* A private file as a record shows it: what it is, and the address it is fetched from,
|
|
276
|
+
* which names the record and the field and never the key.
|
|
277
|
+
*
|
|
278
|
+
* @return array<string, mixed>|null
|
|
279
|
+
*/
|
|
280
|
+
public static function privateRef(?string $key, string $slug, mixed $id, string $field): ?array
|
|
281
|
+
{
|
|
282
|
+
$ref = self::ref($key);
|
|
283
|
+
if ($ref === null || ! self::isPrivate($key)) {
|
|
284
|
+
return $ref;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return ['url' => route('nevela.private.show', ['slug' => $slug, 'id' => $id, 'field' => $field])] + $ref;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Remove a private file for good: from its disk, and the record of it. For when the
|
|
292
|
+
* record it belonged to has been removed for good.
|
|
293
|
+
*/
|
|
294
|
+
public static function discard(string $key): void
|
|
295
|
+
{
|
|
296
|
+
if (! self::isPrivate($key)) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
$upload = Upload::query()->where('key', $key)->first();
|
|
300
|
+
Storage::disk($upload?->disk ?? self::privateDisk())->delete(self::privatePath($key));
|
|
301
|
+
$upload?->delete();
|
|
302
|
+
unset(self::$loaded[$key]);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Remove what visitors uploaded and never used: a form that was started and left.
|
|
307
|
+
* Each such file waits an hour for its create (PublicController::CLAIM_SECONDS), and
|
|
308
|
+
* goes after that, with anything that was made from it.
|
|
309
|
+
*
|
|
310
|
+
* @return int How many were removed
|
|
311
|
+
*/
|
|
312
|
+
public static function purgeUnclaimed(int $olderThanSeconds = 3600): int
|
|
313
|
+
{
|
|
314
|
+
$removed = 0;
|
|
315
|
+
Upload::query()->where('uploaded_by', self::UNCLAIMED)->where('created_at', '<', now()->subSeconds($olderThanSeconds))->orderBy('id')
|
|
316
|
+
->chunkById(200, function ($uploads) use (&$removed) {
|
|
317
|
+
foreach ($uploads as $upload) {
|
|
318
|
+
$disk = Storage::disk($upload->disk);
|
|
319
|
+
$disk->delete(self::isPrivate($upload->key) ? self::privatePath($upload->key) : $upload->key);
|
|
320
|
+
$disk->delete(array_column($upload->renditions ?? [], 'key'));
|
|
321
|
+
if ($upload->original_key !== null) {
|
|
322
|
+
Storage::disk((string) config('nevela.uploads.originals_disk', 'local'))->delete($upload->original_key);
|
|
323
|
+
}
|
|
324
|
+
unset(self::$loaded[$upload->key]);
|
|
325
|
+
$upload->delete();
|
|
326
|
+
$removed++;
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
return $removed;
|
|
331
|
+
}
|
|
332
|
+
|
|
222
333
|
public static function forget(): void
|
|
223
334
|
{
|
|
224
335
|
self::$loaded = [];
|
|
@@ -7,6 +7,7 @@ use Illuminate\Database\UniqueConstraintViolationException;
|
|
|
7
7
|
use Illuminate\Http\JsonResponse;
|
|
8
8
|
use Illuminate\Http\Request;
|
|
9
9
|
use Illuminate\Support\Facades\DB;
|
|
10
|
+
use Illuminate\Support\Str;
|
|
10
11
|
use InvalidArgumentException;
|
|
11
12
|
use Nevela\Laravel\Media\Uploads;
|
|
12
13
|
use Nevela\Laravel\Support\Descriptor;
|
|
@@ -21,7 +22,7 @@ use Nevela\Laravel\Support\Naming;
|
|
|
21
22
|
final class Nevela
|
|
22
23
|
{
|
|
23
24
|
/** Written by `pnpm release`; the same number as the repository's git tag. */
|
|
24
|
-
public const VERSION = '0.
|
|
25
|
+
public const VERSION = '0.8.0';
|
|
25
26
|
|
|
26
27
|
/** @var array<string, Descriptor> */
|
|
27
28
|
private static array $descriptors = [];
|
|
@@ -76,7 +77,18 @@ final class Nevela
|
|
|
76
77
|
*/
|
|
77
78
|
public static function list(Builder $query, string $name, Request $request, string $resourceClass): JsonResponse
|
|
78
79
|
{
|
|
79
|
-
$
|
|
80
|
+
return self::listFor($query, self::resource($name), $request, $resourceClass);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The same, for a descriptor that was handed over and not looked up: the public list
|
|
85
|
+
* is the resource with only the fields anyone may see, so nothing else can be sorted,
|
|
86
|
+
* filtered or searched by.
|
|
87
|
+
*
|
|
88
|
+
* @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass
|
|
89
|
+
*/
|
|
90
|
+
public static function listFor(Builder $query, Descriptor $resource, Request $request, string $resourceClass): JsonResponse
|
|
91
|
+
{
|
|
80
92
|
$list = ListQuery::parse($resource, $request->query(), (int) config('nevela.per_page', 25), (int) config('nevela.max_per_page', 100));
|
|
81
93
|
if (! $list->ok()) {
|
|
82
94
|
return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
|
|
@@ -124,12 +136,32 @@ final class Nevela
|
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
foreach ($list->filters as $filter) {
|
|
139
|
+
$field = $resource->fields[$filter['field']] ?? null;
|
|
140
|
+
if ($filter['value'] !== null && $field?->kind === 'belongsTo' && ! self::couldBeId((string) $field->target, (string) $filter['value'])) {
|
|
141
|
+
// Nothing belongs to a record that can't exist. Asked anyway, some databases
|
|
142
|
+
// answer a number column given letters with an error instead of with no rows.
|
|
143
|
+
$query->whereRaw('1 = 0');
|
|
144
|
+
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
127
147
|
$filter['value'] === null
|
|
128
148
|
? $query->whereNull($filter['column'])
|
|
129
149
|
: $query->where($filter['column'], $filter['value']);
|
|
130
150
|
}
|
|
131
151
|
}
|
|
132
152
|
|
|
153
|
+
/** Whether a value has the shape of one of a resource's ids: digits, or a UUID. */
|
|
154
|
+
private static function couldBeId(string $name, string $value): bool
|
|
155
|
+
{
|
|
156
|
+
try {
|
|
157
|
+
$integers = self::resource($name)->idType === 'int';
|
|
158
|
+
} catch (InvalidArgumentException) {
|
|
159
|
+
return true; // not a resource this app describes: its ids are its own business
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return $integers ? preg_match('/^[0-9]{1,18}$/', $value) === 1 : Str::isUuid($value);
|
|
163
|
+
}
|
|
164
|
+
|
|
133
165
|
/**
|
|
134
166
|
* GET /{slug}/_insights?unit=day|week|month&q&filter[field] → { total, unit, series, breakdown }.
|
|
135
167
|
*
|
|
@@ -318,6 +350,26 @@ final class Nevela
|
|
|
318
350
|
return Uploads::ref($key);
|
|
319
351
|
}
|
|
320
352
|
|
|
353
|
+
/**
|
|
354
|
+
* The same for a private file: what it is, and the address it is fetched from by
|
|
355
|
+
* someone who may view the record, which names the record and never the file's key.
|
|
356
|
+
*
|
|
357
|
+
* @return array<string, mixed>|null
|
|
358
|
+
*/
|
|
359
|
+
public static function privateFile(?string $key, string $slug, mixed $id, string $field): ?array
|
|
360
|
+
{
|
|
361
|
+
return Uploads::privateRef($key, $slug, $id, $field);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* A rich text field's HTML, as it is safe to show: only the tags and attributes in
|
|
366
|
+
* config('nevela.rich_text'). Generated API resources send every rich text field through it.
|
|
367
|
+
*/
|
|
368
|
+
public static function html(?string $html): ?string
|
|
369
|
+
{
|
|
370
|
+
return $html === null ? null : Support\Html::clean($html);
|
|
371
|
+
}
|
|
372
|
+
|
|
321
373
|
/** Whether a request is one Nevela answers (so errors use Flare's shape). */
|
|
322
374
|
public static function handles(Request $request): bool
|
|
323
375
|
{
|
|
@@ -13,6 +13,7 @@ use Illuminate\Support\Facades\Route;
|
|
|
13
13
|
use Illuminate\Support\ServiceProvider;
|
|
14
14
|
use Nevela\Laravel\Access\Access;
|
|
15
15
|
use Nevela\Laravel\Access\Permissions;
|
|
16
|
+
use Nevela\Laravel\Console\AdoptCommand;
|
|
16
17
|
use Nevela\Laravel\Console\DevCommand;
|
|
17
18
|
use Nevela\Laravel\Console\GenerateCommand;
|
|
18
19
|
use Nevela\Laravel\Console\MakeResourceCommand;
|
|
@@ -31,7 +32,9 @@ use Nevela\Laravel\Http\FlareErrors;
|
|
|
31
32
|
use Nevela\Laravel\Http\Auth\AccountController;
|
|
32
33
|
use Nevela\Laravel\Http\Auth\SecurityController;
|
|
33
34
|
use Nevela\Laravel\Http\Auth\SignInController;
|
|
35
|
+
use Nevela\Laravel\Http\PublicThrottle;
|
|
34
36
|
use Nevela\Laravel\Http\TokenController;
|
|
37
|
+
use Nevela\Laravel\Http\ActionController;
|
|
35
38
|
use Nevela\Laravel\Http\TrashController;
|
|
36
39
|
use Nevela\Laravel\Http\UploadController;
|
|
37
40
|
use Nevela\Laravel\Media\Uploads;
|
|
@@ -58,7 +61,7 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
58
61
|
|
|
59
62
|
$this->publishes([__DIR__.'/../config/nevela.php' => config_path('nevela.php')], 'nevela-config');
|
|
60
63
|
$this->commands([
|
|
61
|
-
MakeResourceCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
|
|
64
|
+
MakeResourceCommand::class, AdoptCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
|
|
62
65
|
UpdateCommand::class, DevCommand::class, StatusCommand::class, VersionCommand::class, SetupCommand::class,
|
|
63
66
|
TrashCommand::class,
|
|
64
67
|
]);
|
|
@@ -172,12 +175,21 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
172
175
|
'app' => Nevela::fingerprint(),
|
|
173
176
|
]))->name('nevela.ping');
|
|
174
177
|
|
|
178
|
+
Route::aliasMiddleware('nevela.throttle', PublicThrottle::class);
|
|
179
|
+
|
|
180
|
+
// What a resource's "public" section opens: no sign-in, and each route its own throttle.
|
|
181
|
+
if (is_file($open = base_path('routes/nevela-public.php'))) {
|
|
182
|
+
Route::prefix(trim($prefix.'/public', '/'))->middleware('api')->name('nevela.public.')->group($open);
|
|
183
|
+
}
|
|
184
|
+
|
|
175
185
|
// Stored files. Open, like any image on a website: the key is the secret.
|
|
176
186
|
Route::prefix($prefix)->middleware('api')->get('_nevela/files/{path}', [UploadController::class, 'show'])
|
|
177
187
|
->where('path', '.*')->name('nevela.files.show');
|
|
178
188
|
|
|
179
189
|
Route::prefix($prefix)->middleware(config('nevela.middleware', ['api', 'auth:sanctum']))->name('nevela.')->group(function () {
|
|
180
190
|
Route::put('_nevela/uploads/{resource}/{field}', [UploadController::class, 'store'])->name('uploads.store');
|
|
191
|
+
// A private file: only for someone who may view the record it belongs to.
|
|
192
|
+
Route::get('_nevela/private/{slug}/{id}/{field}', [UploadController::class, 'download'])->name('private.show');
|
|
181
193
|
|
|
182
194
|
// The image profiles, for a client that wants to know which renditions exist.
|
|
183
195
|
Route::get('_nevela/profiles', fn () => response()->json([
|
|
@@ -221,6 +233,10 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
221
233
|
'data' => array_map(fn ($d) => $d->toArray(), Nevela::all()),
|
|
222
234
|
]))->name('resources');
|
|
223
235
|
|
|
236
|
+
// A resource's own actions: on one record, and on the list or a selection from it.
|
|
237
|
+
Route::post('{slug}/_actions/{action}', [ActionController::class, 'collection'])->name('actions.collection');
|
|
238
|
+
Route::post('{slug}/{id}/_actions/{action}', [ActionController::class, 'record'])->name('actions.record');
|
|
239
|
+
|
|
224
240
|
if (is_file($routes = base_path('routes/nevela.php'))) {
|
|
225
241
|
require $routes;
|
|
226
242
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Support;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Validation\Rule;
|
|
6
|
+
use InvalidArgumentException;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Something that can be done to a record, or to a resource as a whole, beyond creating,
|
|
10
|
+
* editing and deleting it: accept an application, send an announcement.
|
|
11
|
+
*
|
|
12
|
+
* The descriptor says what it is called, where its button goes and what it asks for.
|
|
13
|
+
* What it does is a class of the app's own, written once: App\Nevela\Actions\<Resource>\<Action>.
|
|
14
|
+
* Between the two, Nevela does the route, the permission, the checking and the button.
|
|
15
|
+
*/
|
|
16
|
+
final class Action
|
|
17
|
+
{
|
|
18
|
+
/** Where its button is: on one record, or on the list as a whole. */
|
|
19
|
+
public const ON = ['record', 'collection'];
|
|
20
|
+
|
|
21
|
+
/** Names that already mean something for every resource. */
|
|
22
|
+
public const RESERVED = ['create', 'view', 'edit', 'delete', 'update', 'restore', 'list', 'read'];
|
|
23
|
+
|
|
24
|
+
/** What an action can ask for. A picture or a relation is a record's business, not a button's. */
|
|
25
|
+
public const KINDS = ['string', 'text', 'int', 'float', 'boolean', 'date', 'datetime', 'enum'];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param string $name camelCase, as in the descriptor and the address: accept, sendAnnouncement
|
|
29
|
+
* @param string|null $confirm A question to ask before it runs
|
|
30
|
+
* @param array<string, scalar|null> $when Field values a record must have for the action to apply to it
|
|
31
|
+
* @param array<string, Field> $fields What it asks the person for, in a sheet
|
|
32
|
+
* @param bool $many A record action that can also be run on a selection of rows
|
|
33
|
+
*/
|
|
34
|
+
public function __construct(
|
|
35
|
+
public readonly string $name,
|
|
36
|
+
public readonly string $label,
|
|
37
|
+
public readonly string $on = 'record',
|
|
38
|
+
public readonly ?string $icon = null,
|
|
39
|
+
public readonly ?string $confirm = null,
|
|
40
|
+
public readonly array $when = [],
|
|
41
|
+
public readonly array $fields = [],
|
|
42
|
+
public readonly bool $many = false,
|
|
43
|
+
) {
|
|
44
|
+
if (! preg_match('/^[a-z][A-Za-z0-9]*$/', $name)) {
|
|
45
|
+
throw new InvalidArgumentException("Action \"{$name}\" must be camelCase, e.g. accept or sendAnnouncement.");
|
|
46
|
+
}
|
|
47
|
+
if (in_array(Naming::snake($name), self::RESERVED, true)) {
|
|
48
|
+
throw new InvalidArgumentException("\"{$name}\" is something every resource does already. Call the action something else.");
|
|
49
|
+
}
|
|
50
|
+
if (! in_array($on, self::ON, true)) {
|
|
51
|
+
throw new InvalidArgumentException("Action \"{$name}\" is on \"{$on}\". Use record or collection.");
|
|
52
|
+
}
|
|
53
|
+
if ($on === 'collection' && ($when !== [] || $many)) {
|
|
54
|
+
throw new InvalidArgumentException("Action \"{$name}\" is on the collection, so it has no one record to check \"when\" against or to select.");
|
|
55
|
+
}
|
|
56
|
+
foreach ($when as $field => $value) {
|
|
57
|
+
if (! is_scalar($value) && $value !== null) {
|
|
58
|
+
throw new InvalidArgumentException("Action \"{$name}\": \"when\" compares \"{$field}\" with one value, or with null.");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
foreach ($fields as $field) {
|
|
62
|
+
if (! in_array($field->kind, self::KINDS, true)) {
|
|
63
|
+
throw new InvalidArgumentException("Action \"{$name}\" can't ask for \"{$field->name}\": a {$field->kind} isn't something a button asks for. Use: ".implode(', ', self::KINDS).'.');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param array<string, mixed> $data */
|
|
69
|
+
public static function fromArray(string $name, array $data): self
|
|
70
|
+
{
|
|
71
|
+
$fields = [];
|
|
72
|
+
foreach ((array) ($data['fields'] ?? []) as $key => $field) {
|
|
73
|
+
$fields[(string) $key] = Field::fromArray((string) $key, (array) $field);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return new self(
|
|
77
|
+
$name,
|
|
78
|
+
(string) ($data['label'] ?? Naming::humanize($name)),
|
|
79
|
+
(string) ($data['on'] ?? 'record'),
|
|
80
|
+
$data['icon'] ?? null,
|
|
81
|
+
$data['confirm'] ?? null,
|
|
82
|
+
(array) ($data['when'] ?? []),
|
|
83
|
+
$fields,
|
|
84
|
+
(bool) ($data['many'] ?? false),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @return array<string, mixed> */
|
|
89
|
+
public function toArray(): array
|
|
90
|
+
{
|
|
91
|
+
return array_filter([
|
|
92
|
+
'label' => $this->label,
|
|
93
|
+
'icon' => $this->icon,
|
|
94
|
+
'on' => $this->on,
|
|
95
|
+
'confirm' => $this->confirm,
|
|
96
|
+
'when' => $this->when ?: null,
|
|
97
|
+
'fields' => $this->fields ? array_map(fn (Field $f) => $f->toArray(), $this->fields) : null,
|
|
98
|
+
'many' => $this->many ?: null,
|
|
99
|
+
], fn ($v) => $v !== null);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The second half of its permission: "send_announcement" in "newsletter_subscriptions.send_announcement". */
|
|
103
|
+
public function key(): string
|
|
104
|
+
{
|
|
105
|
+
return Naming::snake($this->name);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The class that does it, without its namespace: SendAnnouncement. */
|
|
109
|
+
public function className(): string
|
|
110
|
+
{
|
|
111
|
+
return Naming::pascal($this->name);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether it applies to a record with these values, by field name.
|
|
116
|
+
*
|
|
117
|
+
* @param array<string, mixed> $values
|
|
118
|
+
*/
|
|
119
|
+
public function applies(array $values): bool
|
|
120
|
+
{
|
|
121
|
+
foreach ($this->when as $field => $wanted) {
|
|
122
|
+
$value = $values[$field] ?? null;
|
|
123
|
+
$same = match (true) {
|
|
124
|
+
$wanted === null => $value === null,
|
|
125
|
+
$value === null => false,
|
|
126
|
+
// A yes/no column comes back from some databases as 1 and 0.
|
|
127
|
+
is_bool($wanted) => (bool) $value === $wanted,
|
|
128
|
+
default => (string) $value === (string) $wanted,
|
|
129
|
+
};
|
|
130
|
+
if (! $same) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The rules its fields are checked by: the ones a resource's field of the same kind has.
|
|
140
|
+
*
|
|
141
|
+
* @return array<string, list<mixed>>
|
|
142
|
+
*/
|
|
143
|
+
public function rules(): array
|
|
144
|
+
{
|
|
145
|
+
$rules = [];
|
|
146
|
+
foreach ($this->fields as $field) {
|
|
147
|
+
$rules[$field->name] = [$field->required ? 'required' : 'nullable', ...match (true) {
|
|
148
|
+
$field->format === 'email' => ['email', 'max:'.($field->maxLength ?? 255)],
|
|
149
|
+
$field->format === 'url' => ['url', 'max:'.($field->maxLength ?? 2048)],
|
|
150
|
+
$field->format === 'tel' => ['string', 'max:'.($field->maxLength ?? 32)],
|
|
151
|
+
$field->format === 'slug' => ['string', 'alpha_dash', 'max:'.($field->maxLength ?? 255)],
|
|
152
|
+
$field->format === 'color' => ['string', 'regex:/^#[0-9a-fA-F]{6}$/'],
|
|
153
|
+
$field->kind === 'string' => ['string', 'max:'.($field->maxLength ?? 255)],
|
|
154
|
+
$field->kind === 'text' => ['string', 'max:'.($field->maxLength ?? 65535)],
|
|
155
|
+
$field->kind === 'int' && $field->format === 'percent' => ['integer', 'between:0,100'],
|
|
156
|
+
$field->kind === 'int' => ['integer'],
|
|
157
|
+
$field->kind === 'float' && $field->format === 'percent' => ['numeric', 'between:0,100'],
|
|
158
|
+
$field->kind === 'float' => ['numeric'],
|
|
159
|
+
$field->kind === 'boolean' => ['boolean'],
|
|
160
|
+
$field->kind === 'date' => ['date_format:Y-m-d'],
|
|
161
|
+
$field->kind === 'datetime' => ['date'],
|
|
162
|
+
$field->kind === 'enum' => [Rule::in($field->options)],
|
|
163
|
+
}];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return $rules;
|
|
167
|
+
}
|
|
168
|
+
}
|