create-nevela 0.7.0 → 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.
Files changed (53) hide show
  1. package/index.mjs +1 -0
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +25 -3
  5. package/template/laravel/src/Access/Permissions.php +36 -5
  6. package/template/laravel/src/Access/SampleUsers.php +1 -3
  7. package/template/laravel/src/Concerns/Trashable.php +23 -0
  8. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  9. package/template/laravel/src/Console/GenerateCommand.php +13 -3
  10. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  11. package/template/laravel/src/Console/SeedCommand.php +3 -1
  12. package/template/laravel/src/Console/TrashCommand.php +8 -0
  13. package/template/laravel/src/Console/UserCommand.php +5 -1
  14. package/template/laravel/src/Generator/ResourceGenerator.php +229 -29
  15. package/template/laravel/src/Http/Access/UsersController.php +2 -4
  16. package/template/laravel/src/Http/ActionController.php +222 -0
  17. package/template/laravel/src/Http/PublicController.php +210 -0
  18. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  19. package/template/laravel/src/Http/ResourceRequest.php +151 -2
  20. package/template/laravel/src/Http/TrashController.php +18 -2
  21. package/template/laravel/src/Http/UploadController.php +42 -1
  22. package/template/laravel/src/Media/Uploads.php +111 -0
  23. package/template/laravel/src/Nevela.php +54 -2
  24. package/template/laravel/src/NevelaServiceProvider.php +17 -1
  25. package/template/laravel/src/Support/Action.php +168 -0
  26. package/template/laravel/src/Support/Adoption.php +352 -0
  27. package/template/laravel/src/Support/Descriptor.php +59 -4
  28. package/template/laravel/src/Support/Fake.php +15 -1
  29. package/template/laravel/src/Support/Field.php +74 -3
  30. package/template/laravel/src/Support/Html.php +185 -0
  31. package/template/laravel/src/Support/PublicAccess.php +133 -0
  32. package/template/web/app/api/[...path]/route.ts +42 -9
  33. package/template/web/app/dashboard/actions.ts +34 -0
  34. package/template/web/app/globals.css +46 -0
  35. package/template/web/components/access/role-form.tsx +40 -5
  36. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  37. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  38. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  39. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  40. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  41. package/template/web/components/dashboard/record-actions.tsx +7 -2
  42. package/template/web/components/dashboard/record-detail.tsx +9 -2
  43. package/template/web/components/dashboard/resource-action.tsx +281 -0
  44. package/template/web/components/dashboard/resource-table.tsx +15 -2
  45. package/template/web/components/dashboard/row-actions.tsx +24 -0
  46. package/template/web/components/dashboard/table-selection.tsx +19 -1
  47. package/template/web/lib/access.ts +3 -1
  48. package/template/web/lib/dashboard.ts +10 -0
  49. package/template/web/lib/extras.ts +66 -0
  50. package/template/web/lib/files.ts +14 -0
  51. package/template/web/lib/laravel.ts +60 -0
  52. package/template/web/lib/rich.ts +79 -0
  53. 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
- 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"),
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
- $lines[] = "Route::apiResource('{$d->slug}', {$controller});";
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\Concerns\HasUuids;
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
- use HasUuids;
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
- $columns[] = " \$table->foreignUuid('{$c}')"
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
- \$table->uuid('id')->primary();
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
- $f->kind === 'belongsTo' => ["'uuid'", "Rule::exists('{$this->tableOf((string) $f->target, $d, $all)}', 'id')"],
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'", "'max:255'"],
679
- $f->format === 'url' => ["'url'", "'max:2048'"],
680
- $f->format === 'tel' => ["'string'", "'max:32'"],
681
- $f->format === 'slug' => ["'string'", "'alpha_dash'", "'max:255'"],
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'", "'max:255'"],
684
- $f->kind === 'text' => ["'string'", "'max:65535'"],
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
- public function resource(Descriptor $d): string
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 ($f->kind) {
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[] = " '{$f->name}File' => \\Nevela\\Laravel\\Nevela::file(\$this->{$f->column()}),";
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' => \$this->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
- $opts = $options ? '{ '.implode(', ', array_map(fn ($k, $v) => "{$k}: {$v}", array_keys($options), $options)).' }' : '';
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}" : '').')',
@@ -117,10 +117,8 @@ final class UsersController
117
117
  if (Schema::hasColumn($user->getTable(), 'active')) {
118
118
  $attributes['active'] = (bool) ($input['active'] ?? true);
119
119
  }
120
- if (Schema::hasColumn($user->getTable(), 'email_verified_at')) {
121
- // Someone allowed to create users vouches for the address.
122
- $attributes['email_verified_at'] = now();
123
- }
120
+ // The address is as typed by whoever is adding them. It becomes "verified" when its
121
+ // owner proves it: from their Account page, or by signing in with an emailed link or code.
124
122
  $user->forceFill($attributes)->save();
125
123
  Access::assign($user, $roles->pluck('id')->all());
126
124