create-nevela 0.5.1 → 0.5.3
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 +32 -2
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +16 -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 +84 -1
- package/template/laravel/src/Http/ResourceRequest.php +108 -0
- package/template/laravel/src/Http/TrashController.php +190 -0
- package/template/laravel/src/Nevela.php +162 -15
- package/template/laravel/src/NevelaServiceProvider.php +19 -0
- package/template/laravel/src/Support/Insights.php +78 -0
- package/template/laravel/src/Trash/Trash.php +151 -0
- package/template/laravel/src/Trash/TrashedScope.php +21 -0
- package/template/web/app/dashboard/actions.ts +79 -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/bulk-create-grid.tsx +326 -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-insights.tsx +219 -0
- package/template/web/components/dashboard/resource-table.tsx +10 -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.
|
|
@@ -361,6 +361,10 @@ async function inProject(name, args) {
|
|
|
361
361
|
}
|
|
362
362
|
if (name !== 'upgrade') process.exit(forward(project, name, args));
|
|
363
363
|
|
|
364
|
+
// The name first, before anything that waits on the network. A newer installer this
|
|
365
|
+
// one hands over to knows it has been shown, and prints one line in its place.
|
|
366
|
+
showTitle();
|
|
367
|
+
|
|
364
368
|
// Upgrading is the one command that must not run on stale code itself: it is the newest
|
|
365
369
|
// installer that knows what an older app needs repaired. So even a nevela command that
|
|
366
370
|
// hasn't been updated in a while upgrades an app correctly.
|
|
@@ -371,9 +375,22 @@ async function inProject(name, args) {
|
|
|
371
375
|
if (status !== null) process.exit(status);
|
|
372
376
|
}
|
|
373
377
|
}
|
|
374
|
-
|
|
378
|
+
const before = appVersion(project);
|
|
379
|
+
const looking = args.includes('--check') ? ' (checking only: nothing will be changed)' : '';
|
|
380
|
+
console.log(` ${indigo(`Nevela upgrade — this app: ${before ? `v${before}` : 'Nevela not installed in it yet'}`)}${dim(looking)}\n`);
|
|
375
381
|
const releases = await latestRelease();
|
|
376
382
|
const status = await upgrade(project, args, { latest: releases, say: (line) => console.log(` ${green('✔')} ${line}`) });
|
|
383
|
+
// Where it ended up, read from the app itself and not from what was asked for.
|
|
384
|
+
if (status === 0 && !args.includes('--check') && !args.includes('--undo')) {
|
|
385
|
+
const now = appVersion(project);
|
|
386
|
+
if (now) {
|
|
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
|
+
}
|
|
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`);
|
|
393
|
+
}
|
|
377
394
|
process.exit(status);
|
|
378
395
|
}
|
|
379
396
|
|
|
@@ -435,6 +452,19 @@ function otherCopies() {
|
|
|
435
452
|
for (const removal of removals) console.log(` ${bold(removal)}`);
|
|
436
453
|
}
|
|
437
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
|
+
|
|
438
468
|
/** The version of Nevela an app has installed, read from the package, or null. */
|
|
439
469
|
function appVersion(project) {
|
|
440
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,22 @@ 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
|
+
|
|
127
|
+
/*
|
|
128
|
+
| The most rows POST /{slug}/_bulk creates in one request (the dashboard's "Add several"
|
|
129
|
+
| grid). They are saved in one transaction, so this is also how long one can run.
|
|
130
|
+
*/
|
|
131
|
+
'bulk_max' => 500,
|
|
132
|
+
|
|
117
133
|
/*
|
|
118
134
|
| File and image fields (image:image, manual:file(pdf)).
|
|
119
135
|
|
|
|
@@ -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
|
|
|
@@ -382,6 +383,8 @@ final class ResourceGenerator
|
|
|
382
383
|
foreach ($all as $d) {
|
|
383
384
|
$controller = "\\App\\Http\\Controllers\\Api\\{$d->name}Controller::class";
|
|
384
385
|
$lines[] = "Route::get('{$d->slug}/_stats', [{$controller}, 'stats'])->name('{$d->slug}.stats');";
|
|
386
|
+
$lines[] = "Route::get('{$d->slug}/_insights', [{$controller}, 'insights'])->name('{$d->slug}.insights');";
|
|
387
|
+
$lines[] = "Route::post('{$d->slug}/_bulk', [{$controller}, 'bulk'])->name('{$d->slug}.bulk');";
|
|
385
388
|
$lines[] = "Route::apiResource('{$d->slug}', {$controller});";
|
|
386
389
|
}
|
|
387
390
|
$body = implode("\n", $lines);
|
|
@@ -461,6 +464,9 @@ final class ResourceGenerator
|
|
|
461
464
|
protected \$table = '{$d->table}';
|
|
462
465
|
|
|
463
466
|
{$m}
|
|
467
|
+
// Deleted records go to the trash, where they can be restored for a while.
|
|
468
|
+
use \Nevela\Laravel\Concerns\Trashable;
|
|
469
|
+
|
|
464
470
|
protected \$fillable = [{$fillable}];
|
|
465
471
|
|
|
466
472
|
protected function casts(): array
|
|
@@ -533,6 +539,8 @@ final class ResourceGenerator
|
|
|
533
539
|
\$table->uuid('id')->primary();
|
|
534
540
|
{$cols}
|
|
535
541
|
\$table->timestamps();
|
|
542
|
+
// When a record was deleted. Until it is removed for good it sits in the trash.
|
|
543
|
+
\$table->softDeletes();
|
|
536
544
|
});
|
|
537
545
|
}
|
|
538
546
|
|
|
@@ -545,6 +553,55 @@ final class ResourceGenerator
|
|
|
545
553
|
PHP;
|
|
546
554
|
}
|
|
547
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
|
+
|
|
548
605
|
/** @param list<Descriptor> $all */
|
|
549
606
|
public function request(Descriptor $d, array $all = []): string
|
|
550
607
|
{
|
|
@@ -749,6 +806,13 @@ final class ResourceGenerator
|
|
|
749
806
|
return Nevela::stats({$d->name}::query(), '{$d->name}', \$request);
|
|
750
807
|
}
|
|
751
808
|
|
|
809
|
+
public function insights(Request \$request): JsonResponse
|
|
810
|
+
{
|
|
811
|
+
Gate::authorize('viewAny', {$d->name}::class);
|
|
812
|
+
|
|
813
|
+
return Nevela::insights({$d->name}::query(), '{$d->name}', \$request);
|
|
814
|
+
}
|
|
815
|
+
|
|
752
816
|
public function store({$d->name}Request \$request): JsonResponse
|
|
753
817
|
{
|
|
754
818
|
\${$var} = {$d->name}::create(\$request->values());
|
|
@@ -759,6 +823,13 @@ final class ResourceGenerator
|
|
|
759
823
|
->header('Location', \$request->url().'/'.\${$var}->getKey());
|
|
760
824
|
}
|
|
761
825
|
|
|
826
|
+
public function bulk(Request \$request): JsonResponse
|
|
827
|
+
{
|
|
828
|
+
Gate::authorize('create', {$d->name}::class);
|
|
829
|
+
|
|
830
|
+
return Nevela::createMany({$d->name}::class, {$d->name}Request::class, {$d->name}Resource::class, \$request);
|
|
831
|
+
}
|
|
832
|
+
|
|
762
833
|
public function show({$d->name} \${$var}): {$d->name}Resource
|
|
763
834
|
{
|
|
764
835
|
Gate::authorize('view', \${$var});
|
|
@@ -836,6 +907,18 @@ final class ResourceGenerator
|
|
|
836
907
|
{
|
|
837
908
|
return \$user->can('{$d->table}.delete');
|
|
838
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
|
+
}
|
|
839
922
|
}
|
|
840
923
|
|
|
841
924
|
PHP;
|
|
@@ -3,6 +3,10 @@
|
|
|
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;
|
|
8
|
+
use Illuminate\Support\Facades\Validator as Validation;
|
|
9
|
+
use Illuminate\Validation\Rules\Unique;
|
|
6
10
|
use Illuminate\Validation\Validator;
|
|
7
11
|
|
|
8
12
|
/**
|
|
@@ -49,9 +53,113 @@ abstract class ResourceRequest extends FormRequest
|
|
|
49
53
|
$validator->errors()->add((string) $key, 'Unknown field.');
|
|
50
54
|
}
|
|
51
55
|
}
|
|
56
|
+
self::explainTrashed($validator, $validator->getData());
|
|
52
57
|
});
|
|
53
58
|
}
|
|
54
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
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Many rows, each checked by the rules for creating one.
|
|
91
|
+
*
|
|
92
|
+
* The same rules as a single create, on purpose: a second way into the table with its
|
|
93
|
+
* own idea of what is valid would let through a row the form then refuses to save.
|
|
94
|
+
* Rows are numbered from 1, as a person counts them.
|
|
95
|
+
*
|
|
96
|
+
* Two rows of one batch can't share a unique value either. The database would refuse
|
|
97
|
+
* the second, but only after the first was written and with no row number to show.
|
|
98
|
+
*
|
|
99
|
+
* @param list<mixed> $rows
|
|
100
|
+
* @return array{values: list<array<string, mixed>>, problems: list<array{row: int, issues: list<array{path: string, message: string}>}>}
|
|
101
|
+
*/
|
|
102
|
+
public function many(array $rows): array
|
|
103
|
+
{
|
|
104
|
+
$rules = $this->rules();
|
|
105
|
+
$columns = $this->columns();
|
|
106
|
+
$known = array_keys($columns);
|
|
107
|
+
$computed = array_map(fn (string $field) => $field.'File', $known);
|
|
108
|
+
$unique = array_keys(array_filter($rules, fn ($rule) => is_array($rule) && array_filter($rule, fn ($one) => $one instanceof Unique) !== []));
|
|
109
|
+
|
|
110
|
+
$values = [];
|
|
111
|
+
$problems = [];
|
|
112
|
+
$taken = [];
|
|
113
|
+
foreach (array_values($rows) as $index => $row) {
|
|
114
|
+
$number = $index + 1;
|
|
115
|
+
if (! is_array($row) || array_is_list($row)) {
|
|
116
|
+
// An empty JSON object arrives as an empty list; anything else here is not fields at all.
|
|
117
|
+
$problems[] = ['row' => $number, 'issues' => [['path' => '', 'message' => $row === [] ? 'This row is empty.' : 'This row is not a record.']]];
|
|
118
|
+
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
$issues = [];
|
|
122
|
+
foreach (array_keys($row) as $key) {
|
|
123
|
+
if (! in_array($key, $known, true) && ! in_array($key, self::READ_ONLY, true) && ! in_array($key, $computed, true)) {
|
|
124
|
+
$issues[] = ['path' => (string) $key, 'message' => 'Unknown field.'];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
$validator = Validation::make($row, $rules);
|
|
128
|
+
if ($validator->fails()) {
|
|
129
|
+
self::explainTrashed($validator, $row);
|
|
130
|
+
array_push($issues, ...FlareErrors::issues($validator->errors()->toArray()));
|
|
131
|
+
}
|
|
132
|
+
foreach ($unique as $field) {
|
|
133
|
+
$value = $row[$field] ?? null;
|
|
134
|
+
if (! is_scalar($value) || (string) $value === '') {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
$mark = $field."\0".$value;
|
|
138
|
+
if (isset($taken[$mark])) {
|
|
139
|
+
$issues[] = ['path' => $field, 'message' => "The same as row {$taken[$mark]}. Each one needs its own."];
|
|
140
|
+
} else {
|
|
141
|
+
$taken[$mark] = $number;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if ($issues !== []) {
|
|
145
|
+
$problems[] = ['row' => $number, 'issues' => $issues];
|
|
146
|
+
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
$validated = $validator->validated();
|
|
151
|
+
$mapped = [];
|
|
152
|
+
foreach ($columns as $field => $column) {
|
|
153
|
+
if (array_key_exists($field, $validated)) {
|
|
154
|
+
$mapped[$column] = $validated[$field];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
$values[] = $mapped;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return ['values' => $values, 'problems' => $problems];
|
|
161
|
+
}
|
|
162
|
+
|
|
55
163
|
/** Validated input keyed by column, ready for create()/update(). @return array<string, mixed> */
|
|
56
164
|
public function values(): array
|
|
57
165
|
{
|