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
@@ -22,6 +22,15 @@ use Throwable;
22
22
  */
23
23
  final class Uploads
24
24
  {
25
+ /** Who uploaded a file that came in through a public form and hasn't been used in a create yet. */
26
+ public const UNCLAIMED = 'public:unclaimed';
27
+
28
+ /** And one that has: it belongs to a record now, like any other. */
29
+ public const CLAIMED = 'public';
30
+
31
+ /** What every private file's key starts with. */
32
+ public const PRIVATE_PREFIX = 'private/';
33
+
25
34
  /** @var array<string, Upload|null> Rows already looked up in this request, by key. */
26
35
  private static array $loaded = [];
27
36
 
@@ -30,6 +39,27 @@ final class Uploads
30
39
  return (string) config('nevela.uploads.disk', 'public');
31
40
  }
32
41
 
42
+ /** Where private files are kept: a disk the web server doesn't serve. */
43
+ public static function privateDisk(): string
44
+ {
45
+ return (string) config('nevela.uploads.private_disk', 'local');
46
+ }
47
+
48
+ /**
49
+ * Whether a key is a private file's. It is said by the key itself, so that nothing
50
+ * which hands out files by key has to look anything up to know it must not.
51
+ */
52
+ public static function isPrivate(?string $key): bool
53
+ {
54
+ return is_string($key) && str_starts_with($key, self::PRIVATE_PREFIX);
55
+ }
56
+
57
+ /** Where a private file is on its disk, beside the originals Nevela keeps there. */
58
+ public static function privatePath(string $key): string
59
+ {
60
+ return 'nevela/'.$key;
61
+ }
62
+
33
63
  public static function maxBytes(): int
34
64
  {
35
65
  return (int) config('nevela.uploads.max_bytes', 10 * 1024 * 1024);
@@ -73,6 +103,23 @@ final class Uploads
73
103
  ];
74
104
  $profile = self::profile($field->profile);
75
105
 
106
+ // A private file is stored as it came, where the web server doesn't reach. Nothing
107
+ // is made from it: a thumbnail would be a copy of it on a disk anyone can read.
108
+ if ($field->private) {
109
+ $key = self::PRIVATE_PREFIX."{$base}.{$extension}";
110
+ self::write(Storage::disk(self::privateDisk()), self::privatePath($key), $bytes);
111
+ $size = str_starts_with($mime, 'image/') ? @getimagesizefromstring($bytes) : false;
112
+
113
+ return self::remember(Upload::create(['disk' => self::privateDisk()] + $row + [
114
+ 'key' => $key,
115
+ 'mime' => $mime,
116
+ 'size' => strlen($bytes),
117
+ 'width' => $size ? $size[0] : null,
118
+ 'height' => $size ? $size[1] : null,
119
+ 'optimised' => false,
120
+ ]));
121
+ }
122
+
76
123
  if ($field->isImage() && in_array($mime, ImageOptimizer::OPTIMISABLE, true)) {
77
124
  $written = [];
78
125
  $originals = Storage::disk((string) config('nevela.uploads.originals_disk', 'local'));
@@ -193,6 +240,11 @@ final class Uploads
193
240
  return null;
194
241
  }
195
242
  $upload = self::find($key);
243
+ // A private file has no address of its own. It is fetched through the record it
244
+ // belongs to (see privateRef), by someone who may view that record.
245
+ if (self::isPrivate($key)) {
246
+ return ['key' => $key, 'url' => null, 'private' => true, 'name' => $upload?->name, 'mime' => $upload?->mime, 'size' => $upload?->size];
247
+ }
196
248
  if ($upload === null) {
197
249
  return ['key' => $key, 'url' => self::url($key)];
198
250
  }
@@ -219,6 +271,65 @@ final class Uploads
219
271
  ];
220
272
  }
221
273
 
274
+ /**
275
+ * A private file as a record shows it: what it is, and the address it is fetched from,
276
+ * which names the record and the field and never the key.
277
+ *
278
+ * @return array<string, mixed>|null
279
+ */
280
+ public static function privateRef(?string $key, string $slug, mixed $id, string $field): ?array
281
+ {
282
+ $ref = self::ref($key);
283
+ if ($ref === null || ! self::isPrivate($key)) {
284
+ return $ref;
285
+ }
286
+
287
+ return ['url' => route('nevela.private.show', ['slug' => $slug, 'id' => $id, 'field' => $field])] + $ref;
288
+ }
289
+
290
+ /**
291
+ * Remove a private file for good: from its disk, and the record of it. For when the
292
+ * record it belonged to has been removed for good.
293
+ */
294
+ public static function discard(string $key): void
295
+ {
296
+ if (! self::isPrivate($key)) {
297
+ return;
298
+ }
299
+ $upload = Upload::query()->where('key', $key)->first();
300
+ Storage::disk($upload?->disk ?? self::privateDisk())->delete(self::privatePath($key));
301
+ $upload?->delete();
302
+ unset(self::$loaded[$key]);
303
+ }
304
+
305
+ /**
306
+ * Remove what visitors uploaded and never used: a form that was started and left.
307
+ * Each such file waits an hour for its create (PublicController::CLAIM_SECONDS), and
308
+ * goes after that, with anything that was made from it.
309
+ *
310
+ * @return int How many were removed
311
+ */
312
+ public static function purgeUnclaimed(int $olderThanSeconds = 3600): int
313
+ {
314
+ $removed = 0;
315
+ Upload::query()->where('uploaded_by', self::UNCLAIMED)->where('created_at', '<', now()->subSeconds($olderThanSeconds))->orderBy('id')
316
+ ->chunkById(200, function ($uploads) use (&$removed) {
317
+ foreach ($uploads as $upload) {
318
+ $disk = Storage::disk($upload->disk);
319
+ $disk->delete(self::isPrivate($upload->key) ? self::privatePath($upload->key) : $upload->key);
320
+ $disk->delete(array_column($upload->renditions ?? [], 'key'));
321
+ if ($upload->original_key !== null) {
322
+ Storage::disk((string) config('nevela.uploads.originals_disk', 'local'))->delete($upload->original_key);
323
+ }
324
+ unset(self::$loaded[$upload->key]);
325
+ $upload->delete();
326
+ $removed++;
327
+ }
328
+ });
329
+
330
+ return $removed;
331
+ }
332
+
222
333
  public static function forget(): void
223
334
  {
224
335
  self::$loaded = [];
@@ -7,6 +7,7 @@ use Illuminate\Database\UniqueConstraintViolationException;
7
7
  use Illuminate\Http\JsonResponse;
8
8
  use Illuminate\Http\Request;
9
9
  use Illuminate\Support\Facades\DB;
10
+ use Illuminate\Support\Str;
10
11
  use InvalidArgumentException;
11
12
  use Nevela\Laravel\Media\Uploads;
12
13
  use Nevela\Laravel\Support\Descriptor;
@@ -21,7 +22,7 @@ use Nevela\Laravel\Support\Naming;
21
22
  final class Nevela
22
23
  {
23
24
  /** Written by `pnpm release`; the same number as the repository's git tag. */
24
- public const VERSION = '0.7.1';
25
+ public const VERSION = '0.8.0';
25
26
 
26
27
  /** @var array<string, Descriptor> */
27
28
  private static array $descriptors = [];
@@ -76,7 +77,18 @@ final class Nevela
76
77
  */
77
78
  public static function list(Builder $query, string $name, Request $request, string $resourceClass): JsonResponse
78
79
  {
79
- $resource = self::resource($name);
80
+ return self::listFor($query, self::resource($name), $request, $resourceClass);
81
+ }
82
+
83
+ /**
84
+ * The same, for a descriptor that was handed over and not looked up: the public list
85
+ * is the resource with only the fields anyone may see, so nothing else can be sorted,
86
+ * filtered or searched by.
87
+ *
88
+ * @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass
89
+ */
90
+ public static function listFor(Builder $query, Descriptor $resource, Request $request, string $resourceClass): JsonResponse
91
+ {
80
92
  $list = ListQuery::parse($resource, $request->query(), (int) config('nevela.per_page', 25), (int) config('nevela.max_per_page', 100));
81
93
  if (! $list->ok()) {
82
94
  return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
@@ -124,12 +136,32 @@ final class Nevela
124
136
  }
125
137
 
126
138
  foreach ($list->filters as $filter) {
139
+ $field = $resource->fields[$filter['field']] ?? null;
140
+ if ($filter['value'] !== null && $field?->kind === 'belongsTo' && ! self::couldBeId((string) $field->target, (string) $filter['value'])) {
141
+ // Nothing belongs to a record that can't exist. Asked anyway, some databases
142
+ // answer a number column given letters with an error instead of with no rows.
143
+ $query->whereRaw('1 = 0');
144
+
145
+ continue;
146
+ }
127
147
  $filter['value'] === null
128
148
  ? $query->whereNull($filter['column'])
129
149
  : $query->where($filter['column'], $filter['value']);
130
150
  }
131
151
  }
132
152
 
153
+ /** Whether a value has the shape of one of a resource's ids: digits, or a UUID. */
154
+ private static function couldBeId(string $name, string $value): bool
155
+ {
156
+ try {
157
+ $integers = self::resource($name)->idType === 'int';
158
+ } catch (InvalidArgumentException) {
159
+ return true; // not a resource this app describes: its ids are its own business
160
+ }
161
+
162
+ return $integers ? preg_match('/^[0-9]{1,18}$/', $value) === 1 : Str::isUuid($value);
163
+ }
164
+
133
165
  /**
134
166
  * GET /{slug}/_insights?unit=day|week|month&q&filter[field] → { total, unit, series, breakdown }.
135
167
  *
@@ -318,6 +350,26 @@ final class Nevela
318
350
  return Uploads::ref($key);
319
351
  }
320
352
 
353
+ /**
354
+ * The same for a private file: what it is, and the address it is fetched from by
355
+ * someone who may view the record, which names the record and never the file's key.
356
+ *
357
+ * @return array<string, mixed>|null
358
+ */
359
+ public static function privateFile(?string $key, string $slug, mixed $id, string $field): ?array
360
+ {
361
+ return Uploads::privateRef($key, $slug, $id, $field);
362
+ }
363
+
364
+ /**
365
+ * A rich text field's HTML, as it is safe to show: only the tags and attributes in
366
+ * config('nevela.rich_text'). Generated API resources send every rich text field through it.
367
+ */
368
+ public static function html(?string $html): ?string
369
+ {
370
+ return $html === null ? null : Support\Html::clean($html);
371
+ }
372
+
321
373
  /** Whether a request is one Nevela answers (so errors use Flare's shape). */
322
374
  public static function handles(Request $request): bool
323
375
  {
@@ -13,6 +13,7 @@ use Illuminate\Support\Facades\Route;
13
13
  use Illuminate\Support\ServiceProvider;
14
14
  use Nevela\Laravel\Access\Access;
15
15
  use Nevela\Laravel\Access\Permissions;
16
+ use Nevela\Laravel\Console\AdoptCommand;
16
17
  use Nevela\Laravel\Console\DevCommand;
17
18
  use Nevela\Laravel\Console\GenerateCommand;
18
19
  use Nevela\Laravel\Console\MakeResourceCommand;
@@ -31,7 +32,9 @@ use Nevela\Laravel\Http\FlareErrors;
31
32
  use Nevela\Laravel\Http\Auth\AccountController;
32
33
  use Nevela\Laravel\Http\Auth\SecurityController;
33
34
  use Nevela\Laravel\Http\Auth\SignInController;
35
+ use Nevela\Laravel\Http\PublicThrottle;
34
36
  use Nevela\Laravel\Http\TokenController;
37
+ use Nevela\Laravel\Http\ActionController;
35
38
  use Nevela\Laravel\Http\TrashController;
36
39
  use Nevela\Laravel\Http\UploadController;
37
40
  use Nevela\Laravel\Media\Uploads;
@@ -58,7 +61,7 @@ final class NevelaServiceProvider extends ServiceProvider
58
61
 
59
62
  $this->publishes([__DIR__.'/../config/nevela.php' => config_path('nevela.php')], 'nevela-config');
60
63
  $this->commands([
61
- MakeResourceCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
64
+ MakeResourceCommand::class, AdoptCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
62
65
  UpdateCommand::class, DevCommand::class, StatusCommand::class, VersionCommand::class, SetupCommand::class,
63
66
  TrashCommand::class,
64
67
  ]);
@@ -172,12 +175,21 @@ final class NevelaServiceProvider extends ServiceProvider
172
175
  'app' => Nevela::fingerprint(),
173
176
  ]))->name('nevela.ping');
174
177
 
178
+ Route::aliasMiddleware('nevela.throttle', PublicThrottle::class);
179
+
180
+ // What a resource's "public" section opens: no sign-in, and each route its own throttle.
181
+ if (is_file($open = base_path('routes/nevela-public.php'))) {
182
+ Route::prefix(trim($prefix.'/public', '/'))->middleware('api')->name('nevela.public.')->group($open);
183
+ }
184
+
175
185
  // Stored files. Open, like any image on a website: the key is the secret.
176
186
  Route::prefix($prefix)->middleware('api')->get('_nevela/files/{path}', [UploadController::class, 'show'])
177
187
  ->where('path', '.*')->name('nevela.files.show');
178
188
 
179
189
  Route::prefix($prefix)->middleware(config('nevela.middleware', ['api', 'auth:sanctum']))->name('nevela.')->group(function () {
180
190
  Route::put('_nevela/uploads/{resource}/{field}', [UploadController::class, 'store'])->name('uploads.store');
191
+ // A private file: only for someone who may view the record it belongs to.
192
+ Route::get('_nevela/private/{slug}/{id}/{field}', [UploadController::class, 'download'])->name('private.show');
181
193
 
182
194
  // The image profiles, for a client that wants to know which renditions exist.
183
195
  Route::get('_nevela/profiles', fn () => response()->json([
@@ -221,6 +233,10 @@ final class NevelaServiceProvider extends ServiceProvider
221
233
  'data' => array_map(fn ($d) => $d->toArray(), Nevela::all()),
222
234
  ]))->name('resources');
223
235
 
236
+ // A resource's own actions: on one record, and on the list or a selection from it.
237
+ Route::post('{slug}/_actions/{action}', [ActionController::class, 'collection'])->name('actions.collection');
238
+ Route::post('{slug}/{id}/_actions/{action}', [ActionController::class, 'record'])->name('actions.record');
239
+
224
240
  if (is_file($routes = base_path('routes/nevela.php'))) {
225
241
  require $routes;
226
242
  }
@@ -0,0 +1,168 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Support;
4
+
5
+ use Illuminate\Validation\Rule;
6
+ use InvalidArgumentException;
7
+
8
+ /**
9
+ * Something that can be done to a record, or to a resource as a whole, beyond creating,
10
+ * editing and deleting it: accept an application, send an announcement.
11
+ *
12
+ * The descriptor says what it is called, where its button goes and what it asks for.
13
+ * What it does is a class of the app's own, written once: App\Nevela\Actions\<Resource>\<Action>.
14
+ * Between the two, Nevela does the route, the permission, the checking and the button.
15
+ */
16
+ final class Action
17
+ {
18
+ /** Where its button is: on one record, or on the list as a whole. */
19
+ public const ON = ['record', 'collection'];
20
+
21
+ /** Names that already mean something for every resource. */
22
+ public const RESERVED = ['create', 'view', 'edit', 'delete', 'update', 'restore', 'list', 'read'];
23
+
24
+ /** What an action can ask for. A picture or a relation is a record's business, not a button's. */
25
+ public const KINDS = ['string', 'text', 'int', 'float', 'boolean', 'date', 'datetime', 'enum'];
26
+
27
+ /**
28
+ * @param string $name camelCase, as in the descriptor and the address: accept, sendAnnouncement
29
+ * @param string|null $confirm A question to ask before it runs
30
+ * @param array<string, scalar|null> $when Field values a record must have for the action to apply to it
31
+ * @param array<string, Field> $fields What it asks the person for, in a sheet
32
+ * @param bool $many A record action that can also be run on a selection of rows
33
+ */
34
+ public function __construct(
35
+ public readonly string $name,
36
+ public readonly string $label,
37
+ public readonly string $on = 'record',
38
+ public readonly ?string $icon = null,
39
+ public readonly ?string $confirm = null,
40
+ public readonly array $when = [],
41
+ public readonly array $fields = [],
42
+ public readonly bool $many = false,
43
+ ) {
44
+ if (! preg_match('/^[a-z][A-Za-z0-9]*$/', $name)) {
45
+ throw new InvalidArgumentException("Action \"{$name}\" must be camelCase, e.g. accept or sendAnnouncement.");
46
+ }
47
+ if (in_array(Naming::snake($name), self::RESERVED, true)) {
48
+ throw new InvalidArgumentException("\"{$name}\" is something every resource does already. Call the action something else.");
49
+ }
50
+ if (! in_array($on, self::ON, true)) {
51
+ throw new InvalidArgumentException("Action \"{$name}\" is on \"{$on}\". Use record or collection.");
52
+ }
53
+ if ($on === 'collection' && ($when !== [] || $many)) {
54
+ throw new InvalidArgumentException("Action \"{$name}\" is on the collection, so it has no one record to check \"when\" against or to select.");
55
+ }
56
+ foreach ($when as $field => $value) {
57
+ if (! is_scalar($value) && $value !== null) {
58
+ throw new InvalidArgumentException("Action \"{$name}\": \"when\" compares \"{$field}\" with one value, or with null.");
59
+ }
60
+ }
61
+ foreach ($fields as $field) {
62
+ if (! in_array($field->kind, self::KINDS, true)) {
63
+ throw new InvalidArgumentException("Action \"{$name}\" can't ask for \"{$field->name}\": a {$field->kind} isn't something a button asks for. Use: ".implode(', ', self::KINDS).'.');
64
+ }
65
+ }
66
+ }
67
+
68
+ /** @param array<string, mixed> $data */
69
+ public static function fromArray(string $name, array $data): self
70
+ {
71
+ $fields = [];
72
+ foreach ((array) ($data['fields'] ?? []) as $key => $field) {
73
+ $fields[(string) $key] = Field::fromArray((string) $key, (array) $field);
74
+ }
75
+
76
+ return new self(
77
+ $name,
78
+ (string) ($data['label'] ?? Naming::humanize($name)),
79
+ (string) ($data['on'] ?? 'record'),
80
+ $data['icon'] ?? null,
81
+ $data['confirm'] ?? null,
82
+ (array) ($data['when'] ?? []),
83
+ $fields,
84
+ (bool) ($data['many'] ?? false),
85
+ );
86
+ }
87
+
88
+ /** @return array<string, mixed> */
89
+ public function toArray(): array
90
+ {
91
+ return array_filter([
92
+ 'label' => $this->label,
93
+ 'icon' => $this->icon,
94
+ 'on' => $this->on,
95
+ 'confirm' => $this->confirm,
96
+ 'when' => $this->when ?: null,
97
+ 'fields' => $this->fields ? array_map(fn (Field $f) => $f->toArray(), $this->fields) : null,
98
+ 'many' => $this->many ?: null,
99
+ ], fn ($v) => $v !== null);
100
+ }
101
+
102
+ /** The second half of its permission: "send_announcement" in "newsletter_subscriptions.send_announcement". */
103
+ public function key(): string
104
+ {
105
+ return Naming::snake($this->name);
106
+ }
107
+
108
+ /** The class that does it, without its namespace: SendAnnouncement. */
109
+ public function className(): string
110
+ {
111
+ return Naming::pascal($this->name);
112
+ }
113
+
114
+ /**
115
+ * Whether it applies to a record with these values, by field name.
116
+ *
117
+ * @param array<string, mixed> $values
118
+ */
119
+ public function applies(array $values): bool
120
+ {
121
+ foreach ($this->when as $field => $wanted) {
122
+ $value = $values[$field] ?? null;
123
+ $same = match (true) {
124
+ $wanted === null => $value === null,
125
+ $value === null => false,
126
+ // A yes/no column comes back from some databases as 1 and 0.
127
+ is_bool($wanted) => (bool) $value === $wanted,
128
+ default => (string) $value === (string) $wanted,
129
+ };
130
+ if (! $same) {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ return true;
136
+ }
137
+
138
+ /**
139
+ * The rules its fields are checked by: the ones a resource's field of the same kind has.
140
+ *
141
+ * @return array<string, list<mixed>>
142
+ */
143
+ public function rules(): array
144
+ {
145
+ $rules = [];
146
+ foreach ($this->fields as $field) {
147
+ $rules[$field->name] = [$field->required ? 'required' : 'nullable', ...match (true) {
148
+ $field->format === 'email' => ['email', 'max:'.($field->maxLength ?? 255)],
149
+ $field->format === 'url' => ['url', 'max:'.($field->maxLength ?? 2048)],
150
+ $field->format === 'tel' => ['string', 'max:'.($field->maxLength ?? 32)],
151
+ $field->format === 'slug' => ['string', 'alpha_dash', 'max:'.($field->maxLength ?? 255)],
152
+ $field->format === 'color' => ['string', 'regex:/^#[0-9a-fA-F]{6}$/'],
153
+ $field->kind === 'string' => ['string', 'max:'.($field->maxLength ?? 255)],
154
+ $field->kind === 'text' => ['string', 'max:'.($field->maxLength ?? 65535)],
155
+ $field->kind === 'int' && $field->format === 'percent' => ['integer', 'between:0,100'],
156
+ $field->kind === 'int' => ['integer'],
157
+ $field->kind === 'float' && $field->format === 'percent' => ['numeric', 'between:0,100'],
158
+ $field->kind === 'float' => ['numeric'],
159
+ $field->kind === 'boolean' => ['boolean'],
160
+ $field->kind === 'date' => ['date_format:Y-m-d'],
161
+ $field->kind === 'datetime' => ['date'],
162
+ $field->kind === 'enum' => [Rule::in($field->options)],
163
+ }];
164
+ }
165
+
166
+ return $rules;
167
+ }
168
+ }