create-nevela 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/index.mjs +18 -1
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +10 -0
  5. package/template/laravel/src/Concerns/Trashable.php +63 -0
  6. package/template/laravel/src/Console/GenerateCommand.php +43 -0
  7. package/template/laravel/src/Console/TrashCommand.php +33 -0
  8. package/template/laravel/src/Generator/ResourceGenerator.php +68 -1
  9. package/template/laravel/src/Http/ResourceRequest.php +33 -0
  10. package/template/laravel/src/Http/TrashController.php +190 -0
  11. package/template/laravel/src/Nevela.php +1 -1
  12. package/template/laravel/src/NevelaServiceProvider.php +19 -0
  13. package/template/laravel/src/Trash/Trash.php +151 -0
  14. package/template/laravel/src/Trash/TrashedScope.php +21 -0
  15. package/template/web/app/dashboard/layout.tsx +4 -2
  16. package/template/web/app/dashboard/trash/actions.ts +78 -0
  17. package/template/web/app/dashboard/trash/page.tsx +167 -0
  18. package/template/web/components/dashboard/dashboard-sidebar.tsx +5 -1
  19. package/template/web/components/dashboard/record-actions.tsx +14 -5
  20. package/template/web/components/dashboard/record-detail.tsx +3 -0
  21. package/template/web/components/dashboard/resource-table.tsx +6 -0
  22. package/template/web/components/dashboard/row-actions.tsx +12 -7
  23. package/template/web/components/dashboard/table-selection.tsx +27 -7
  24. package/template/web/components/dashboard/trash-actions.tsx +178 -0
  25. package/template/web/lib/trash-info.ts +13 -0
  26. package/template/web/lib/trash.ts +66 -0
  27. package/template/web/package.json +1 -1
package/index.mjs CHANGED
@@ -75,7 +75,7 @@ const HELP = ` Create a new Nevela app: a Laravel API and a Next.js dashboard.
75
75
  nevela dev Run the API and the dashboard
76
76
  nevela status Check versions, migrations, users and the dashboard
77
77
  nevela resource <Name> --fields="…" [--seed]
78
- nevela seed | user | generate | migrate | artisan <command>
78
+ nevela seed | user | generate | migrate | trash | artisan <command>
79
79
 
80
80
  ${bold('Options')}
81
81
  --pm <pnpm|npm|yarn|bun> Package manager for the dashboard. Default: the one you ran this with.
@@ -386,6 +386,10 @@ async function inProject(name, args) {
386
386
  if (now) {
387
387
  console.log(`\n ${green(`✔ ${now === before ? `This app is on Nevela v${now}, the version it was on.` : `This app is now on Nevela v${now}.`}`)}\n`);
388
388
  }
389
+ // A release is on Packagist a few minutes before every Composer can see it. Finishing
390
+ // on the older one without a word would look like there was nothing newer.
391
+ const behind = behindNotice(now, releases);
392
+ if (behind) console.log(`${behind}\n`);
389
393
  }
390
394
  process.exit(status);
391
395
  }
@@ -448,6 +452,19 @@ function otherCopies() {
448
452
  for (const removal of removals) console.log(` ${bold(removal)}`);
449
453
  }
450
454
 
455
+ /**
456
+ * What to say when an upgrade finished on a version older than the newest release, or null
457
+ * when it didn't. `now` is what the app has, `latest` what Packagist lists.
458
+ */
459
+ function behindNotice(now, latest) {
460
+ if (!now || !latest || !isNewer(latest, now)) return null;
461
+ return [
462
+ ` ${red('!')} Nevela ${bold(`v${latest}`)} is out, and Composer didn't bring it in.`,
463
+ ` A release takes a few minutes to reach Composer. Run ${bold('nevela upgrade')} again shortly.`,
464
+ ` ${dim(`Still on v${now} after that? This says what holds it back: cd apps/api && composer why-not nevela/laravel ${latest}`)}`,
465
+ ].join('\n');
466
+ }
467
+
451
468
  /** The version of Nevela an app has installed, read from the package, or null. */
452
469
  function appVersion(project) {
453
470
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Create a new Nevela app: a Laravel API and a Next.js dashboard from one resource description.",
5
5
  "type": "module",
6
6
  "bin": {
package/project.mjs CHANGED
@@ -11,7 +11,7 @@ import { isNewer, satisfiesCaret } from './packagist.mjs';
11
11
  import { spawnCommand } from './shell.mjs';
12
12
 
13
13
  /** What `nevela <name>` runs: these go to `php artisan nevela:<name>`. */
14
- const NEVELA = ['upgrade', 'dev', 'status', 'version', 'resource', 'generate', 'seed', 'user'];
14
+ const NEVELA = ['upgrade', 'dev', 'status', 'version', 'resource', 'generate', 'seed', 'user', 'trash'];
15
15
  /** These go to the artisan command of the same name. */
16
16
  const ARTISAN = ['migrate', 'tinker', 'test', 'serve'];
17
17
 
@@ -114,6 +114,16 @@ return [
114
114
  'per_page' => 25,
115
115
  'max_per_page' => 100,
116
116
 
117
+ /*
118
+ | The trash. A deleted record is kept, hidden from every list and count, and can be
119
+ | restored from the dashboard's Trash page for this many days. Then it is removed for
120
+ | good: each night by `nevela:trash`, which needs Laravel's scheduler running, and
121
+ | whenever someone opens the trash. Null keeps deleted records until someone removes them.
122
+ */
123
+ 'trash' => [
124
+ 'days' => 30,
125
+ ],
126
+
117
127
  /*
118
128
  | The most rows POST /{slug}/_bulk creates in one request (the dashboard's "Add several"
119
129
  | grid). They are saved in one transaction, so this is also how long one can run.
@@ -0,0 +1,63 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Concerns;
4
+
5
+ use Illuminate\Database\Eloquent\SoftDeletes;
6
+ use Illuminate\Support\Facades\Schema;
7
+ use Nevela\Laravel\Trash\TrashedScope;
8
+
9
+ /**
10
+ * A model whose deleted records go to the trash, where they can be restored for a while.
11
+ *
12
+ * This is Laravel's own soft delete, with one difference: it only takes effect once the
13
+ * table has its `deleted_at` column. An app that has upgraded and not yet migrated keeps
14
+ * working exactly as before, with delete meaning delete, where plain SoftDeletes would
15
+ * put `deleted_at is null` into every query on a table that has no such column.
16
+ *
17
+ * Every generated model uses it. Everything Laravel's soft delete offers is here:
18
+ * `$model->restore()`, `$model->forceDelete()`, `$model->trashed()`, and on a query
19
+ * `withTrashed()` and `onlyTrashed()`.
20
+ *
21
+ * @mixin \Illuminate\Database\Eloquent\Model
22
+ */
23
+ trait Trashable
24
+ {
25
+ use SoftDeletes;
26
+
27
+ /** Whether this model's table has somewhere to keep a deleted record. Only "yes" is remembered. */
28
+ private static bool $hasTrash = false;
29
+
30
+ public static function trashes(): bool
31
+ {
32
+ if (static::$hasTrash) {
33
+ return true;
34
+ }
35
+ $model = new static;
36
+
37
+ // The column can arrive later in the same process (a migration run from a command),
38
+ // so "no" is asked again and "yes" is kept.
39
+ return static::$hasTrash = Schema::connection($model->getConnectionName())->hasColumn($model->getTable(), $model->getDeletedAtColumn());
40
+ }
41
+
42
+ /** Ask the database again next time. For a test that adds or drops the column within one process. */
43
+ public static function forgetTrash(): void
44
+ {
45
+ static::$hasTrash = false;
46
+ }
47
+
48
+ /** In place of SoftDeletes' own: the same scope, applied only where there is a trash. */
49
+ public static function bootSoftDeletes(): void
50
+ {
51
+ static::addGlobalScope(new TrashedScope);
52
+ }
53
+
54
+ public static function bootTrashable(): void
55
+ {
56
+ // With no column to mark it in, a delete removes the row, as it always did.
57
+ static::deleting(function (self $model) {
58
+ if (! static::trashes()) {
59
+ $model->forceDeleting = true;
60
+ }
61
+ });
62
+ }
63
+ }
@@ -3,6 +3,7 @@
3
3
  namespace Nevela\Laravel\Console;
4
4
 
5
5
  use Illuminate\Console\Command;
6
+ use Illuminate\Support\Facades\Schema;
6
7
  use InvalidArgumentException;
7
8
  use Nevela\Laravel\Generator\ResourceGenerator;
8
9
  use Nevela\Laravel\Generator\Writer;
@@ -17,6 +18,41 @@ final class GenerateCommand extends Command
17
18
 
18
19
  protected $description = 'Regenerate Laravel code and Flare descriptors from nevela/resources/*.json';
19
20
 
21
+ /**
22
+ * Whether a resource's table needs the migration that gives it a trash.
23
+ *
24
+ * Not when its own migration already makes the column, and not when the column is
25
+ * there without one of ours having been written: then it came from a migration of
26
+ * the app's own, and ours would take that column away again when rolled back.
27
+ *
28
+ * @param list<string> $createMigrations The contents of the table's own migration(s); none for a resource that is new
29
+ * @param bool $written Whether an add_trash_to_… migration exists for the table already
30
+ * @param bool $columnExists Whether the table has deleted_at in the database now
31
+ */
32
+ public static function needsTrashMigration(array $createMigrations, bool $written, bool $columnExists): bool
33
+ {
34
+ if ($createMigrations === []) {
35
+ return false;
36
+ }
37
+ foreach ($createMigrations as $contents) {
38
+ if (preg_match('/softDeletes|deleted_at/', $contents) === 1) {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ return $written || ! $columnExists;
44
+ }
45
+
46
+ /** Whether the table has the column now. No, where there is no database to ask. */
47
+ private static function hasTrashColumn(string $table): bool
48
+ {
49
+ try {
50
+ return Schema::hasColumn($table, 'deleted_at');
51
+ } catch (\Throwable) {
52
+ return false;
53
+ }
54
+ }
55
+
20
56
  public function handle(): int
21
57
  {
22
58
  Nevela::forget();
@@ -87,6 +123,13 @@ final class GenerateCommand extends Command
87
123
  $files = [];
88
124
  foreach ($queue as $descriptor) {
89
125
  array_push($files, ...$generator->forResource($descriptor, null, $all));
126
+ // A table made before there was a trash gets a migration that gives it one. A new
127
+ // resource's own migration, written by the line above, already has the column.
128
+ $made = array_map(fn (string $file) => (string) file_get_contents($file), glob(database_path("migrations/*_create_{$descriptor->table}_table.php")) ?: []);
129
+ $written = (glob(database_path("migrations/*_add_trash_to_{$descriptor->table}_table.php")) ?: []) !== [];
130
+ if (self::needsTrashMigration($made, $written, self::hasTrashColumn($descriptor->table))) {
131
+ $files[] = $generator->trashMigrationFile($descriptor);
132
+ }
90
133
  }
91
134
  $files[] = $generator->routes($all);
92
135
  $files[] = $generator->registry($all);
@@ -0,0 +1,33 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Console;
4
+
5
+ use Illuminate\Console\Command;
6
+ use Nevela\Laravel\Trash\Trash;
7
+
8
+ final class TrashCommand extends Command
9
+ {
10
+ protected $signature = 'nevela:trash';
11
+
12
+ protected $description = 'Remove, for good, the deleted records whose time in the trash has run out';
13
+
14
+ public function handle(): int
15
+ {
16
+ $days = Trash::days();
17
+ if ($days === null) {
18
+ $this->components->info('Deleted records are kept until someone removes them (nevela.trash.days is off), so there is nothing to do.');
19
+
20
+ return self::SUCCESS;
21
+ }
22
+
23
+ $removed = Trash::purgeExpired();
24
+ foreach ($removed as $resource => $count) {
25
+ $this->components->twoColumnDetail($resource, number_format($count).' removed');
26
+ }
27
+ $this->components->info($removed === []
28
+ ? "Nothing in the trash is older than {$days} days."
29
+ : 'Removed '.number_format(array_sum($removed))." record(s) deleted more than {$days} days ago.");
30
+
31
+ return self::SUCCESS;
32
+ }
33
+ }
@@ -226,7 +226,7 @@ final class ResourceGenerator
226
226
 
227
227
  // What you type => the artisan command it runs.
228
228
  // "update" is what upgrade was called before 0.3.0; it still works.
229
- \$nevela = ['resource', 'generate', 'seed', 'user', 'upgrade', 'update', 'dev', 'status', 'version'];
229
+ \$nevela = ['resource', 'generate', 'seed', 'user', 'upgrade', 'update', 'dev', 'status', 'version', 'trash'];
230
230
  \$artisan = ['migrate', 'tinker', 'test', 'serve'];
231
231
  if (in_array(\$name, ['--version', '-v', '-V'], true)) {
232
232
  \$name = 'version';
@@ -255,6 +255,7 @@ final class ResourceGenerator
255
255
  php nevela generate regenerate after editing a descriptor
256
256
  php nevela seed Product fill a resource with records
257
257
  php nevela user create someone who can sign in
258
+ php nevela trash remove deleted records whose time in the trash is up
258
259
  php nevela upgrade bring this app to the latest Nevela
259
260
  php nevela version which Nevela this is
260
261
 
@@ -463,6 +464,9 @@ final class ResourceGenerator
463
464
  protected \$table = '{$d->table}';
464
465
 
465
466
  {$m}
467
+ // Deleted records go to the trash, where they can be restored for a while.
468
+ use \Nevela\Laravel\Concerns\Trashable;
469
+
466
470
  protected \$fillable = [{$fillable}];
467
471
 
468
472
  protected function casts(): array
@@ -535,6 +539,8 @@ final class ResourceGenerator
535
539
  \$table->uuid('id')->primary();
536
540
  {$cols}
537
541
  \$table->timestamps();
542
+ // When a record was deleted. Until it is removed for good it sits in the trash.
543
+ \$table->softDeletes();
538
544
  });
539
545
  }
540
546
 
@@ -547,6 +553,55 @@ final class ResourceGenerator
547
553
  PHP;
548
554
  }
549
555
 
556
+ /**
557
+ * For a table made before there was a trash: the column that gives it one.
558
+ *
559
+ * Written once, by `nevela:generate`, for a resource whose own migration doesn't
560
+ * have the column. Until it is run, deleting a record removes it as before.
561
+ */
562
+ public function trashMigration(Descriptor $d): string
563
+ {
564
+ return <<<PHP
565
+ <?php
566
+
567
+ use Illuminate\Database\Migrations\Migration;
568
+ use Illuminate\Database\Schema\Blueprint;
569
+ use Illuminate\Support\Facades\Schema;
570
+
571
+ // Gives {$d->pluralLabel} a trash: a deleted record is kept, hidden, and can be restored
572
+ // for a while (config/nevela.php, trash.days). Generated once by Nevela.
573
+ return new class extends Migration
574
+ {
575
+ public function up(): void
576
+ {
577
+ if (Schema::hasTable('{$d->table}') && ! Schema::hasColumn('{$d->table}', 'deleted_at')) {
578
+ Schema::table('{$d->table}', function (Blueprint \$table) {
579
+ \$table->softDeletes();
580
+ });
581
+ }
582
+ }
583
+
584
+ public function down(): void
585
+ {
586
+ if (Schema::hasColumn('{$d->table}', 'deleted_at')) {
587
+ Schema::table('{$d->table}', function (Blueprint \$table) {
588
+ \$table->dropSoftDeletes();
589
+ });
590
+ }
591
+ }
592
+ };
593
+
594
+ PHP;
595
+ }
596
+
597
+ /** The file `trashMigration()` goes in. Not made again once one exists for the table, whatever its date. */
598
+ public function trashMigrationFile(Descriptor $d, ?string $timestamp = null): GeneratedFile
599
+ {
600
+ $timestamp ??= date('Y_m_d_His');
601
+
602
+ return new GeneratedFile(GeneratedFile::TARGET_API, "database/migrations/{$timestamp}_add_trash_to_{$d->table}_table.php", $this->trashMigration($d), GeneratedFile::MODE_ONCE, "database/migrations/*_add_trash_to_{$d->table}_table.php");
603
+ }
604
+
550
605
  /** @param list<Descriptor> $all */
551
606
  public function request(Descriptor $d, array $all = []): string
552
607
  {
@@ -852,6 +907,18 @@ final class ResourceGenerator
852
907
  {
853
908
  return \$user->can('{$d->table}.delete');
854
909
  }
910
+
911
+ /** Bring a deleted one back from the trash. */
912
+ public function restore(User \$user, {$d->name} \${$var}): bool
913
+ {
914
+ return \$user->can('{$d->table}.delete');
915
+ }
916
+
917
+ /** Remove a deleted one from the trash, for good. */
918
+ public function forceDelete(User \$user, {$d->name} \${$var}): bool
919
+ {
920
+ return \$user->can('{$d->table}.delete');
921
+ }
855
922
  }
856
923
 
857
924
  PHP;
@@ -3,6 +3,8 @@
3
3
  namespace Nevela\Laravel\Http;
4
4
 
5
5
  use Illuminate\Foundation\Http\FormRequest;
6
+ use Illuminate\Support\Facades\DB;
7
+ use Illuminate\Support\Facades\Schema;
6
8
  use Illuminate\Support\Facades\Validator as Validation;
7
9
  use Illuminate\Validation\Rules\Unique;
8
10
  use Illuminate\Validation\Validator;
@@ -51,9 +53,39 @@ abstract class ResourceRequest extends FormRequest
51
53
  $validator->errors()->add((string) $key, 'Unknown field.');
52
54
  }
53
55
  }
56
+ self::explainTrashed($validator, $validator->getData());
54
57
  });
55
58
  }
56
59
 
60
+ /**
61
+ * A unique value can be held by a record nobody can see: one in the trash. "Already
62
+ * taken" would send someone looking through a list it isn't in, so say where it is.
63
+ *
64
+ * @param array<string, mixed> $data
65
+ * @return list<array{path: string, message: string}> The messages that were replaced
66
+ */
67
+ private static function explainTrashed(Validator $validator, array $data): array
68
+ {
69
+ $replaced = [];
70
+ foreach ($validator->failed() as $field => $rules) {
71
+ // What Laravel gives for a failed unique rule: the table and the column it looked in.
72
+ [$table, $column] = array_pad($rules['Unique'] ?? [], 2, null);
73
+ $value = $data[$field] ?? null;
74
+ if (! is_string($table) || ! is_string($column) || ! is_scalar($value) || $column === 'NULL') {
75
+ continue;
76
+ }
77
+ if (! Schema::hasColumn($table, 'deleted_at') || ! DB::table($table)->where($column, $value)->whereNotNull('deleted_at')->exists()) {
78
+ continue;
79
+ }
80
+ $message = 'This belongs to a record in the trash. Restore that record, or delete it for good to use the value again.';
81
+ $validator->errors()->forget($field);
82
+ $validator->errors()->add($field, $message);
83
+ $replaced[] = ['path' => (string) $field, 'message' => $message];
84
+ }
85
+
86
+ return $replaced;
87
+ }
88
+
57
89
  /**
58
90
  * Many rows, each checked by the rules for creating one.
59
91
  *
@@ -94,6 +126,7 @@ abstract class ResourceRequest extends FormRequest
94
126
  }
95
127
  $validator = Validation::make($row, $rules);
96
128
  if ($validator->fails()) {
129
+ self::explainTrashed($validator, $row);
97
130
  array_push($issues, ...FlareErrors::issues($validator->errors()->toArray()));
98
131
  }
99
132
  foreach ($unique as $field) {
@@ -0,0 +1,190 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http;
4
+
5
+ use Illuminate\Http\JsonResponse;
6
+ use Illuminate\Http\Request;
7
+ use Illuminate\Http\Response;
8
+ use Nevela\Laravel\Support\Descriptor;
9
+ use Nevela\Laravel\Trash\Trash;
10
+ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
11
+ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
12
+
13
+ /**
14
+ * The trash: deleted records, by resource, to restore or to remove for good.
15
+ *
16
+ * One controller for every resource, where everything else about a resource is generated.
17
+ * The trash asks the same three questions of all of them (what was deleted, bring it back,
18
+ * get rid of it), and a generated copy per resource would be the same file many times.
19
+ *
20
+ * Who may use it is the resource's own policy: `restore` and `forceDelete` where it has
21
+ * them, and what it says to `delete` where it was written before there was a trash.
22
+ */
23
+ final class TrashController
24
+ {
25
+ /**
26
+ * GET _nevela/trash/_status: which resources have a trash this person may use, and how
27
+ * long it keeps things. Nothing is counted and nothing is removed: this is asked on
28
+ * the way to drawing a delete button, to say truthfully what pressing it will do.
29
+ */
30
+ public function status(): JsonResponse
31
+ {
32
+ $slugs = [];
33
+ foreach (Trash::resources() as ['descriptor' => $descriptor, 'model' => $model]) {
34
+ if (Trash::maySee($model)) {
35
+ $slugs[] = $descriptor->slug;
36
+ }
37
+ }
38
+
39
+ return response()->json(['days' => Trash::days(), 'resources' => $slugs]);
40
+ }
41
+
42
+ /**
43
+ * GET _nevela/trash: how long things are kept, and how much of each resource is in it.
44
+ * Only the resources this person may deal with. Anything past its time goes first.
45
+ */
46
+ public function index(): JsonResponse
47
+ {
48
+ Trash::purgeExpired();
49
+
50
+ $resources = [];
51
+ foreach (Trash::resources() as ['descriptor' => $descriptor, 'model' => $model]) {
52
+ if (! Trash::maySee($model)) {
53
+ continue;
54
+ }
55
+ $resources[] = [
56
+ 'name' => $descriptor->name,
57
+ 'label' => $descriptor->label,
58
+ 'pluralLabel' => $descriptor->pluralLabel,
59
+ 'slug' => $descriptor->slug,
60
+ 'icon' => $descriptor->icon,
61
+ 'count' => $model::onlyTrashed()->count(),
62
+ ];
63
+ }
64
+
65
+ return response()->json(['days' => Trash::days(), 'resources' => $resources]);
66
+ }
67
+
68
+ /** GET _nevela/trash/{slug}?page=&perPage=: one resource's deleted records, the latest deletion first. */
69
+ public function show(Request $request, string $slug): JsonResponse
70
+ {
71
+ ['descriptor' => $descriptor, 'model' => $model] = $this->resource($slug);
72
+ $input = $request->validate(['page' => ['nullable', 'integer', 'min:1'], 'perPage' => ['nullable', 'integer', 'min:1', 'max:100']]);
73
+ Trash::purgeExpired($model);
74
+
75
+ $instance = new $model;
76
+ $page = $model::onlyTrashed()
77
+ ->orderByDesc($instance->getQualifiedDeletedAtColumn())->orderBy($instance->getQualifiedKeyName())
78
+ ->paginate((int) ($input['perPage'] ?? 25), ['*'], 'page', (int) ($input['page'] ?? 1));
79
+
80
+ return response()->json([
81
+ 'data' => $page->getCollection()->map(fn ($record) => [
82
+ 'id' => (string) $record->getKey(),
83
+ 'label' => self::label($descriptor, $record),
84
+ 'deletedAt' => $record->{$record->getDeletedAtColumn()}?->toJSON(),
85
+ 'expiresAt' => Trash::expires($record->{$record->getDeletedAtColumn()})?->toJSON(),
86
+ ])->all(),
87
+ 'meta' => ['page' => $page->currentPage(), 'perPage' => $page->perPage(), 'total' => $page->total(), 'totalPages' => max(1, $page->lastPage())],
88
+ ]);
89
+ }
90
+
91
+ /** POST _nevela/trash/{slug}/{id}/restore: back into the list, as it was. */
92
+ public function restore(string $slug, string $id): JsonResponse
93
+ {
94
+ ['descriptor' => $descriptor, 'model' => $model] = $this->resource($slug);
95
+ $record = $model::onlyTrashed()->find($id) ?? throw new NotFoundHttpException("No deleted {$this->noun($descriptor)} with that id.");
96
+ if (! Trash::may('restore', $record)) {
97
+ throw new AccessDeniedHttpException("You don't have access to this.");
98
+ }
99
+
100
+ // A record that must belong to another can't come back while that other is itself deleted.
101
+ foreach ($descriptor->fields as $field) {
102
+ if ($field->kind !== 'belongsTo' || ! $field->required) {
103
+ continue;
104
+ }
105
+ $parent = "App\\Models\\{$field->target}";
106
+ $key = $record->{$field->column()};
107
+ if ($key !== null && class_exists($parent) && method_exists($parent, 'trashes') && $parent::trashes() && $parent::onlyTrashed()->whereKey($key)->exists()) {
108
+ $what = strtolower($field->target);
109
+
110
+ return response()->json(['error' => "Its {$what} is in the trash too. Restore the {$what} first.", 'code' => 'PARENT_IN_TRASH'], 409);
111
+ }
112
+ }
113
+ $record->restore();
114
+
115
+ return response()->json(['id' => (string) $record->getKey(), 'restored' => true]);
116
+ }
117
+
118
+ /** DELETE _nevela/trash/{slug}/{id}: gone for good. */
119
+ public function destroy(string $slug, string $id): JsonResponse|Response
120
+ {
121
+ ['descriptor' => $descriptor, 'model' => $model] = $this->resource($slug);
122
+ $record = $model::onlyTrashed()->find($id) ?? throw new NotFoundHttpException("No deleted {$this->noun($descriptor)} with that id.");
123
+ if (! Trash::may('forceDelete', $record)) {
124
+ throw new AccessDeniedHttpException("You don't have access to this.");
125
+ }
126
+ if (! Trash::purge($record)) {
127
+ return response()->json(['error' => 'Other records in the trash still belong to this one. Delete those for good first.', 'code' => 'STILL_REFERENCED'], 409);
128
+ }
129
+
130
+ return response()->noContent();
131
+ }
132
+
133
+ /**
134
+ * DELETE _nevela/trash/{slug}?confirm={slug}: everything of one resource, for good.
135
+ * The slug has to be said again, so that this can't be the result of a mistyped address.
136
+ */
137
+ public function empty(Request $request, string $slug): JsonResponse
138
+ {
139
+ ['model' => $model] = $this->resource($slug);
140
+ if ($request->query('confirm') !== $slug) {
141
+ return response()->json(['error' => "To empty this trash, send confirm={$slug}.", 'code' => 'CONFIRM'], 422);
142
+ }
143
+ $removed = 0;
144
+ $kept = 0;
145
+ $instance = new $model;
146
+ $model::onlyTrashed()->orderBy($instance->getQualifiedKeyName())->chunkById(200, function ($records) use (&$removed, &$kept) {
147
+ foreach ($records as $record) {
148
+ if (Trash::may('forceDelete', $record) && Trash::purge($record)) {
149
+ $removed++;
150
+ } else {
151
+ $kept++;
152
+ }
153
+ }
154
+ });
155
+
156
+ return response()->json(['removed' => $removed, 'kept' => $kept]);
157
+ }
158
+
159
+ /** @return array{descriptor: Descriptor, model: class-string<\Illuminate\Database\Eloquent\Model>} */
160
+ private function resource(string $slug): array
161
+ {
162
+ $resource = Trash::resource($slug) ?? throw new NotFoundHttpException('This resource has no trash.');
163
+ if (! Trash::maySee($resource['model'])) {
164
+ throw new AccessDeniedHttpException("You don't have access to this.");
165
+ }
166
+
167
+ return $resource;
168
+ }
169
+
170
+ private function noun(Descriptor $descriptor): string
171
+ {
172
+ return strtolower($descriptor->label);
173
+ }
174
+
175
+ /** What to call a deleted record: its title, the way a list would, and its id when it has none. */
176
+ private static function label(Descriptor $descriptor, mixed $record): string
177
+ {
178
+ // The field the descriptor names, or the first piece of text it has, as the dashboard does.
179
+ $field = $descriptor->titleField !== null ? ($descriptor->fields[$descriptor->titleField] ?? null) : null;
180
+ foreach ($field === null ? $descriptor->fields : [] as $candidate) {
181
+ if ($candidate->kind === 'string') {
182
+ $field = $candidate;
183
+ break;
184
+ }
185
+ }
186
+ $value = $field ? $record->getAttribute($field->column()) : null;
187
+
188
+ return is_scalar($value) && (string) $value !== '' ? (string) $value : (string) $record->getKey();
189
+ }
190
+ }
@@ -21,7 +21,7 @@ use Nevela\Laravel\Support\Naming;
21
21
  final class Nevela
22
22
  {
23
23
  /** Written by `pnpm release`; the same number as the repository's git tag. */
24
- public const VERSION = '0.5.2';
24
+ public const VERSION = '0.6.0';
25
25
 
26
26
  /** @var array<string, Descriptor> */
27
27
  private static array $descriptors = [];
@@ -4,6 +4,7 @@ namespace Nevela\Laravel;
4
4
 
5
5
  use Illuminate\Contracts\Debug\ExceptionHandler;
6
6
  use Illuminate\Cache\RateLimiting\Limit;
7
+ use Illuminate\Console\Scheduling\Schedule;
7
8
  use Illuminate\Foundation\Console\ServeCommand;
8
9
  use Illuminate\Http\Request;
9
10
  use Illuminate\Support\Facades\Gate;
@@ -18,6 +19,7 @@ use Nevela\Laravel\Console\MakeResourceCommand;
18
19
  use Nevela\Laravel\Console\SeedCommand;
19
20
  use Nevela\Laravel\Console\SetupCommand;
20
21
  use Nevela\Laravel\Console\StatusCommand;
22
+ use Nevela\Laravel\Console\TrashCommand;
21
23
  use Nevela\Laravel\Console\UpdateCommand;
22
24
  use Nevela\Laravel\Console\UserCommand;
23
25
  use Nevela\Laravel\Console\VersionCommand;
@@ -29,6 +31,7 @@ use Nevela\Laravel\Http\Auth\AccountController;
29
31
  use Nevela\Laravel\Http\Auth\SecurityController;
30
32
  use Nevela\Laravel\Http\Auth\SignInController;
31
33
  use Nevela\Laravel\Http\TokenController;
34
+ use Nevela\Laravel\Http\TrashController;
32
35
  use Nevela\Laravel\Http\UploadController;
33
36
  use Nevela\Laravel\Media\Uploads;
34
37
 
@@ -56,7 +59,15 @@ final class NevelaServiceProvider extends ServiceProvider
56
59
  $this->commands([
57
60
  MakeResourceCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
58
61
  UpdateCommand::class, DevCommand::class, StatusCommand::class, VersionCommand::class, SetupCommand::class,
62
+ TrashCommand::class,
59
63
  ]);
64
+
65
+ // What has been in the trash too long is removed each night, wherever the app's
66
+ // scheduler is running (Laravel's one cron line). Where it isn't, the same thing
67
+ // happens whenever someone opens the trash.
68
+ $this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
69
+ $schedule->command(TrashCommand::class)->dailyAt('03:40')->withoutOverlapping();
70
+ });
60
71
  }
61
72
 
62
73
  $this->callAfterResolving(ExceptionHandler::class, function ($handler) {
@@ -171,6 +182,14 @@ final class NevelaServiceProvider extends ServiceProvider
171
182
  'data' => array_map(fn ($name) => (array) Uploads::profile($name), array_keys((array) config('nevela.uploads.profiles', ['default' => []]))),
172
183
  ]))->name('profiles');
173
184
 
185
+ // The trash: deleted records, to restore or to remove for good.
186
+ Route::get('_nevela/trash', [TrashController::class, 'index'])->name('trash.index');
187
+ Route::get('_nevela/trash/_status', [TrashController::class, 'status'])->name('trash.status');
188
+ Route::get('_nevela/trash/{slug}', [TrashController::class, 'show'])->name('trash.show');
189
+ Route::post('_nevela/trash/{slug}/{id}/restore', [TrashController::class, 'restore'])->name('trash.restore');
190
+ Route::delete('_nevela/trash/{slug}/{id}', [TrashController::class, 'destroy'])->name('trash.destroy');
191
+ Route::delete('_nevela/trash/{slug}', [TrashController::class, 'empty'])->name('trash.empty');
192
+
174
193
  // Users and roles, for whoever holds the permissions to manage them.
175
194
  Route::middleware(RolesAreSetUp::class)->group(function () {
176
195
  Route::get('_nevela/users', [UsersController::class, 'index'])->name('users.index');