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.
- package/index.mjs +18 -1
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +10 -0
- package/template/laravel/src/Concerns/Trashable.php +63 -0
- package/template/laravel/src/Console/GenerateCommand.php +43 -0
- package/template/laravel/src/Console/TrashCommand.php +33 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +68 -1
- package/template/laravel/src/Http/ResourceRequest.php +33 -0
- package/template/laravel/src/Http/TrashController.php +190 -0
- package/template/laravel/src/Nevela.php +1 -1
- package/template/laravel/src/NevelaServiceProvider.php +19 -0
- package/template/laravel/src/Trash/Trash.php +151 -0
- package/template/laravel/src/Trash/TrashedScope.php +21 -0
- package/template/web/app/dashboard/layout.tsx +4 -2
- package/template/web/app/dashboard/trash/actions.ts +78 -0
- package/template/web/app/dashboard/trash/page.tsx +167 -0
- package/template/web/components/dashboard/dashboard-sidebar.tsx +5 -1
- package/template/web/components/dashboard/record-actions.tsx +14 -5
- package/template/web/components/dashboard/record-detail.tsx +3 -0
- package/template/web/components/dashboard/resource-table.tsx +6 -0
- package/template/web/components/dashboard/row-actions.tsx +12 -7
- package/template/web/components/dashboard/table-selection.tsx +27 -7
- package/template/web/components/dashboard/trash-actions.tsx +178 -0
- package/template/web/lib/trash-info.ts +13 -0
- package/template/web/lib/trash.ts +66 -0
- 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
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.
|
|
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');
|