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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
namespace Nevela\Laravel\Generator;
|
|
4
4
|
|
|
5
|
+
use Nevela\Laravel\Support\Action;
|
|
5
6
|
use Nevela\Laravel\Support\Descriptor;
|
|
6
7
|
use Nevela\Laravel\Support\Field;
|
|
7
8
|
use Nevela\Laravel\Support\Naming;
|
|
@@ -27,19 +28,64 @@ final class ResourceGenerator
|
|
|
27
28
|
{
|
|
28
29
|
$timestamp ??= date('Y_m_d_His');
|
|
29
30
|
$api = GeneratedFile::TARGET_API;
|
|
31
|
+
// An adopted table is already there, made by a migration of the app's own.
|
|
32
|
+
$migration = $d->adopted ? [] : [
|
|
33
|
+
new GeneratedFile($api, "database/migrations/{$timestamp}_create_{$d->table}_table.php", $this->migration($d, $all), GeneratedFile::MODE_ONCE, "database/migrations/*_create_{$d->table}_table.php"),
|
|
34
|
+
];
|
|
30
35
|
|
|
31
36
|
return [
|
|
32
37
|
new GeneratedFile($api, "app/Models/{$d->name}.php", $this->model($d, $all)),
|
|
33
|
-
|
|
38
|
+
...$migration,
|
|
34
39
|
new GeneratedFile($api, "app/Http/Requests/Nevela/{$d->name}Request.php", $this->request($d, $all)),
|
|
35
|
-
new GeneratedFile($api, "app/Http/Resources/Nevela/{$d->name}Resource.php", $this->resource($d)),
|
|
40
|
+
new GeneratedFile($api, "app/Http/Resources/Nevela/{$d->name}Resource.php", $this->resource($d, $all)),
|
|
36
41
|
new GeneratedFile($api, "app/Http/Controllers/Api/{$d->name}Controller.php", $this->controller($d, $all)),
|
|
37
42
|
new GeneratedFile($api, "app/Policies/{$d->name}Policy.php", $this->policy($d), GeneratedFile::MODE_ONCE),
|
|
38
43
|
new GeneratedFile(GeneratedFile::TARGET_WEB, 'resources/'.Naming::kebab($d->name).'.resource.ts', $this->typescript($d, $all)),
|
|
39
44
|
...$this->pages($d),
|
|
45
|
+
...array_values(array_map(fn (Action $action) => new GeneratedFile($api, "app/Nevela/Actions/{$d->name}/{$action->className()}.php", $this->action($d, $action), GeneratedFile::MODE_ONCE), $d->actions)),
|
|
40
46
|
];
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The class an action is written in: what happens when its button is pressed. Written
|
|
51
|
+
* once and then the app's own, like a policy. Until someone fills it in it says so.
|
|
52
|
+
*/
|
|
53
|
+
public function action(Descriptor $d, Action $action): string
|
|
54
|
+
{
|
|
55
|
+
$var = $d->variable();
|
|
56
|
+
$class = $action->className();
|
|
57
|
+
$path = "app/Nevela/Actions/{$d->name}/{$class}.php";
|
|
58
|
+
$asks = $action->fields === [] ? 'It asks for nothing, so $input is empty.' : 'What the person filled in, checked: '.implode(', ', array_keys($action->fields)).'.';
|
|
59
|
+
[$imports, $signature, $what] = $action->on === 'record'
|
|
60
|
+
? ["\nuse App\\Models\\{$d->name};\n", "{$d->name} \${$var}, array \$input", "on one {$this->lower($d->label)}"]
|
|
61
|
+
: ['', 'array $input', "on {$this->lower($d->pluralLabel)} as a whole"];
|
|
62
|
+
$permission = "{$d->table}.{$action->key()}";
|
|
63
|
+
|
|
64
|
+
return <<<PHP
|
|
65
|
+
<?php
|
|
66
|
+
|
|
67
|
+
namespace App\Nevela\Actions\\{$d->name};
|
|
68
|
+
{$imports}
|
|
69
|
+
/**
|
|
70
|
+
* {$action->label}, {$what}. Generated once — this file is yours.
|
|
71
|
+
*
|
|
72
|
+
* Nevela has done the rest by the time handle() runs: the route, the "{$permission}"
|
|
73
|
+
* permission, the check that the action applies, and the validation of what was asked
|
|
74
|
+
* for. It runs inside a database transaction, so throwing undoes what it changed, and
|
|
75
|
+
* the person is told it didn't work. What it returns is the message they see.
|
|
76
|
+
*/
|
|
77
|
+
class {$class}
|
|
78
|
+
{
|
|
79
|
+
/** @param array<string, mixed> \$input {$asks} */
|
|
80
|
+
public function handle({$signature}): ?string
|
|
81
|
+
{
|
|
82
|
+
throw new \LogicException('{$action->label} does nothing yet. Write it in {$path}.');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
PHP;
|
|
87
|
+
}
|
|
88
|
+
|
|
43
89
|
/**
|
|
44
90
|
* The resources that point at this one, each with the field that does it and the name
|
|
45
91
|
* the relation gets here: a Category's Products are `products`. A second field from
|
|
@@ -90,6 +136,23 @@ final class ResourceGenerator
|
|
|
90
136
|
return Naming::snake(Naming::plural($name));
|
|
91
137
|
}
|
|
92
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Whether a resource's ids are integers. A relation's column, its validation and what
|
|
141
|
+
* the API sends for it all follow the resource it points at, not the one it is on.
|
|
142
|
+
*
|
|
143
|
+
* @param list<Descriptor> $all
|
|
144
|
+
*/
|
|
145
|
+
private function hasIntIds(string $name, Descriptor $d, array $all): bool
|
|
146
|
+
{
|
|
147
|
+
foreach ([$d, ...$all] as $candidate) {
|
|
148
|
+
if ($candidate->name === $name) {
|
|
149
|
+
return $candidate->idType === 'int';
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
93
156
|
/**
|
|
94
157
|
* The dashboard pages for a resource, in Flare's layout: list, new, detail and edit,
|
|
95
158
|
* each with its loading skeleton. They only name the descriptor — Flare's components
|
|
@@ -226,7 +289,7 @@ final class ResourceGenerator
|
|
|
226
289
|
|
|
227
290
|
// What you type => the artisan command it runs.
|
|
228
291
|
// "update" is what upgrade was called before 0.3.0; it still works.
|
|
229
|
-
\$nevela = ['resource', 'generate', 'seed', 'user', 'upgrade', 'update', 'dev', 'status', 'version', 'trash'];
|
|
292
|
+
\$nevela = ['resource', 'adopt', 'generate', 'seed', 'user', 'upgrade', 'update', 'dev', 'status', 'version', 'trash'];
|
|
230
293
|
\$artisan = ['migrate', 'tinker', 'test', 'serve'];
|
|
231
294
|
if (in_array(\$name, ['--version', '-v', '-V'], true)) {
|
|
232
295
|
\$name = 'version';
|
|
@@ -252,6 +315,7 @@ final class ResourceGenerator
|
|
|
252
315
|
php nevela status check versions, migrations, users and the dashboard
|
|
253
316
|
php nevela resource Product --fields="name:string, price:money" --seed
|
|
254
317
|
add a resource, create its table, fill it
|
|
318
|
+
php nevela adopt posts make a resource of a table you already have
|
|
255
319
|
php nevela generate regenerate after editing a descriptor
|
|
256
320
|
php nevela seed Product fill a resource with records
|
|
257
321
|
php nevela user create someone who can sign in
|
|
@@ -303,6 +367,14 @@ final class ResourceGenerator
|
|
|
303
367
|
$body = $names === []
|
|
304
368
|
? 'export const resources = [] as const;'
|
|
305
369
|
: implode("\n", $imports)."\n\nexport { {$list} };\nexport const resources = [{$list}] as const;";
|
|
370
|
+
// What Flare's descriptor has no place for, by resource name: the actions a resource has.
|
|
371
|
+
$extras = [];
|
|
372
|
+
foreach ($all as $d) {
|
|
373
|
+
if ($d->actions !== []) {
|
|
374
|
+
$extras[$d->name]['actions'] = array_map(fn (Action $a) => ['permission' => "{$d->table}.{$a->key()}"] + $a->toArray(), $d->actions);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
$body .= "\n\n/** What a resource has beyond its fields. Read through lib/extras.ts. */\nexport const extras: Record<string, unknown> = ".($extras === [] ? '{}' : json_encode($extras, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)).';';
|
|
306
378
|
$m = self::M;
|
|
307
379
|
$e = self::E;
|
|
308
380
|
|
|
@@ -387,7 +459,9 @@ final class ResourceGenerator
|
|
|
387
459
|
$lines[] = "Route::get('{$d->slug}/_stats', [{$controller}, 'stats'])->name('{$d->slug}.stats');";
|
|
388
460
|
$lines[] = "Route::get('{$d->slug}/_insights', [{$controller}, 'insights'])->name('{$d->slug}.insights');";
|
|
389
461
|
$lines[] = "Route::post('{$d->slug}/_bulk', [{$controller}, 'bulk'])->name('{$d->slug}.bulk');";
|
|
390
|
-
|
|
462
|
+
// An integer id is digits and nothing else: anything else in its place is no record's address.
|
|
463
|
+
$where = $d->idType === 'int' ? "->where(['{$d->routeParameter()}' => '[0-9]{1,18}'])" : '';
|
|
464
|
+
$lines[] = "Route::apiResource('{$d->slug}', {$controller}){$where};";
|
|
391
465
|
}
|
|
392
466
|
$body = implode("\n", $lines);
|
|
393
467
|
$m = self::M;
|
|
@@ -407,6 +481,61 @@ final class ResourceGenerator
|
|
|
407
481
|
PHP);
|
|
408
482
|
}
|
|
409
483
|
|
|
484
|
+
/**
|
|
485
|
+
* The routes anyone can call: only for resources with a "public" section, and only
|
|
486
|
+
* what each one's section opens. Loaded by Nevela under {prefix}/public with the api
|
|
487
|
+
* middleware and no sign-in.
|
|
488
|
+
*
|
|
489
|
+
* @param list<Descriptor> $all
|
|
490
|
+
*/
|
|
491
|
+
public function publicRoutes(array $all): GeneratedFile
|
|
492
|
+
{
|
|
493
|
+
usort($all, fn (Descriptor $a, Descriptor $b) => strcmp($a->slug, $b->slug));
|
|
494
|
+
$controller = '\\Nevela\\Laravel\\Http\\PublicController::class';
|
|
495
|
+
$lines = [];
|
|
496
|
+
foreach ($all as $d) {
|
|
497
|
+
$public = $d->public;
|
|
498
|
+
if ($public === null) {
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
$more = implode('', array_map(fn (string $name) => ", '{$name}'", $public->middleware));
|
|
502
|
+
$for = "->defaults('resource', '{$d->name}')";
|
|
503
|
+
// The third part names the count. Without it Laravel keeps one per visitor for every
|
|
504
|
+
// throttled route there is, and reading a few pages would use up a form's allowance.
|
|
505
|
+
// (nevela.throttle is Laravel's throttle, counting the visitor and not the web app's server: see PublicThrottle.)
|
|
506
|
+
$throttle = fn (string $limit, string $what) => "'nevela.throttle:{$limit},{$d->slug}.{$what}:'";
|
|
507
|
+
if ($public->read !== null) {
|
|
508
|
+
$lines[] = "Route::get('{$d->slug}', [{$controller}, 'index']){$for}->middleware([{$throttle($public->readThrottle, 'read')}{$more}])->name('{$d->slug}.index');";
|
|
509
|
+
$lines[] = "Route::get('{$d->slug}/{id}', [{$controller}, 'show']){$for}->middleware([{$throttle($public->readThrottle, 'read')}{$more}])->name('{$d->slug}.show');";
|
|
510
|
+
}
|
|
511
|
+
if ($public->create !== null) {
|
|
512
|
+
$lines[] = "Route::post('{$d->slug}', [{$controller}, 'store']){$for}->middleware([{$throttle($public->createThrottle, 'create')}{$more}])->name('{$d->slug}.store');";
|
|
513
|
+
foreach ($public->create as $field) {
|
|
514
|
+
if ($d->fields[$field]->kind === 'file') {
|
|
515
|
+
$lines[] = "Route::put('_nevela/uploads/{$d->name}/{$field}', [{$controller}, 'upload']){$for}->defaults('field', '{$field}')->middleware([{$throttle($public->createThrottle, 'upload')}{$more}])->name('{$d->slug}.upload.{$field}');";
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
$body = $lines === [] ? '// No resource has a "public" section, so nothing is open.' : implode("\n", $lines);
|
|
521
|
+
$m = self::M;
|
|
522
|
+
$e = self::E;
|
|
523
|
+
|
|
524
|
+
return new GeneratedFile(GeneratedFile::TARGET_API, 'routes/nevela-public.php', <<<PHP
|
|
525
|
+
<?php
|
|
526
|
+
|
|
527
|
+
use Illuminate\Support\Facades\Route;
|
|
528
|
+
|
|
529
|
+
// What anyone can call without signing in: {prefix}/public/… (default: /api/public).
|
|
530
|
+
// Written from each resource's "public" section by `php artisan nevela:generate`.
|
|
531
|
+
// A resource without one has no route here, and so nothing of it is open.
|
|
532
|
+
{$m}
|
|
533
|
+
{$body}
|
|
534
|
+
{$e}
|
|
535
|
+
|
|
536
|
+
PHP);
|
|
537
|
+
}
|
|
538
|
+
|
|
410
539
|
/** @param list<Descriptor> $all */
|
|
411
540
|
public function model(Descriptor $d, array $all = []): string
|
|
412
541
|
{
|
|
@@ -448,6 +577,24 @@ final class ResourceGenerator
|
|
|
448
577
|
}
|
|
449
578
|
}
|
|
450
579
|
$castBlock = $casts ? implode("\n", $casts) : '';
|
|
580
|
+
// Written with the model, outside its generated block: which records are public is the app's to say.
|
|
581
|
+
$scope = $d->public?->read === null ? '' : <<<PHP
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Which {$this->lower($d->pluralLabel)} anyone may read, signed in or not. The public list and
|
|
586
|
+
* the public page of one both go through here. It lets nothing through until you
|
|
587
|
+
* say what may be: for instance
|
|
588
|
+
*
|
|
589
|
+
* \$query->whereNotNull('published_at')->where('published_at', '<=', now());
|
|
590
|
+
*/
|
|
591
|
+
public function scopePublicQuery(\Illuminate\Database\Eloquent\Builder \$query): void
|
|
592
|
+
{
|
|
593
|
+
\$query->whereRaw('1 = 0');
|
|
594
|
+
}
|
|
595
|
+
PHP;
|
|
596
|
+
// An integer id is Eloquent's own default: the database numbers the records.
|
|
597
|
+
[$import, $trait] = $d->idType === 'int' ? ['', ''] : ["use Illuminate\\Database\\Eloquent\\Concerns\\HasUuids;\n", " use HasUuids;\n\n"];
|
|
451
598
|
$m = self::M;
|
|
452
599
|
$e = self::E;
|
|
453
600
|
|
|
@@ -456,14 +603,11 @@ final class ResourceGenerator
|
|
|
456
603
|
|
|
457
604
|
namespace App\Models;
|
|
458
605
|
|
|
459
|
-
use Illuminate\Database\Eloquent\
|
|
460
|
-
use Illuminate\Database\Eloquent\Model;
|
|
606
|
+
{$import}use Illuminate\Database\Eloquent\Model;
|
|
461
607
|
|
|
462
608
|
class {$d->name} extends Model
|
|
463
609
|
{
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
protected \$table = '{$d->table}';
|
|
610
|
+
{$trait} protected \$table = '{$d->table}';
|
|
467
611
|
|
|
468
612
|
{$m}
|
|
469
613
|
// Deleted records go to the trash, where they can be restored for a while.
|
|
@@ -479,7 +623,7 @@ final class ResourceGenerator
|
|
|
479
623
|
}{$relationBlock}
|
|
480
624
|
{$e}
|
|
481
625
|
|
|
482
|
-
// Your own relationships, scopes and accessors go here — outside the generated block.
|
|
626
|
+
// Your own relationships, scopes and accessors go here — outside the generated block.{$scope}
|
|
483
627
|
}
|
|
484
628
|
|
|
485
629
|
PHP;
|
|
@@ -493,7 +637,8 @@ final class ResourceGenerator
|
|
|
493
637
|
$c = $f->column();
|
|
494
638
|
if ($f->kind === 'belongsTo') {
|
|
495
639
|
// A required parent can't be deleted while it has children; an optional one lets go of them.
|
|
496
|
-
$
|
|
640
|
+
$foreign = $this->hasIntIds((string) $f->target, $d, $all) ? 'foreignId' : 'foreignUuid';
|
|
641
|
+
$columns[] = " \$table->{$foreign}('{$c}')"
|
|
497
642
|
.($f->required ? '' : '->nullable()')
|
|
498
643
|
.($f->unique ? '->unique()' : '->index()')
|
|
499
644
|
."->constrained('{$this->tableOf((string) $f->target, $d, $all)}')"
|
|
@@ -503,6 +648,8 @@ final class ResourceGenerator
|
|
|
503
648
|
}
|
|
504
649
|
$line = match (true) {
|
|
505
650
|
$f->kind === 'file' => "\$table->string('{$c}', 512)",
|
|
651
|
+
// More than a text column holds on MySQL, or HTML, whose tags count against it too.
|
|
652
|
+
$f->kind === 'text' && ($f->isRich() || $f->maxLength > 65535) => "\$table->longText('{$c}')",
|
|
506
653
|
$f->kind === 'text' => "\$table->text('{$c}')",
|
|
507
654
|
$f->kind === 'int' => "\$table->integer('{$c}')",
|
|
508
655
|
$f->kind === 'float' && $f->format === 'money' => "\$table->decimal('{$c}', 12, 2)",
|
|
@@ -510,12 +657,17 @@ final class ResourceGenerator
|
|
|
510
657
|
$f->kind === 'boolean' => "\$table->boolean('{$c}')->default(false)",
|
|
511
658
|
$f->kind === 'date' => "\$table->date('{$c}')",
|
|
512
659
|
$f->kind === 'datetime' => "\$table->dateTime('{$c}')",
|
|
660
|
+
$f->maxLength !== null => "\$table->string('{$c}', {$f->maxLength})",
|
|
513
661
|
$f->format === 'url' => "\$table->string('{$c}', 2048)",
|
|
514
662
|
default => "\$table->string('{$c}')",
|
|
515
663
|
};
|
|
516
664
|
if (! $f->required) {
|
|
517
665
|
$line .= '->nullable()';
|
|
518
666
|
}
|
|
667
|
+
if ($f->default !== null && $f->kind !== 'text') {
|
|
668
|
+
// (A text column can't have one on MySQL; the request supplies it there.)
|
|
669
|
+
$line = str_replace('->default(false)', '', $line).'->default('.var_export($f->default, true).')';
|
|
670
|
+
}
|
|
519
671
|
if ($f->unique) {
|
|
520
672
|
$line .= '->unique()';
|
|
521
673
|
} elseif ($f->kind === 'enum') {
|
|
@@ -524,6 +676,7 @@ final class ResourceGenerator
|
|
|
524
676
|
$columns[] = " {$line};";
|
|
525
677
|
}
|
|
526
678
|
$cols = implode("\n", $columns);
|
|
679
|
+
$id = $d->idType === 'int' ? '$table->id();' : "\$table->uuid('id')->primary();";
|
|
527
680
|
|
|
528
681
|
return <<<PHP
|
|
529
682
|
<?php
|
|
@@ -538,7 +691,7 @@ final class ResourceGenerator
|
|
|
538
691
|
public function up(): void
|
|
539
692
|
{
|
|
540
693
|
Schema::create('{$d->table}', function (Blueprint \$table) {
|
|
541
|
-
|
|
694
|
+
{$id}
|
|
542
695
|
{$cols}
|
|
543
696
|
\$table->timestamps();
|
|
544
697
|
// When a record was deleted. Until it is removed for good it sits in the trash.
|
|
@@ -617,6 +770,27 @@ final class ResourceGenerator
|
|
|
617
770
|
}
|
|
618
771
|
$ruleLines = implode("\n", $rules);
|
|
619
772
|
$columnMap = implode(', ', $columns);
|
|
773
|
+
$defaults = implode(', ', array_map(fn (Field $f) => "'{$f->name}' => ".var_export($f->default, true), array_filter($d->fields, fn (Field $f) => $f->default !== null)));
|
|
774
|
+
$defaultsBlock = $defaults === '' ? '' : <<<PHP
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
/** What a field is when a create leaves it out. */
|
|
778
|
+
protected function defaults(): array
|
|
779
|
+
{
|
|
780
|
+
return [{$defaults}];
|
|
781
|
+
}
|
|
782
|
+
PHP;
|
|
783
|
+
// Only where there is one: a resource without rich text has the request it always had.
|
|
784
|
+
$rich = implode(', ', array_map(fn (Field $f) => "'{$f->name}'", array_filter($d->fields, fn (Field $f) => $f->isRich())));
|
|
785
|
+
$richBlock = $rich === '' ? '' : <<<PHP
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
/** Fields that hold HTML. It is cleaned before it is checked, so the length is that of what is kept. */
|
|
789
|
+
protected function rich(): array
|
|
790
|
+
{
|
|
791
|
+
return [{$rich}];
|
|
792
|
+
}
|
|
793
|
+
PHP;
|
|
620
794
|
$m = self::M;
|
|
621
795
|
$e = self::E;
|
|
622
796
|
|
|
@@ -658,7 +832,7 @@ final class ResourceGenerator
|
|
|
658
832
|
protected function columns(): array
|
|
659
833
|
{
|
|
660
834
|
return [{$columnMap}];
|
|
661
|
-
}
|
|
835
|
+
}{$richBlock}{$defaultsBlock}
|
|
662
836
|
{$e}
|
|
663
837
|
}
|
|
664
838
|
|
|
@@ -672,16 +846,23 @@ final class ResourceGenerator
|
|
|
672
846
|
private function rules(Descriptor $d, Field $f, array $all = []): array
|
|
673
847
|
{
|
|
674
848
|
$rules = [$f->required ? "'required'" : "'nullable'"];
|
|
849
|
+
// With a default, leaving it out is saying "the usual". Sent, it is checked like any other.
|
|
850
|
+
if ($f->default !== null) {
|
|
851
|
+
array_unshift($rules, "'sometimes'");
|
|
852
|
+
}
|
|
853
|
+
// A length of the field's own takes the place of the usual one.
|
|
854
|
+
$max = fn (int $usual) => "'max:".($f->maxLength ?? $usual)."'";
|
|
675
855
|
$type = match (true) {
|
|
676
|
-
|
|
856
|
+
// An integer id is up to 18 digits, which is as many as always fit in the column.
|
|
857
|
+
$f->kind === 'belongsTo' => [...($this->hasIntIds((string) $f->target, $d, $all) ? ["'integer'", "'digits_between:1,18'"] : ["'uuid'"]), "Rule::exists('{$this->tableOf((string) $f->target, $d, $all)}', 'id')"],
|
|
677
858
|
$f->kind === 'file' => ["'string'", "'max:512'", "new \\Nevela\\Laravel\\Rules\\UploadKey('{$d->name}', '{$f->name}')"],
|
|
678
|
-
$f->format === 'email' => ["'email'",
|
|
679
|
-
$f->format === 'url' => ["'url'",
|
|
680
|
-
$f->format === 'tel' => ["'string'",
|
|
681
|
-
$f->format === 'slug' => ["'string'", "'alpha_dash'",
|
|
859
|
+
$f->format === 'email' => ["'email'", $max(255)],
|
|
860
|
+
$f->format === 'url' => ["'url'", $max(2048)],
|
|
861
|
+
$f->format === 'tel' => ["'string'", $max(32)],
|
|
862
|
+
$f->format === 'slug' => ["'string'", "'alpha_dash'", $max(255)],
|
|
682
863
|
$f->format === 'color' => ["'string'", "'regex:/^#[0-9a-fA-F]{6}$/'"],
|
|
683
|
-
$f->kind === 'string' => ["'string'",
|
|
684
|
-
$f->kind === 'text' => ["'string'",
|
|
864
|
+
$f->kind === 'string' => ["'string'", $max(255)],
|
|
865
|
+
$f->kind === 'text' => ["'string'", $max(65535)],
|
|
685
866
|
$f->kind === 'int' && $f->format === 'percent' => ["'integer'", "'between:0,100'"],
|
|
686
867
|
$f->kind === 'int' => ["'integer'"],
|
|
687
868
|
$f->kind === 'float' && $f->format === 'percent' => ["'numeric'", "'between:0,100'"],
|
|
@@ -699,22 +880,31 @@ final class ResourceGenerator
|
|
|
699
880
|
return $rules;
|
|
700
881
|
}
|
|
701
882
|
|
|
702
|
-
|
|
883
|
+
/** @param list<Descriptor> $all */
|
|
884
|
+
public function resource(Descriptor $d, array $all = []): string
|
|
703
885
|
{
|
|
704
886
|
$lines = [];
|
|
705
887
|
foreach ($d->fields as $f) {
|
|
706
|
-
$value = match (
|
|
707
|
-
'date' => "\$this->{$f->column()}?->format('Y-m-d')",
|
|
708
|
-
'datetime' => "\$this->{$f->column()}?->toJSON()",
|
|
888
|
+
$value = match (true) {
|
|
889
|
+
$f->kind === 'date' => "\$this->{$f->column()}?->format('Y-m-d')",
|
|
890
|
+
$f->kind === 'datetime' => "\$this->{$f->column()}?->toJSON()",
|
|
891
|
+
// Cleaned again on the way out: HTML can reach a table without passing a request.
|
|
892
|
+
$f->isRich() => "\\Nevela\\Laravel\\Nevela::html(\$this->{$f->column()})",
|
|
893
|
+
// An id is a string in the API whatever it is in the database.
|
|
894
|
+
$f->kind === 'belongsTo' && $this->hasIntIds((string) $f->target, $d, $all) => "\$this->{$f->column()} === null ? null : (string) \$this->{$f->column()}",
|
|
709
895
|
default => "\$this->{$f->column()}",
|
|
710
896
|
};
|
|
711
897
|
$lines[] = " '{$f->name}' => {$value},";
|
|
712
898
|
if ($f->kind === 'file') {
|
|
713
899
|
// The key is what a record stores; this is what a page needs to show it.
|
|
714
|
-
$lines[] =
|
|
900
|
+
$lines[] = $f->private
|
|
901
|
+
// A private file is fetched through its record, so its details say which record.
|
|
902
|
+
? " '{$f->name}File' => \\Nevela\\Laravel\\Nevela::privateFile(\$this->{$f->column()}, '{$d->slug}', \$this->getKey(), '{$f->name}'),"
|
|
903
|
+
: " '{$f->name}File' => \\Nevela\\Laravel\\Nevela::file(\$this->{$f->column()}),";
|
|
715
904
|
}
|
|
716
905
|
}
|
|
717
906
|
$body = implode("\n", $lines);
|
|
907
|
+
$id = $d->idType === 'int' ? '(string) $this->id' : '$this->id';
|
|
718
908
|
$m = self::M;
|
|
719
909
|
$e = self::E;
|
|
720
910
|
|
|
@@ -734,7 +924,7 @@ final class ResourceGenerator
|
|
|
734
924
|
public function toArray(Request \$request): array
|
|
735
925
|
{
|
|
736
926
|
return [
|
|
737
|
-
'id' =>
|
|
927
|
+
'id' => {$id},
|
|
738
928
|
{$m}
|
|
739
929
|
{$body}
|
|
740
930
|
{$e}
|
|
@@ -953,12 +1143,22 @@ final class ResourceGenerator
|
|
|
953
1143
|
'unique' => $f->unique ? 'true' : null,
|
|
954
1144
|
'label' => $f->label ? $j($f->label) : null,
|
|
955
1145
|
'format' => in_array($f->format, ['money', 'percent', 'rating'], true) ? $j($f->format) : null,
|
|
1146
|
+
'maxLength' => $f->maxLength !== null ? (string) $f->maxLength : null,
|
|
1147
|
+
'default' => $f->default !== null ? $j($f->default) : null,
|
|
956
1148
|
// What the migration does when the parent goes: an optional link is cleared.
|
|
957
1149
|
'onDelete' => $f->kind === 'belongsTo' && ! $f->required ? '"set null"' : null,
|
|
958
|
-
// A picture is worth a column in the table; other files are not.
|
|
959
|
-
'list' => $f->isImage() ? 'true' : null,
|
|
1150
|
+
// A picture is worth a column in the table; other files are not, and a private one has no thumbnail to show.
|
|
1151
|
+
'list' => $f->isImage() && ! $f->private ? 'true' : null,
|
|
960
1152
|
]);
|
|
961
|
-
$
|
|
1153
|
+
$parts = array_map(fn ($k, $v) => "{$k}: {$v}", array_keys($options), $options);
|
|
1154
|
+
if ($f->private) {
|
|
1155
|
+
$parts[] = '...({ private: true } as {})';
|
|
1156
|
+
}
|
|
1157
|
+
if ($f->isRich()) {
|
|
1158
|
+
// Flare has no word for it, so it is said in passing: the dashboard reads `rich` off the field.
|
|
1159
|
+
$parts[] = '...({ rich: true } as {})';
|
|
1160
|
+
}
|
|
1161
|
+
$opts = $parts ? '{ '.implode(', ', $parts).' }' : '';
|
|
962
1162
|
$call = match (true) {
|
|
963
1163
|
$f->kind === 'belongsTo' => "field.belongsTo({$j($f->target)}".($opts ? ", {$opts}" : '').')',
|
|
964
1164
|
$f->kind === 'file' => "field.file({$j($f->accept)}".($opts ? ", {$opts}" : '').')',
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Http;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Database\Eloquent\Model;
|
|
6
|
+
use Illuminate\Http\JsonResponse;
|
|
7
|
+
use Illuminate\Http\Request;
|
|
8
|
+
use Illuminate\Support\Facades\DB;
|
|
9
|
+
use Illuminate\Support\Facades\Gate;
|
|
10
|
+
use Illuminate\Support\Facades\Validator;
|
|
11
|
+
use Illuminate\Validation\ValidationException;
|
|
12
|
+
use Nevela\Laravel\Nevela;
|
|
13
|
+
use Nevela\Laravel\Support\Action;
|
|
14
|
+
use Nevela\Laravel\Support\Descriptor;
|
|
15
|
+
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
16
|
+
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
|
17
|
+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
18
|
+
use Throwable;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A resource's own actions: what its descriptor's "actions" section names.
|
|
22
|
+
*
|
|
23
|
+
* POST {slug}/{id}/_actions/{action} { input } on one record
|
|
24
|
+
* POST {slug}/_actions/{action} { input } on the resource as a whole
|
|
25
|
+
* POST {slug}/_actions/{action} { ids, input } a record action, on a selection
|
|
26
|
+
*
|
|
27
|
+
* One controller for every resource, like the trash: every action is reached the same
|
|
28
|
+
* way and differs only in the class that does it, App\Nevela\Actions\<Resource>\<Action>.
|
|
29
|
+
* Here is everything around that class: who may, whether it applies, whether what was
|
|
30
|
+
* asked for is valid, and what the person is told when it fails.
|
|
31
|
+
*/
|
|
32
|
+
final class ActionController
|
|
33
|
+
{
|
|
34
|
+
/** The most records a selection can be: what the dashboard's table can select at once, with room. */
|
|
35
|
+
private const MOST = 500;
|
|
36
|
+
|
|
37
|
+
public function record(Request $request, string $slug, string $id, string $action): JsonResponse
|
|
38
|
+
{
|
|
39
|
+
[$descriptor, $definition] = $this->find($slug, $action, 'record');
|
|
40
|
+
$this->authorize($request, $descriptor, $definition);
|
|
41
|
+
$record = $this->one($descriptor, $id) ?? throw new NotFoundHttpException;
|
|
42
|
+
if (! $this->applies($descriptor, $definition, $record)) {
|
|
43
|
+
return response()->json(['error' => "{$definition->label} can't be done to this ".strtolower($descriptor->label).' as it is now.', 'code' => 'NOT_APPLICABLE'], 409);
|
|
44
|
+
}
|
|
45
|
+
$input = $this->input($request, $definition);
|
|
46
|
+
|
|
47
|
+
return $this->run($definition, function () use ($descriptor, $definition, $record, $input, $request) {
|
|
48
|
+
$message = $this->handler($descriptor, $definition)->handle($record, $input);
|
|
49
|
+
|
|
50
|
+
return ['message' => is_string($message) && $message !== '' ? $message : "{$definition->label}: done.", 'record' => $this->present($descriptor, $record, $request)];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public function collection(Request $request, string $slug, string $action): JsonResponse
|
|
55
|
+
{
|
|
56
|
+
[$descriptor, $definition] = $this->find($slug, $action, null);
|
|
57
|
+
$this->authorize($request, $descriptor, $definition);
|
|
58
|
+
$input = $this->input($request, $definition);
|
|
59
|
+
|
|
60
|
+
if ($definition->on === 'collection') {
|
|
61
|
+
return $this->run($definition, function () use ($descriptor, $definition, $input) {
|
|
62
|
+
$message = $this->handler($descriptor, $definition)->handle($input);
|
|
63
|
+
|
|
64
|
+
return ['message' => is_string($message) && $message !== '' ? $message : "{$definition->label}: done."];
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A record action, on several records at once.
|
|
69
|
+
if (! $definition->many) {
|
|
70
|
+
throw new NotFoundHttpException;
|
|
71
|
+
}
|
|
72
|
+
$ids = $request->input('ids');
|
|
73
|
+
if (! is_array($ids) || ! array_is_list($ids) || $ids === [] || count($ids) > self::MOST) {
|
|
74
|
+
return response()->json(['error' => 'Send the records as "ids": a list of 1 to '.self::MOST.'.'], 422);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return $this->run($definition, function () use ($descriptor, $definition, $input, $ids) {
|
|
78
|
+
$handler = $this->handler($descriptor, $definition);
|
|
79
|
+
$done = 0;
|
|
80
|
+
$skipped = 0;
|
|
81
|
+
foreach (array_unique(array_map('strval', array_filter($ids, 'is_scalar'))) as $id) {
|
|
82
|
+
$record = $this->one($descriptor, $id);
|
|
83
|
+
// One it no longer applies to, or that has gone, is passed over: the rest still want doing.
|
|
84
|
+
if ($record === null || ! $this->applies($descriptor, $definition, $record)) {
|
|
85
|
+
$skipped++;
|
|
86
|
+
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
$handler->handle($record, $input);
|
|
90
|
+
$done++;
|
|
91
|
+
}
|
|
92
|
+
$what = strtolower($done === 1 ? $descriptor->label : $descriptor->pluralLabel);
|
|
93
|
+
|
|
94
|
+
return [
|
|
95
|
+
'message' => "{$definition->label}: {$done} {$what}".($skipped > 0 ? ", and {$skipped} left as ".($skipped === 1 ? 'it was' : 'they were').'.' : '.'),
|
|
96
|
+
'done' => $done,
|
|
97
|
+
'skipped' => $skipped,
|
|
98
|
+
];
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The resource and the action an address names.
|
|
104
|
+
*
|
|
105
|
+
* @param 'record'|null $on Where it must be, or either
|
|
106
|
+
* @return array{Descriptor, Action}
|
|
107
|
+
*/
|
|
108
|
+
private function find(string $slug, string $action, ?string $on): array
|
|
109
|
+
{
|
|
110
|
+
foreach (Nevela::all() as $descriptor) {
|
|
111
|
+
$definition = $descriptor->slug === $slug ? ($descriptor->actions[$action] ?? null) : null;
|
|
112
|
+
if ($definition !== null && ($on === null || $definition->on === $on)) {
|
|
113
|
+
return [$descriptor, $definition];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
throw new NotFoundHttpException;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Its own permission, by name. Being able to edit the records is not being able to do this to them. */
|
|
120
|
+
private function authorize(Request $request, Descriptor $descriptor, Action $action): void
|
|
121
|
+
{
|
|
122
|
+
if (! $request->user()?->can("{$descriptor->table}.{$action->key()}")) {
|
|
123
|
+
throw new AccessDeniedHttpException("You don't have access to this.");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The record with this id, if there is one and the person may see it. */
|
|
128
|
+
private function one(Descriptor $descriptor, string $id): ?Model
|
|
129
|
+
{
|
|
130
|
+
$model = "App\\Models\\{$descriptor->name}";
|
|
131
|
+
if (! class_exists($model) || ($descriptor->idType === 'int' && ! preg_match('/^[0-9]{1,18}$/', $id))) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
$record = $model::query()->find($id);
|
|
135
|
+
|
|
136
|
+
return $record !== null && Gate::allows('view', $record) ? $record : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private function applies(Descriptor $descriptor, Action $action, Model $record): bool
|
|
140
|
+
{
|
|
141
|
+
$values = [];
|
|
142
|
+
foreach (array_keys($action->when) as $field) {
|
|
143
|
+
$values[$field] = $record->getAttribute($descriptor->fields[$field]->column());
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return $action->applies($values);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* What the action asked for, checked. A problem is reported against its field, the way
|
|
151
|
+
* a form's is, so the sheet can show it there.
|
|
152
|
+
*
|
|
153
|
+
* @return array<string, mixed>
|
|
154
|
+
*/
|
|
155
|
+
private function input(Request $request, Action $action): array
|
|
156
|
+
{
|
|
157
|
+
$sent = $request->input('input', []);
|
|
158
|
+
$sent = is_array($sent) ? $sent : [];
|
|
159
|
+
$validator = Validator::make($sent, $action->rules());
|
|
160
|
+
$validator->after(function ($validator) use ($sent, $action) {
|
|
161
|
+
foreach (array_keys($sent) as $key) {
|
|
162
|
+
if (! isset($action->fields[$key])) {
|
|
163
|
+
$validator->errors()->add((string) $key, 'Unknown field.');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
if ($validator->fails()) {
|
|
168
|
+
throw new ValidationException($validator);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return $validator->validated();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private function handler(Descriptor $descriptor, Action $action): object
|
|
175
|
+
{
|
|
176
|
+
$class = "App\\Nevela\\Actions\\{$descriptor->name}\\{$action->className()}";
|
|
177
|
+
if (! class_exists($class) || ! method_exists($class, 'handle')) {
|
|
178
|
+
throw new \LogicException("{$action->label} has no class to do it. Run: php artisan nevela:generate {$descriptor->name}");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return app($class);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Do it, all of it or none: what the class changed in the database is undone when it
|
|
186
|
+
* throws, and the person is told that it didn't work and that nothing was changed.
|
|
187
|
+
*
|
|
188
|
+
* @param callable(): array<string, mixed> $do
|
|
189
|
+
*/
|
|
190
|
+
private function run(Action $action, callable $do): JsonResponse
|
|
191
|
+
{
|
|
192
|
+
try {
|
|
193
|
+
return response()->json(DB::transaction($do));
|
|
194
|
+
} catch (HttpExceptionInterface|ValidationException $e) {
|
|
195
|
+
throw $e; // the class said why itself: abort(409, …), or a validation error
|
|
196
|
+
} catch (Throwable $e) {
|
|
197
|
+
report($e);
|
|
198
|
+
|
|
199
|
+
return response()->json(array_filter([
|
|
200
|
+
'error' => "{$action->label} didn't work, and nothing was changed.",
|
|
201
|
+
// Said to whoever is building the app, not to whoever is using it.
|
|
202
|
+
'detail' => config('app.debug') ? $e->getMessage() : null,
|
|
203
|
+
]), 500);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The record as the API shows it, after the action: what the page it was pressed on now needs.
|
|
209
|
+
*
|
|
210
|
+
* @return array<string, mixed>|null
|
|
211
|
+
*/
|
|
212
|
+
private function present(Descriptor $descriptor, Model $record, Request $request): ?array
|
|
213
|
+
{
|
|
214
|
+
$resource = "App\\Http\\Resources\\Nevela\\{$descriptor->name}Resource";
|
|
215
|
+
if (! $record->exists || ! class_exists($resource)) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
$fresh = $record->fresh();
|
|
219
|
+
|
|
220
|
+
return $fresh === null ? null : (new $resource($fresh))->resolve($request);
|
|
221
|
+
}
|
|
222
|
+
}
|