create-nevela 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.mjs +1 -0
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +25 -3
- package/template/laravel/src/Access/Permissions.php +36 -5
- package/template/laravel/src/Concerns/Trashable.php +23 -0
- package/template/laravel/src/Console/AdoptCommand.php +139 -0
- package/template/laravel/src/Console/GenerateCommand.php +13 -3
- package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
- package/template/laravel/src/Console/SeedCommand.php +3 -1
- package/template/laravel/src/Console/TrashCommand.php +8 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +229 -29
- package/template/laravel/src/Http/ActionController.php +222 -0
- package/template/laravel/src/Http/PublicController.php +210 -0
- package/template/laravel/src/Http/PublicThrottle.php +48 -0
- package/template/laravel/src/Http/ResourceRequest.php +151 -2
- package/template/laravel/src/Http/TrashController.php +18 -2
- package/template/laravel/src/Http/UploadController.php +42 -1
- package/template/laravel/src/Media/Uploads.php +111 -0
- package/template/laravel/src/Nevela.php +54 -2
- package/template/laravel/src/NevelaServiceProvider.php +17 -1
- package/template/laravel/src/Support/Action.php +168 -0
- package/template/laravel/src/Support/Adoption.php +352 -0
- package/template/laravel/src/Support/Descriptor.php +59 -4
- package/template/laravel/src/Support/Fake.php +15 -1
- package/template/laravel/src/Support/Field.php +74 -3
- package/template/laravel/src/Support/Html.php +185 -0
- package/template/laravel/src/Support/PublicAccess.php +133 -0
- package/template/web/app/api/[...path]/route.ts +42 -9
- package/template/web/app/dashboard/actions.ts +34 -0
- package/template/web/app/globals.css +46 -0
- package/template/web/components/access/role-form.tsx +40 -5
- package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
- package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
- package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
- package/template/web/components/dashboard/fields/file-field.tsx +9 -5
- package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
- package/template/web/components/dashboard/record-actions.tsx +7 -2
- package/template/web/components/dashboard/record-detail.tsx +9 -2
- package/template/web/components/dashboard/resource-action.tsx +281 -0
- package/template/web/components/dashboard/resource-table.tsx +15 -2
- package/template/web/components/dashboard/row-actions.tsx +24 -0
- package/template/web/components/dashboard/table-selection.tsx +19 -1
- package/template/web/lib/access.ts +3 -1
- package/template/web/lib/dashboard.ts +10 -0
- package/template/web/lib/extras.ts +66 -0
- package/template/web/lib/files.ts +14 -0
- package/template/web/lib/laravel.ts +60 -0
- package/template/web/lib/rich.ts +79 -0
- package/template/web/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -75,6 +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 adopt <table> Make a resource of a table you already have
|
|
78
79
|
nevela seed | user | generate | migrate | trash | artisan <command>
|
|
79
80
|
|
|
80
81
|
${bold('Options')}
|
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', 'trash'];
|
|
14
|
+
const NEVELA = ['upgrade', 'dev', 'status', 'version', 'resource', 'adopt', '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
|
|
|
@@ -131,6 +131,19 @@ return [
|
|
|
131
131
|
'days' => 30,
|
|
132
132
|
],
|
|
133
133
|
|
|
134
|
+
/*
|
|
135
|
+
| Rich text fields (body:richtext). Whatever is sent is parsed and written out again
|
|
136
|
+
| with only these tags and attributes: nothing the editor, a script or a visitor sends
|
|
137
|
+
| is trusted. A tag that isn't listed goes and its words stay. The list matches the
|
|
138
|
+
| dashboard editor's toolbar; add to it only what you would let a stranger put on a page.
|
|
139
|
+
| A link may use these schemes, or none (/about).
|
|
140
|
+
*/
|
|
141
|
+
'rich_text' => [
|
|
142
|
+
'tags' => ['p', 'br', 'strong', 'em', 's', 'a', 'h2', 'h3', 'ul', 'ol', 'li', 'blockquote'],
|
|
143
|
+
'attributes' => ['a' => ['href']],
|
|
144
|
+
'schemes' => ['http', 'https', 'mailto'],
|
|
145
|
+
],
|
|
146
|
+
|
|
134
147
|
/*
|
|
135
148
|
| The most rows POST /{slug}/_bulk creates in one request (the dashboard's "Add several"
|
|
136
149
|
| grid). They are saved in one transaction, so this is also how long one can run.
|
|
@@ -142,14 +155,23 @@ return [
|
|
|
142
155
|
|
|
|
143
156
|
| Files go on a disk from config/filesystems.php and are handed out by Nevela's own
|
|
144
157
|
| route, GET {prefix}/_nevela/files/{key}, so nothing needs linking or publishing. Keys
|
|
145
|
-
| are unguessable, but anyone who has one can fetch the file
|
|
146
|
-
|
|
|
147
|
-
|
|
|
158
|
+
| are unguessable, but anyone who has one can fetch the file. If a CDN or your web
|
|
159
|
+
| server serves the disk, put its address in NEVELA_UPLOADS_URL and the API's links
|
|
160
|
+
| point there.
|
|
161
|
+
|
|
|
162
|
+
| A document that must stay private goes in a private file field ("private": true in
|
|
163
|
+
| the descriptor, or cv:file(pdf|private)). It is kept on private_disk, which must not
|
|
164
|
+
| be one the web server serves, and is handed out by
|
|
165
|
+
| GET {prefix}/_nevela/private/{slug}/{id}/{field} to someone signed in who may view
|
|
166
|
+
| that record. Its key opens nothing.
|
|
148
167
|
*/
|
|
149
168
|
'uploads' => [
|
|
150
169
|
'disk' => env('NEVELA_UPLOADS_DISK', 'public'),
|
|
151
170
|
'url' => env('NEVELA_UPLOADS_URL'),
|
|
152
171
|
|
|
172
|
+
/* Where private files are kept. Never served: not "public", and not a bucket anyone can read. */
|
|
173
|
+
'private_disk' => env('NEVELA_PRIVATE_DISK', 'local'),
|
|
174
|
+
|
|
153
175
|
/* Where untouched originals are kept, so an image can be optimised again later. Never served. */
|
|
154
176
|
'originals_disk' => env('NEVELA_ORIGINALS_DISK', 'local'),
|
|
155
177
|
|
|
@@ -29,6 +29,14 @@ final class Permissions
|
|
|
29
29
|
/** @var list<array{key: string, name: string, groups: list<array{key: string, name: string, features: list<array{key: string, name: string, actions: list<string>}>}>}>|null */
|
|
30
30
|
private static ?array $catalog = null;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The permissions that are a resource's own actions, such as "internship_applications.accept".
|
|
34
|
+
* They are the only ones outside the four, and are only ever given by name.
|
|
35
|
+
*
|
|
36
|
+
* @var array<string, true>
|
|
37
|
+
*/
|
|
38
|
+
private static array $own = [];
|
|
39
|
+
|
|
32
40
|
/**
|
|
33
41
|
* The catalog, shaped for the roles screen: modules, their groups, their features.
|
|
34
42
|
* Only the feature and the action appear in a permission; the rest is for reading.
|
|
@@ -41,6 +49,7 @@ final class Permissions
|
|
|
41
49
|
return self::$catalog;
|
|
42
50
|
}
|
|
43
51
|
|
|
52
|
+
self::$own = [];
|
|
44
53
|
$taken = ['users' => true, 'roles' => true];
|
|
45
54
|
$modules = [[
|
|
46
55
|
'key' => 'access',
|
|
@@ -64,7 +73,14 @@ final class Permissions
|
|
|
64
73
|
$taken[$descriptor->table] = true;
|
|
65
74
|
$heading = trim((string) ($descriptor->group ?? '')) ?: 'Resources';
|
|
66
75
|
$groups[$heading] ??= ['key' => self::slug($heading), 'name' => $heading, 'features' => []];
|
|
67
|
-
$
|
|
76
|
+
$feature = ['key' => $descriptor->table, 'name' => $descriptor->pluralLabel, 'actions' => self::ACTIONS];
|
|
77
|
+
// The resource's own actions, after the four: one permission each, with the name its button has.
|
|
78
|
+
foreach ($descriptor->actions as $action) {
|
|
79
|
+
$feature['actions'][] = $action->key();
|
|
80
|
+
$feature['labels'][$action->key()] = $action->label;
|
|
81
|
+
self::$own["{$descriptor->table}.{$action->key()}"] = true;
|
|
82
|
+
}
|
|
83
|
+
$groups[$heading]['features'][] = $feature;
|
|
68
84
|
}
|
|
69
85
|
if ($groups !== []) {
|
|
70
86
|
$modules[] = ['key' => 'resources', 'name' => 'Resources', 'groups' => array_values($groups)];
|
|
@@ -92,6 +108,7 @@ final class Permissions
|
|
|
92
108
|
public static function forget(): void
|
|
93
109
|
{
|
|
94
110
|
self::$catalog = null;
|
|
111
|
+
self::$own = [];
|
|
95
112
|
}
|
|
96
113
|
|
|
97
114
|
/**
|
|
@@ -119,7 +136,16 @@ final class Permissions
|
|
|
119
136
|
/** Whether `ability` has the shape of a permission, so the gate answers for it. */
|
|
120
137
|
public static function isPermission(string $ability): bool
|
|
121
138
|
{
|
|
122
|
-
|
|
139
|
+
if (preg_match('/^[a-z][a-z0-9_]*\.(create|view|edit|delete)$/', $ability)) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
// Or one of a resource's own actions. Anything else with a dot in it is the app's to define.
|
|
143
|
+
if (! str_contains($ability, '.')) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
self::catalog();
|
|
147
|
+
|
|
148
|
+
return isset(self::$own[$ability]);
|
|
123
149
|
}
|
|
124
150
|
|
|
125
151
|
/**
|
|
@@ -144,6 +170,11 @@ final class Permissions
|
|
|
144
170
|
* A pattern is checked to its end: "products.*" covers "products.view", and "*.view"
|
|
145
171
|
* covers "products.view" and nothing else of products. Stopping at the first "*"
|
|
146
172
|
* would turn "*.view" into "everything".
|
|
173
|
+
*
|
|
174
|
+
* In the second half, "*" is the four things every feature has: create, view, edit and
|
|
175
|
+
* delete. A resource's own action, such as accepting an application, is covered only
|
|
176
|
+
* by its own name or by "*" alone. Someone given "everything to do with the records"
|
|
177
|
+
* has not thereby been given the button that sends a letter.
|
|
147
178
|
*/
|
|
148
179
|
public static function matches(string $pattern, string $key): bool
|
|
149
180
|
{
|
|
@@ -165,7 +196,7 @@ final class Permissions
|
|
|
165
196
|
return false;
|
|
166
197
|
}
|
|
167
198
|
|
|
168
|
-
return $action === '*'
|
|
199
|
+
return $action === '*' ? in_array($k[1], self::ACTIONS, true) : $action === $k[1];
|
|
169
200
|
}
|
|
170
201
|
|
|
171
202
|
/**
|
|
@@ -236,10 +267,10 @@ final class Permissions
|
|
|
236
267
|
if ($grant === '*') {
|
|
237
268
|
return true;
|
|
238
269
|
}
|
|
239
|
-
if (! preg_match('/^(\*|@?[a-z][a-z0-9_]*)\.(\*|
|
|
270
|
+
if (! preg_match('/^(\*|@?[a-z][a-z0-9_]*)\.(\*|[a-z][a-z0-9_]*)$/', $grant, $m)) {
|
|
240
271
|
return false;
|
|
241
272
|
}
|
|
242
|
-
if (str_starts_with($grant, '@')) {
|
|
273
|
+
if (str_starts_with($grant, '@') && in_array($m[2], ['*', ...self::ACTIONS], true)) {
|
|
243
274
|
return in_array(substr(explode('.', $grant)[0], 1), ['access', 'resources', 'app'], true);
|
|
244
275
|
}
|
|
245
276
|
|
|
@@ -4,6 +4,9 @@ namespace Nevela\Laravel\Concerns;
|
|
|
4
4
|
|
|
5
5
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
6
6
|
use Illuminate\Support\Facades\Schema;
|
|
7
|
+
use InvalidArgumentException;
|
|
8
|
+
use Nevela\Laravel\Media\Uploads;
|
|
9
|
+
use Nevela\Laravel\Nevela;
|
|
7
10
|
use Nevela\Laravel\Trash\TrashedScope;
|
|
8
11
|
|
|
9
12
|
/**
|
|
@@ -59,5 +62,25 @@ trait Trashable
|
|
|
59
62
|
$model->forceDeleting = true;
|
|
60
63
|
}
|
|
61
64
|
});
|
|
65
|
+
|
|
66
|
+
// A private file goes when its record goes for good. In the trash the record still
|
|
67
|
+
// exists, and so does its file, unreachable, for the day it is restored.
|
|
68
|
+
static::deleted(function (self $model) {
|
|
69
|
+
if ($model->exists) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
$resource = Nevela::resource(class_basename($model));
|
|
74
|
+
} catch (InvalidArgumentException) {
|
|
75
|
+
return; // a model that uses the trash without being a resource
|
|
76
|
+
}
|
|
77
|
+
foreach ($resource->fields as $field) {
|
|
78
|
+
$key = $field->kind === 'file' && $field->private ? $model->getAttribute($field->column()) : null;
|
|
79
|
+
// Not while another record of the table still points at the same file.
|
|
80
|
+
if (is_string($key) && $key !== '' && ! $model->newQueryWithoutScopes()->where($field->column(), $key)->exists()) {
|
|
81
|
+
Uploads::discard($key);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
});
|
|
62
85
|
}
|
|
63
86
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Console;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Console\Command;
|
|
6
|
+
use InvalidArgumentException;
|
|
7
|
+
use Nevela\Laravel\Generator\GeneratedFile;
|
|
8
|
+
use Nevela\Laravel\Generator\ResourceGenerator;
|
|
9
|
+
use Nevela\Laravel\Generator\Writer;
|
|
10
|
+
use Nevela\Laravel\Nevela;
|
|
11
|
+
use Nevela\Laravel\Support\Adoption;
|
|
12
|
+
use Nevela\Laravel\Support\Descriptor;
|
|
13
|
+
use Nevela\Laravel\Support\Naming;
|
|
14
|
+
|
|
15
|
+
final class AdoptCommand extends Command
|
|
16
|
+
{
|
|
17
|
+
protected $signature = 'nevela:adopt
|
|
18
|
+
{table : The table to make a resource of, e.g. posts}
|
|
19
|
+
{--name= : The resource\'s name, when it isn\'t the table\'s own (posts is Post)}
|
|
20
|
+
{--icon= : Lucide icon name for the dashboard, e.g. newspaper}
|
|
21
|
+
{--group= : Sidebar group in the dashboard}
|
|
22
|
+
{--yes : Write it without asking}
|
|
23
|
+
{--force : Replace the descriptor this table already has}';
|
|
24
|
+
|
|
25
|
+
protected $description = 'Make a resource of a table you already have: its descriptor is read from the table, which is left as it is';
|
|
26
|
+
|
|
27
|
+
public function handle(): int
|
|
28
|
+
{
|
|
29
|
+
$table = (string) $this->argument('table');
|
|
30
|
+
if (str_starts_with($table, 'nevela_') || $table === 'migrations') {
|
|
31
|
+
$this->components->error("{$table} is a table Nevela or Laravel keeps for itself.");
|
|
32
|
+
|
|
33
|
+
return self::FAILURE;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
Nevela::forget();
|
|
37
|
+
try {
|
|
38
|
+
$all = Nevela::all();
|
|
39
|
+
// The descriptor this table has already, if it is being adopted again.
|
|
40
|
+
$others = array_values(array_filter($all, fn (Descriptor $d) => $d->table !== $table));
|
|
41
|
+
$existing = array_values(array_filter($all, fn (Descriptor $d) => $d->table === $table))[0] ?? null;
|
|
42
|
+
if ($existing !== null && ! $this->option('force')) {
|
|
43
|
+
$this->components->error("{$table} is a resource already. Edit its descriptor and run: php artisan nevela:generate. To read it from the table again, add --force.");
|
|
44
|
+
|
|
45
|
+
return self::FAILURE;
|
|
46
|
+
}
|
|
47
|
+
$name = $this->option('name');
|
|
48
|
+
// Read again, it stays the resource it is. Under another name the first descriptor
|
|
49
|
+
// would still be there, and the table would be two resources.
|
|
50
|
+
if ($existing !== null && $name !== null && Naming::pascal($name) !== $existing->name) {
|
|
51
|
+
$this->components->error("{$table} is the {$existing->name} resource, and --force reads it again under that name. To call it something else, remove nevela/resources/".Naming::kebab($existing->name).'.json and the code generated for it, then adopt the table again.');
|
|
52
|
+
|
|
53
|
+
return self::FAILURE;
|
|
54
|
+
}
|
|
55
|
+
$adoption = Adoption::of($table, $others, $existing?->name ?? $name, $this->option('icon') ?? $existing?->icon, $this->option('group') ?? $existing?->group);
|
|
56
|
+
} catch (InvalidArgumentException $e) {
|
|
57
|
+
$this->components->error($e->getMessage());
|
|
58
|
+
|
|
59
|
+
return self::FAILURE;
|
|
60
|
+
}
|
|
61
|
+
$descriptor = $adoption->descriptor;
|
|
62
|
+
|
|
63
|
+
$dir = rtrim(config('nevela.descriptors_path'), '/\\');
|
|
64
|
+
$path = $dir.DIRECTORY_SEPARATOR.Naming::kebab($descriptor->name).'.json';
|
|
65
|
+
foreach ($others as $other) {
|
|
66
|
+
if ($other->name === $descriptor->name) {
|
|
67
|
+
$this->components->error("There is a {$descriptor->name} already, on the {$other->table} table. Give this one another name: --name=…");
|
|
68
|
+
|
|
69
|
+
return self::FAILURE;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if ($taken = $this->ownFiles($descriptor, [...$others, $descriptor])) {
|
|
73
|
+
$this->components->error("The name {$descriptor->name} is taken by code of your own, which Nevela won't write over:");
|
|
74
|
+
$this->components->bulletList($taken);
|
|
75
|
+
$this->line(" Adopt the table under another name (<fg=cyan>--name=…</>), or move those files aside first.");
|
|
76
|
+
|
|
77
|
+
return self::FAILURE;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
$this->components->info("{$table} as {$descriptor->name}, with ".($descriptor->idType === 'int' ? 'integer' : 'UUID').' ids');
|
|
81
|
+
foreach ($adoption->fields as $column => $field) {
|
|
82
|
+
$this->components->twoColumnDetail($column, "{$field->name}:".Adoption::spec($field));
|
|
83
|
+
}
|
|
84
|
+
foreach ($adoption->left as $left) {
|
|
85
|
+
$this->components->twoColumnDetail($left['column'], "<fg=gray>left alone: {$left['why']}</>");
|
|
86
|
+
}
|
|
87
|
+
if ($adoption->notes !== []) {
|
|
88
|
+
$this->newLine();
|
|
89
|
+
$this->components->bulletList($adoption->notes);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (! $this->option('yes') && ! $this->components->confirm("Make {$descriptor->name} a resource? The table is not changed", true)) {
|
|
93
|
+
$this->components->info('Nothing was written.');
|
|
94
|
+
|
|
95
|
+
return self::SUCCESS;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (! is_dir($dir)) {
|
|
99
|
+
mkdir($dir, 0775, true);
|
|
100
|
+
}
|
|
101
|
+
file_put_contents($path, $descriptor->toJson());
|
|
102
|
+
$this->components->info("Described {$descriptor->name} in ".str_replace(base_path().DIRECTORY_SEPARATOR, '', $path));
|
|
103
|
+
|
|
104
|
+
Nevela::forget();
|
|
105
|
+
$status = GenerateCommand::generate($this, [$descriptor], Nevela::all(), false);
|
|
106
|
+
|
|
107
|
+
$this->newLine();
|
|
108
|
+
$this->line(' Next: <fg=cyan>php artisan migrate</> gives the table a trash (a deleted_at column), then tighten <fg=cyan>app/Policies/'.$descriptor->name.'Policy.php</>.');
|
|
109
|
+
$this->line(' Every column came in as the plainest type that holds it. Where one is an email, a slug or a choice, say so in the descriptor and run <fg=cyan>php artisan nevela:generate</>.');
|
|
110
|
+
|
|
111
|
+
return $status;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Files this resource would be written to that are there already and aren't Nevela's:
|
|
116
|
+
* a model of the same name, most often. Their generated block has nowhere to go, and a
|
|
117
|
+
* resource whose model lacks it only half works.
|
|
118
|
+
*
|
|
119
|
+
* @param list<Descriptor> $all
|
|
120
|
+
* @return list<string>
|
|
121
|
+
*/
|
|
122
|
+
private function ownFiles(Descriptor $descriptor, array $all): array
|
|
123
|
+
{
|
|
124
|
+
$roots = [GeneratedFile::TARGET_API => base_path()];
|
|
125
|
+
if (($web = config('nevela.web_path')) && is_dir($web)) {
|
|
126
|
+
$roots[GeneratedFile::TARGET_WEB] = $web;
|
|
127
|
+
}
|
|
128
|
+
$taken = [];
|
|
129
|
+
foreach ((new ResourceGenerator)->forResource($descriptor, null, $all) as $file) {
|
|
130
|
+
$root = $roots[$file->target] ?? null;
|
|
131
|
+
$absolute = $root === null ? null : rtrim($root, '/\\').DIRECTORY_SEPARATOR.$file->path;
|
|
132
|
+
if ($file->mode === GeneratedFile::MODE_BLOCK && $absolute !== null && is_file($absolute) && Writer::block((string) file_get_contents($absolute)) === null) {
|
|
133
|
+
$taken[] = $file->path;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return $taken;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -28,10 +28,11 @@ final class GenerateCommand extends Command
|
|
|
28
28
|
* @param list<string> $createMigrations The contents of the table's own migration(s); none for a resource that is new
|
|
29
29
|
* @param bool $written Whether an add_trash_to_… migration exists for the table already
|
|
30
30
|
* @param bool $columnExists Whether the table has deleted_at in the database now
|
|
31
|
+
* @param bool $adopted Whether the table was there before Nevela: then there may be no migration of that name to read
|
|
31
32
|
*/
|
|
32
|
-
public static function needsTrashMigration(array $createMigrations, bool $written, bool $columnExists): bool
|
|
33
|
+
public static function needsTrashMigration(array $createMigrations, bool $written, bool $columnExists, bool $adopted = false): bool
|
|
33
34
|
{
|
|
34
|
-
if ($createMigrations === []) {
|
|
35
|
+
if ($createMigrations === [] && ! $adopted) {
|
|
35
36
|
return false;
|
|
36
37
|
}
|
|
37
38
|
foreach ($createMigrations as $contents) {
|
|
@@ -127,11 +128,12 @@ final class GenerateCommand extends Command
|
|
|
127
128
|
// resource's own migration, written by the line above, already has the column.
|
|
128
129
|
$made = array_map(fn (string $file) => (string) file_get_contents($file), glob(database_path("migrations/*_create_{$descriptor->table}_table.php")) ?: []);
|
|
129
130
|
$written = (glob(database_path("migrations/*_add_trash_to_{$descriptor->table}_table.php")) ?: []) !== [];
|
|
130
|
-
if (self::needsTrashMigration($made, $written, self::hasTrashColumn($descriptor->table))) {
|
|
131
|
+
if (self::needsTrashMigration($made, $written, self::hasTrashColumn($descriptor->table), $descriptor->adopted)) {
|
|
131
132
|
$files[] = $generator->trashMigrationFile($descriptor);
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
$files[] = $generator->routes($all);
|
|
136
|
+
$files[] = $generator->publicRoutes($all);
|
|
135
137
|
$files[] = $generator->registry($all);
|
|
136
138
|
$files[] = $generator->authConfig((array) config('nevela.auth', []));
|
|
137
139
|
|
|
@@ -168,6 +170,14 @@ final class GenerateCommand extends Command
|
|
|
168
170
|
if ($edited) {
|
|
169
171
|
$command->components->warn("{$edited} file(s) skipped because you edited their generated block. Move your changes outside it, or rerun with --force.");
|
|
170
172
|
}
|
|
173
|
+
// A model written before its resource was made public has no scope saying which
|
|
174
|
+
// records are, and without one none are. Said, so an empty public list isn't a puzzle.
|
|
175
|
+
foreach ($queue as $descriptor) {
|
|
176
|
+
$model = base_path("app/Models/{$descriptor->name}.php");
|
|
177
|
+
if ($descriptor->public?->read !== null && is_file($model) && ! str_contains((string) file_get_contents($model), 'scopePublicQuery')) {
|
|
178
|
+
$command->components->warn("{$descriptor->name} can be read publicly, but its model doesn't say which records may be, so none are. Add to app/Models/{$descriptor->name}.php: public function scopePublicQuery(\\Illuminate\\Database\\Eloquent\\Builder \$query): void { … }");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
171
181
|
|
|
172
182
|
return self::SUCCESS;
|
|
173
183
|
}
|
|
@@ -15,6 +15,7 @@ final class MakeResourceCommand extends Command
|
|
|
15
15
|
{--fields= : "name:string, sku:string!, price:money, status:enum(draft|live), notes:text?"}
|
|
16
16
|
{--icon= : Lucide icon name for the dashboard, e.g. package}
|
|
17
17
|
{--group= : Sidebar group in the dashboard}
|
|
18
|
+
{--id=uuid : What a record\'s id is: uuid, or int for the numbered ids of $table->id()}
|
|
18
19
|
{--force : Overwrite an existing descriptor}
|
|
19
20
|
{--migrate : Run the new migration straight away}
|
|
20
21
|
{--seed= : Fill the new resource with records too (25, or the number you give). Runs the migration first}';
|
|
@@ -29,6 +30,7 @@ final class MakeResourceCommand extends Command
|
|
|
29
30
|
(string) ($this->option('fields') ?? $this->ask('Fields (e.g. name:string, price:money, active:boolean)')),
|
|
30
31
|
$this->option('icon'),
|
|
31
32
|
$this->option('group'),
|
|
33
|
+
(string) $this->option('id'),
|
|
32
34
|
);
|
|
33
35
|
} catch (InvalidArgumentException $e) {
|
|
34
36
|
$this->components->error($e->getMessage());
|
|
@@ -175,7 +175,9 @@ final class SeedCommand extends Command
|
|
|
175
175
|
for ($i = $done; $i < min($count, $done + $chunk); $i++) {
|
|
176
176
|
// Spread over the last 60 days, so the dashboard's "this week" and trend have something to show.
|
|
177
177
|
$created = date('Y-m-d H:i:s', $now - mt_rand(0, 60 * 86400));
|
|
178
|
-
|
|
178
|
+
// An integer id is the database's to give.
|
|
179
|
+
$id = $resource->idType === 'int' ? [] : ['id' => (string) Str::uuid7()];
|
|
180
|
+
$rows[] = $id + Fake::row($resource, $offset + $i + 1, $pools) + ['created_at' => $created, 'updated_at' => $created];
|
|
179
181
|
}
|
|
180
182
|
DB::table($resource->table)->insert($rows);
|
|
181
183
|
$bar->advance(count($rows));
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
namespace Nevela\Laravel\Console;
|
|
4
4
|
|
|
5
5
|
use Illuminate\Console\Command;
|
|
6
|
+
use Nevela\Laravel\Media\Uploads;
|
|
6
7
|
use Nevela\Laravel\Trash\Trash;
|
|
7
8
|
|
|
8
9
|
final class TrashCommand extends Command
|
|
@@ -13,6 +14,13 @@ final class TrashCommand extends Command
|
|
|
13
14
|
|
|
14
15
|
public function handle(): int
|
|
15
16
|
{
|
|
17
|
+
// Files visitors uploaded for a form they never sent. Nothing to do with the trash,
|
|
18
|
+
// but the same kind of job, on the same night.
|
|
19
|
+
$unclaimed = Uploads::purgeUnclaimed();
|
|
20
|
+
if ($unclaimed > 0) {
|
|
21
|
+
$this->components->info('Removed '.number_format($unclaimed).' file(s) uploaded through a public form and never used.');
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
$days = Trash::days();
|
|
17
25
|
if ($days === null) {
|
|
18
26
|
$this->components->info('Deleted records are kept until someone removes them (nevela.trash.days is off), so there is nothing to do.');
|