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.
Files changed (50) hide show
  1. package/index.mjs +1 -0
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +25 -3
  5. package/template/laravel/src/Access/Permissions.php +36 -5
  6. package/template/laravel/src/Concerns/Trashable.php +23 -0
  7. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  8. package/template/laravel/src/Console/GenerateCommand.php +13 -3
  9. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  10. package/template/laravel/src/Console/SeedCommand.php +3 -1
  11. package/template/laravel/src/Console/TrashCommand.php +8 -0
  12. package/template/laravel/src/Generator/ResourceGenerator.php +229 -29
  13. package/template/laravel/src/Http/ActionController.php +222 -0
  14. package/template/laravel/src/Http/PublicController.php +210 -0
  15. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  16. package/template/laravel/src/Http/ResourceRequest.php +151 -2
  17. package/template/laravel/src/Http/TrashController.php +18 -2
  18. package/template/laravel/src/Http/UploadController.php +42 -1
  19. package/template/laravel/src/Media/Uploads.php +111 -0
  20. package/template/laravel/src/Nevela.php +54 -2
  21. package/template/laravel/src/NevelaServiceProvider.php +17 -1
  22. package/template/laravel/src/Support/Action.php +168 -0
  23. package/template/laravel/src/Support/Adoption.php +352 -0
  24. package/template/laravel/src/Support/Descriptor.php +59 -4
  25. package/template/laravel/src/Support/Fake.php +15 -1
  26. package/template/laravel/src/Support/Field.php +74 -3
  27. package/template/laravel/src/Support/Html.php +185 -0
  28. package/template/laravel/src/Support/PublicAccess.php +133 -0
  29. package/template/web/app/api/[...path]/route.ts +42 -9
  30. package/template/web/app/dashboard/actions.ts +34 -0
  31. package/template/web/app/globals.css +46 -0
  32. package/template/web/components/access/role-form.tsx +40 -5
  33. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  34. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  35. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  36. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  37. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  38. package/template/web/components/dashboard/record-actions.tsx +7 -2
  39. package/template/web/components/dashboard/record-detail.tsx +9 -2
  40. package/template/web/components/dashboard/resource-action.tsx +281 -0
  41. package/template/web/components/dashboard/resource-table.tsx +15 -2
  42. package/template/web/components/dashboard/row-actions.tsx +24 -0
  43. package/template/web/components/dashboard/table-selection.tsx +19 -1
  44. package/template/web/lib/access.ts +3 -1
  45. package/template/web/lib/dashboard.ts +10 -0
  46. package/template/web/lib/extras.ts +66 -0
  47. package/template/web/lib/files.ts +14 -0
  48. package/template/web/lib/laravel.ts +60 -0
  49. package/template/web/lib/rich.ts +79 -0
  50. package/template/web/package.json +1 -1
@@ -0,0 +1,352 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Support;
4
+
5
+ use Illuminate\Support\Facades\DB;
6
+ use Illuminate\Support\Facades\Schema;
7
+ use Illuminate\Support\Str;
8
+ use InvalidArgumentException;
9
+
10
+ /**
11
+ * A descriptor for a table that is already there, read from the table itself.
12
+ *
13
+ * `php artisan nevela:adopt posts` is for an app that had its tables before it had Nevela.
14
+ * Nothing about the table is changed by reading it, and what can't be said in a descriptor
15
+ * is left out of it and listed: a column Nevela doesn't know about stays in the table,
16
+ * untouched by the API, for the app's own code to use.
17
+ *
18
+ * Types are taken as they are and no tighter. An `email` column is adopted as a string,
19
+ * not as an email, because every value in it was valid when it was saved and a stricter
20
+ * rule would refuse to save an old record again. Tightening is for whoever knows the data.
21
+ */
22
+ final class Adoption
23
+ {
24
+ private const INTEGERS = ['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'int2', 'int4', 'int8', 'serial', 'bigserial', 'year'];
25
+
26
+ private const DECIMALS = ['decimal', 'numeric', 'float', 'double', 'real', 'float4', 'float8', 'double precision', 'money'];
27
+
28
+ private const STRINGS = ['varchar', 'char', 'bpchar', 'nvarchar', 'nchar', 'character varying', 'character', 'string'];
29
+
30
+ private const DATETIMES = ['datetime', 'timestamp', 'timestamptz', 'datetime2', 'datetimeoffset', 'smalldatetime'];
31
+
32
+ /** Columns every resource has already, by another name. */
33
+ private const OWN = ['id', 'created_at', 'updated_at', 'deleted_at'];
34
+
35
+ /**
36
+ * @param array<string, Field> $fields The fields, keyed by the column each was read from
37
+ * @param list<array{column: string, why: string}> $left Columns that are not in the descriptor
38
+ * @param list<string> $notes What whoever adopts the table should know
39
+ */
40
+ private function __construct(
41
+ public readonly Descriptor $descriptor,
42
+ public readonly array $fields,
43
+ public readonly array $left,
44
+ public readonly array $notes,
45
+ ) {}
46
+
47
+ /**
48
+ * Read a table from the database.
49
+ *
50
+ * @param list<Descriptor> $resources The resources the app has: a foreign key to one of their tables is a relation
51
+ *
52
+ * @throws InvalidArgumentException when the table can't be a resource, with the reason
53
+ */
54
+ public static function of(string $table, array $resources = [], ?string $name = null, ?string $icon = null, ?string $group = null): self
55
+ {
56
+ if (! Schema::hasTable($table)) {
57
+ throw new InvalidArgumentException("There is no table called {$table}.");
58
+ }
59
+
60
+ $columns = Schema::getColumns($table);
61
+
62
+ return self::describe($table, $columns, Schema::getIndexes($table), Schema::getForeignKeys($table), $resources, $name, $icon, $group, self::longest($table, $columns));
63
+ }
64
+
65
+ /**
66
+ * The longest value in each string column that has no length of its own.
67
+ *
68
+ * PostgreSQL's `varchar` can be declared without one, and SQLite keeps none at all,
69
+ * so nothing about such a column says how long its values may be. The values do.
70
+ *
71
+ * @param list<array<string, mixed>> $columns
72
+ * @return array<string, int> by column
73
+ */
74
+ private static function longest(string $table, array $columns): array
75
+ {
76
+ $unbounded = [];
77
+ foreach ($columns as $column) {
78
+ if (in_array(strtolower((string) ($column['type_name'] ?? '')), self::STRINGS, true) && ! str_contains((string) ($column['type'] ?? ''), '(')) {
79
+ $unbounded[] = (string) $column['name'];
80
+ }
81
+ }
82
+ if ($unbounded === []) {
83
+ return [];
84
+ }
85
+ try {
86
+ $grammar = DB::connection()->getQueryGrammar();
87
+ $row = (array) DB::table($table)->selectRaw(implode(', ', array_map(fn (string $column) => 'max(length('.$grammar->wrap($column).')) as '.$grammar->wrap($column), $unbounded)))->first();
88
+ } catch (\Throwable) {
89
+ return []; // a database that measures some other way: the usual length, as for any string
90
+ }
91
+
92
+ return array_map(fn ($length) => (int) $length, $row);
93
+ }
94
+
95
+ /**
96
+ * The same, from what Laravel's schema builder says about a table: getColumns(),
97
+ * getIndexes() and getForeignKeys().
98
+ *
99
+ * @param list<array<string, mixed>> $columns
100
+ * @param list<array<string, mixed>> $indexes
101
+ * @param list<array<string, mixed>> $foreignKeys
102
+ * @param list<Descriptor> $resources
103
+ * @param array<string, int> $longest The longest value in a string column that declares no length, by column
104
+ *
105
+ * @throws InvalidArgumentException
106
+ */
107
+ public static function describe(string $table, array $columns, array $indexes, array $foreignKeys, array $resources = [], ?string $name = null, ?string $icon = null, ?string $group = null, array $longest = []): self
108
+ {
109
+ $name = Naming::pascal($name ?? Str::singular($table));
110
+ $byName = [];
111
+ foreach ($columns as $column) {
112
+ $byName[(string) $column['name']] = $column;
113
+ }
114
+
115
+ $idType = self::idType($table, $byName, $indexes);
116
+ $missing = array_values(array_diff(['created_at', 'updated_at'], array_keys($byName)));
117
+ if ($missing !== []) {
118
+ throw new InvalidArgumentException("{$table} has no ".implode(' or ', $missing).'. Nevela sorts and counts records by when they were made and changed: add the columns with a migration ($table->timestamps()), then adopt it.');
119
+ }
120
+
121
+ $unique = [];
122
+ foreach ($indexes as $index) {
123
+ if (($index['unique'] ?? false) && ! ($index['primary'] ?? false) && count($index['columns'] ?? []) === 1) {
124
+ $unique[(string) $index['columns'][0]] = true;
125
+ }
126
+ }
127
+ // column => the table it points at, for a foreign key on that one column to an id.
128
+ $parents = [];
129
+ foreach ($foreignKeys as $key) {
130
+ if (count($key['columns'] ?? []) === 1 && ($key['foreign_columns'] ?? []) === ['id']) {
131
+ $parents[(string) $key['columns'][0]] = (string) $key['foreign_table'];
132
+ }
133
+ }
134
+ $tables = [$table => $name];
135
+ foreach ($resources as $resource) {
136
+ $tables[$resource->table] ??= $resource->name;
137
+ }
138
+
139
+ $fields = [];
140
+ $left = [];
141
+ $notes = [];
142
+ foreach ($byName as $column => $info) {
143
+ if (in_array($column, self::OWN, true)) {
144
+ continue;
145
+ }
146
+ $required = ! ($info['nullable'] ?? false);
147
+ $leave = function (string $why) use (&$left, &$notes, $column, $info, $required) {
148
+ $left[] = ['column' => $column, 'why' => $why];
149
+ if ($required && ($info['default'] ?? null) === null && ! ($info['auto_increment'] ?? false) && ($info['generation'] ?? null) === null) {
150
+ $notes[] = "{$column} has to have a value and has no default, and it isn't in the descriptor, so creating a record from the dashboard will fail. Give the column a default, or make it nullable.";
151
+ }
152
+ };
153
+
154
+ $field = Naming::camel($column);
155
+ if (! preg_match('/^[a-z][A-Za-z0-9]*$/', $field) || Naming::snake($field) !== $column || in_array($field, Field::RESERVED, true)) {
156
+ $leave("its name can't be written as a field");
157
+
158
+ continue;
159
+ }
160
+ if (($info['generation'] ?? null) !== null) {
161
+ $leave('the database works it out');
162
+
163
+ continue;
164
+ }
165
+ if (preg_match('/(^|_)(password|secret|token)$/', $column)) {
166
+ $leave('it looks like a secret');
167
+
168
+ continue;
169
+ }
170
+
171
+ if (isset($parents[$column])) {
172
+ $target = $tables[$parents[$column]] ?? null;
173
+ if ($target !== null && str_ends_with($column, '_id')) {
174
+ $fields[$column] = new Field($field, 'belongsTo', required: $required, unique: isset($unique[$column]), target: $target);
175
+
176
+ continue;
177
+ }
178
+ $notes[] = $target === null
179
+ ? "{$column} points at {$parents[$column]}, which isn't a resource, so it is adopted as a plain value. Adopt {$parents[$column]} first and it becomes a relation."
180
+ : "{$column} points at {$parents[$column]}, but a relation's column ends in _id, so it is adopted as a plain value.";
181
+ }
182
+
183
+ $kind = self::kind($info);
184
+ if ($kind === null) {
185
+ $leave(strtolower((string) ($info['type_name'] ?? 'this type')).' has no field kind');
186
+
187
+ continue;
188
+ }
189
+ // No length of its own, and more in it already than the usual one allows.
190
+ if ($kind['kind'] === 'string' && ! isset($kind['maxLength']) && ($longest[$column] ?? 0) > 255) {
191
+ $kind['maxLength'] = max(65_535, $longest[$column]);
192
+ $notes[] = "{$column} declares no length and holds values longer than 255 characters, so it is adopted with room for ".number_format($kind['maxLength']).'.';
193
+ }
194
+ $default = isset($unique[$column]) ? null : self::default($kind, $info['default'] ?? null);
195
+ try {
196
+ $fields[$column] = new Field($field, $kind['kind'], required: $required, unique: isset($unique[$column]), options: $kind['options'] ?? [], maxLength: $kind['maxLength'] ?? null, default: $default);
197
+ } catch (InvalidArgumentException $e) {
198
+ $leave($e->getMessage());
199
+ }
200
+ }
201
+ if ($fields === []) {
202
+ throw new InvalidArgumentException("{$table} has no column that can be a field.");
203
+ }
204
+ // The app's own soft deletes become Nevela's trash, which doesn't keep things for ever.
205
+ if (isset($byName['deleted_at'])) {
206
+ $notes[] = "{$table} has a deleted_at column already, so the rows it marks as deleted are in Nevela's trash from the start, and the trash removes for good what has been in it longer than trash.days (30 unless you changed it). If those rows are to be kept, set trash.days to null in config/nevela.php before anyone opens the Trash page.";
207
+ }
208
+
209
+ $keyed = [];
210
+ foreach ($fields as $field) {
211
+ $keyed[$field->name] = $field;
212
+ }
213
+ $base = Descriptor::make($name, $keyed);
214
+
215
+ return new self(
216
+ new Descriptor($name, $keyed, $table, $base->slug, $base->label, $base->pluralLabel, $icon, $group, null, $idType, true),
217
+ $fields,
218
+ $left,
219
+ $notes,
220
+ );
221
+ }
222
+
223
+ /**
224
+ * What the table's ids are. A resource has one id column, called id, holding integers or UUIDs.
225
+ *
226
+ * @param array<string, array<string, mixed>> $columns
227
+ * @param list<array<string, mixed>> $indexes
228
+ */
229
+ private static function idType(string $table, array $columns, array $indexes): string
230
+ {
231
+ $primary = null;
232
+ foreach ($indexes as $index) {
233
+ if ($index['primary'] ?? false) {
234
+ $primary = array_values($index['columns'] ?? []);
235
+ }
236
+ }
237
+ $id = $columns['id'] ?? null;
238
+ if ($id === null || ($primary !== null && $primary !== ['id']) || ($primary === null && ! ($id['auto_increment'] ?? false))) {
239
+ throw new InvalidArgumentException("{$table} can't be a resource: a resource's primary key is one column called id.");
240
+ }
241
+ $type = strtolower((string) ($id['type_name'] ?? ''));
242
+ $full = strtolower((string) ($id['type'] ?? $type));
243
+
244
+ return match (true) {
245
+ in_array($type, self::INTEGERS, true) => 'int',
246
+ in_array($type, ['uuid', 'uniqueidentifier'], true), in_array($type, self::STRINGS, true) && str_contains($full, '(36)') => 'uuid',
247
+ default => throw new InvalidArgumentException("{$table} can't be a resource: its ids are neither integers nor UUIDs."),
248
+ };
249
+ }
250
+
251
+ /**
252
+ * The field kind a column is, or null when there isn't one for it.
253
+ *
254
+ * @param array<string, mixed> $column
255
+ * @return array{kind: string, maxLength?: int, options?: list<string>}|null
256
+ */
257
+ private static function kind(array $column): ?array
258
+ {
259
+ $type = strtolower((string) ($column['type_name'] ?? ''));
260
+ $full = strtolower((string) ($column['type'] ?? $type));
261
+ $length = preg_match('/\((\d+)\)/', $full, $m) ? (int) $m[1] : null;
262
+
263
+ return match (true) {
264
+ in_array($type, ['bool', 'boolean', 'bit'], true), $type === 'tinyint' && $length === 1 => ['kind' => 'boolean'],
265
+ in_array($type, self::INTEGERS, true) => ['kind' => 'int'],
266
+ in_array($type, self::DECIMALS, true) => ['kind' => 'float'],
267
+ $type === 'date' => ['kind' => 'date'],
268
+ in_array($type, self::DATETIMES, true) => ['kind' => 'datetime'],
269
+ // nvarchar(max) is SQL Server's long text.
270
+ in_array($type, self::STRINGS, true) && str_contains($full, '(max)') => ['kind' => 'text'],
271
+ // Its own length where it has one that isn't the usual: a value that fit the column has to fit the rule.
272
+ in_array($type, self::STRINGS, true) => ['kind' => 'string'] + ($length !== null && $length !== 255 ? ['maxLength' => $length] : []),
273
+ in_array($type, ['uuid', 'uniqueidentifier'], true) => ['kind' => 'string', 'maxLength' => 36],
274
+ $type === 'tinytext' => ['kind' => 'string'],
275
+ in_array($type, ['text', 'ntext'], true) => ['kind' => 'text'],
276
+ $type === 'mediumtext' => ['kind' => 'text', 'maxLength' => 16_777_215],
277
+ $type === 'longtext' => ['kind' => 'text', 'maxLength' => 4_294_967_295],
278
+ $type === 'enum' => self::choices((string) ($column['type'] ?? '')),
279
+ default => null,
280
+ };
281
+ }
282
+
283
+ /**
284
+ * A column's default, as the field's: 'pending', 0, false. Each database writes it its
285
+ * own way ('pending'::character varying), and one that is worked out when a row is
286
+ * made (CURRENT_TIMESTAMP, nextval(…)) is the column's business and is left there.
287
+ *
288
+ * @param array{kind: string, options?: list<string>} $kind
289
+ */
290
+ private static function default(array $kind, mixed $written): string|int|float|bool|null
291
+ {
292
+ if (! is_scalar($written) || (string) $written === '') {
293
+ return null;
294
+ }
295
+ $text = trim((string) $written);
296
+ // PostgreSQL adds what type it is; SQL Server wraps it in brackets.
297
+ $text = (string) preg_replace('/::[a-z_ ]+(\([0-9, ]*\))?(\[\])?$/i', '', $text);
298
+ while (str_starts_with($text, '(') && str_ends_with($text, ')')) {
299
+ $text = substr($text, 1, -1);
300
+ }
301
+ $quoted = strlen($text) >= 2 && $text[0] === "'" && str_ends_with($text, "'");
302
+ $value = $quoted ? str_replace("''", "'", substr($text, 1, -1)) : $text;
303
+ if (! $quoted && (strcasecmp($value, 'null') === 0 || preg_match('/[()]|^current_/i', $value))) {
304
+ return null;
305
+ }
306
+
307
+ return match ($kind['kind']) {
308
+ 'string', 'text' => $value === '' ? null : $value,
309
+ 'enum' => in_array($value, $kind['options'] ?? [], true) ? $value : null,
310
+ 'int' => filter_var($value, FILTER_VALIDATE_INT) === false ? null : (int) $value,
311
+ 'float' => is_numeric($value) ? (float) $value : null,
312
+ 'boolean' => match (strtolower($value)) {
313
+ '1', 'true', 't' => true,
314
+ '0', 'false', 'f' => false,
315
+ default => null,
316
+ },
317
+ default => null,
318
+ };
319
+ }
320
+
321
+ /**
322
+ * A MySQL enum column: its values, where they are ones an enum field can have.
323
+ *
324
+ * @return array{kind: string, options?: list<string>}
325
+ */
326
+ private static function choices(string $type): array
327
+ {
328
+ preg_match_all("/'((?:[^']|'')*)'/", $type, $m);
329
+ $options = $m[1];
330
+ foreach ($options as $option) {
331
+ if (! preg_match('/^[A-Za-z0-9_-]+$/', $option)) {
332
+ return ['kind' => 'string'];
333
+ }
334
+ }
335
+
336
+ return $options === [] ? ['kind' => 'string'] : ['kind' => 'enum', 'options' => $options];
337
+ }
338
+
339
+ /** One field as it is written after --fields, e.g. "excerpt:string(500)?". */
340
+ public static function spec(Field $field): string
341
+ {
342
+ $type = match (true) {
343
+ $field->kind === 'belongsTo' => "belongsTo({$field->target})",
344
+ $field->kind === 'enum' => 'enum('.implode('|', $field->options).')',
345
+ $field->maxLength !== null => "{$field->kind}({$field->maxLength})",
346
+ default => $field->kind,
347
+ };
348
+ $default = $field->default === null ? '' : '='.(is_bool($field->default) ? ($field->default ? 'true' : 'false') : $field->default);
349
+
350
+ return $type.($field->required ? '' : '?').($field->unique ? '!' : '').$default;
351
+ }
352
+ }
@@ -12,7 +12,16 @@ use JsonException;
12
12
  */
13
13
  final class Descriptor
14
14
  {
15
- /** @param array<string, Field> $fields keyed by field name, in declaration order */
15
+ /** What a record's id can be: a UUID, which is what Nevela makes, or the auto-incrementing integer of `$table->id()`. */
16
+ public const ID_TYPES = ['uuid', 'int'];
17
+
18
+ /**
19
+ * @param array<string, Field> $fields keyed by field name, in declaration order
20
+ * @param string $idType "uuid" or "int". Chosen when the resource is made: changing it later means migrating the table yourself.
21
+ * @param bool $adopted The table was there before Nevela, so no migration is written to create it
22
+ * @param array<string, Action> $actions What can be done to a record or to the list beyond create, edit and delete, by name
23
+ * @param PublicAccess|null $public What of it anyone may read or create without signing in. Nothing, when null.
24
+ */
16
25
  public function __construct(
17
26
  public readonly string $name,
18
27
  public readonly array $fields,
@@ -23,20 +32,41 @@ final class Descriptor
23
32
  public readonly ?string $icon = null,
24
33
  public readonly ?string $group = null,
25
34
  public readonly ?string $titleField = null,
35
+ public readonly string $idType = 'uuid',
36
+ public readonly bool $adopted = false,
37
+ public readonly array $actions = [],
38
+ public readonly ?PublicAccess $public = null,
26
39
  ) {
27
40
  if (! preg_match('/^[A-Z][A-Za-z0-9]*$/', $name)) {
28
41
  throw new InvalidArgumentException('Resource name must be PascalCase, e.g. Product or BlogPost.');
29
42
  }
43
+ if (! in_array($idType, self::ID_TYPES, true)) {
44
+ throw new InvalidArgumentException("\"{$idType}\" is not an id type. Use uuid or int.");
45
+ }
30
46
  if ($fields === []) {
31
47
  throw new InvalidArgumentException("Resource {$name} needs at least one field.");
32
48
  }
33
49
  if ($titleField !== null && ! isset($fields[$titleField])) {
34
50
  throw new InvalidArgumentException("titleField \"{$titleField}\" is not a field of {$name}.");
35
51
  }
52
+ $keys = [];
53
+ foreach ($actions as $action) {
54
+ foreach (array_keys($action->when) as $field) {
55
+ if (! isset($fields[$field])) {
56
+ throw new InvalidArgumentException("Action \"{$action->name}\" of {$name} depends on \"{$field}\", which is not one of its fields.");
57
+ }
58
+ }
59
+ // sendNow and send_now would be the same permission.
60
+ if (isset($keys[$action->key()])) {
61
+ throw new InvalidArgumentException("{$name} has two actions that come to \"{$action->key()}\".");
62
+ }
63
+ $keys[$action->key()] = true;
64
+ }
65
+ $public?->check($name, $fields);
36
66
  }
37
67
 
38
68
  /** Build from a name and a `--fields` string: "name:string, price:money, status:enum(draft|live)?". */
39
- public static function fromSpec(string $name, string $spec, ?string $icon = null, ?string $group = null): self
69
+ public static function fromSpec(string $name, string $spec, ?string $icon = null, ?string $group = null, string $idType = 'uuid'): self
40
70
  {
41
71
  $fields = [];
42
72
  foreach (array_filter(array_map('trim', self::splitSpec($spec))) as $token) {
@@ -47,11 +77,11 @@ final class Descriptor
47
77
  $fields[$field->name] = $field;
48
78
  }
49
79
 
50
- return self::make(Naming::pascal($name), $fields, icon: $icon, group: $group);
80
+ return self::make(Naming::pascal($name), $fields, icon: $icon, group: $group, idType: $idType);
51
81
  }
52
82
 
53
83
  /** @param array<string, Field> $fields */
54
- public static function make(string $name, array $fields, ?string $icon = null, ?string $group = null, ?string $titleField = null): self
84
+ public static function make(string $name, array $fields, ?string $icon = null, ?string $group = null, ?string $titleField = null, string $idType = 'uuid'): self
55
85
  {
56
86
  $plural = Naming::plural($name);
57
87
 
@@ -65,6 +95,7 @@ final class Descriptor
65
95
  $icon,
66
96
  $group,
67
97
  $titleField,
98
+ $idType,
68
99
  );
69
100
  }
70
101
 
@@ -77,6 +108,10 @@ final class Descriptor
77
108
  $fields[$key] = Field::fromArray($key, (array) $field);
78
109
  }
79
110
  $base = self::make($name, $fields);
111
+ $actions = [];
112
+ foreach ((array) ($data['actions'] ?? []) as $key => $action) {
113
+ $actions[(string) $key] = Action::fromArray((string) $key, (array) $action);
114
+ }
80
115
 
81
116
  return new self(
82
117
  $name,
@@ -88,6 +123,10 @@ final class Descriptor
88
123
  $data['icon'] ?? null,
89
124
  $data['group'] ?? null,
90
125
  $data['titleField'] ?? null,
126
+ (string) ($data['id'] ?? 'uuid'),
127
+ (bool) ($data['adopted'] ?? false),
128
+ $actions,
129
+ isset($data['public']) ? PublicAccess::fromArray((array) $data['public']) : null,
91
130
  );
92
131
  }
93
132
 
@@ -107,6 +146,9 @@ final class Descriptor
107
146
  '$schema' => 'https://nevela.dev/schema/resource.v1.json',
108
147
  'name' => $this->name,
109
148
  'table' => $this->table,
149
+ // Left out when they are what every descriptor written before them means.
150
+ 'id' => $this->idType === 'uuid' ? null : $this->idType,
151
+ 'adopted' => $this->adopted ?: null,
110
152
  'slug' => $this->slug,
111
153
  'label' => $this->label,
112
154
  'pluralLabel' => $this->pluralLabel,
@@ -114,6 +156,8 @@ final class Descriptor
114
156
  'group' => $this->group,
115
157
  'titleField' => $this->titleField,
116
158
  'fields' => array_map(fn (Field $f) => $f->toArray(), $this->fields),
159
+ 'actions' => $this->actions ? array_map(fn (Action $a) => $a->toArray(), $this->actions) : null,
160
+ 'public' => $this->public?->toArray(),
117
161
  ], fn ($v) => $v !== null);
118
162
  }
119
163
 
@@ -137,6 +181,17 @@ final class Descriptor
137
181
  return 'id';
138
182
  }
139
183
 
184
+ /**
185
+ * This resource with only some of its fields: what a list of it is sorted, filtered
186
+ * and searched by when the list is the public one.
187
+ *
188
+ * @param list<string> $fields
189
+ */
190
+ public function only(array $fields): self
191
+ {
192
+ return new self($this->name, array_intersect_key($this->fields, array_flip($fields)), $this->table, $this->slug, $this->label, $this->pluralLabel, $this->icon, $this->group, null, $this->idType, $this->adopted);
193
+ }
194
+
140
195
  public function variable(): string
141
196
  {
142
197
  return Naming::camel($this->name);
@@ -133,11 +133,25 @@ final class Fake
133
133
  'datetime' => $field->unique
134
134
  ? gmdate('Y-m-d H:i:s', self::EPOCH + $number)
135
135
  : date('Y-m-d H:i:s', time() - mt_rand(0, 365 * 86400)),
136
- 'text' => self::sentence(mt_rand(8, 24)).($field->unique ? " {$number}" : ''),
136
+ 'text' => $field->isRich()
137
+ ? self::article($field->unique ? " {$number}" : '')
138
+ : self::sentence(mt_rand(8, 24)).($field->unique ? " {$number}" : ''),
137
139
  default => self::string($field, $number, $first, $last, $isPerson),
138
140
  };
139
141
  }
140
142
 
143
+ /** A few paragraphs with a heading and a list, so a seeded record reads like one somebody wrote. */
144
+ private static function article(string $suffix): string
145
+ {
146
+ $items = implode('', array_map(fn () => '<li>'.ucfirst(self::sentence(mt_rand(3, 6))).'</li>', range(1, 3)));
147
+
148
+ return '<p>'.self::sentence(mt_rand(14, 24)).'</p>'
149
+ .'<h2>'.rtrim(self::sentence(mt_rand(3, 5)), '.').$suffix.'</h2>'
150
+ .'<p>'.self::sentence(mt_rand(12, 20)).' <strong>'.rtrim(self::sentence(mt_rand(2, 4)), '.').'</strong>, '.lcfirst(self::sentence(mt_rand(6, 10))).'</p>'
151
+ ."<ul>{$items}</ul>"
152
+ .'<p>'.self::sentence(mt_rand(10, 18)).'</p>';
153
+ }
154
+
141
155
  private static function string(Field $field, int $number, string $first, string $last, bool $isPerson): string
142
156
  {
143
157
  $name = strtolower($field->name);