create-nevela 0.1.1 → 0.1.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/README.md +64 -55
- package/index.mjs +405 -418
- package/package.json +1 -1
- package/packagist.mjs +34 -25
- package/template/laravel/README.md +1 -0
- package/template/laravel/config/nevela.php +7 -0
- package/template/laravel/src/Console/DevCommand.php +104 -0
- package/template/laravel/src/Console/GenerateCommand.php +107 -77
- package/template/laravel/src/Console/MakeResourceCommand.php +17 -2
- package/template/laravel/src/Console/StatusCommand.php +172 -0
- package/template/laravel/src/Console/UpdateCommand.php +303 -0
- package/template/laravel/src/Console/VersionCommand.php +20 -0
- package/template/laravel/src/Generator/GeneratedFile.php +11 -2
- package/template/laravel/src/Generator/ResourceGenerator.php +719 -639
- package/template/laravel/src/Nevela.php +161 -151
- package/template/laravel/src/NevelaServiceProvider.php +79 -65
- package/template/laravel/src/Support/DashboardUpdate.php +110 -0
- package/template/laravel/src/Support/Releases.php +45 -0
- package/template/web/app/auth-actions.ts +12 -4
- package/template/web/package.json +1 -1
|
@@ -1,639 +1,719 @@
|
|
|
1
|
-
<?php
|
|
2
|
-
|
|
3
|
-
namespace Nevela\Laravel\Generator;
|
|
4
|
-
|
|
5
|
-
use Nevela\Laravel\Support\Descriptor;
|
|
6
|
-
use Nevela\Laravel\Support\Field;
|
|
7
|
-
use Nevela\Laravel\Support\Naming;
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Turns descriptors into files. Pure: no Laravel, no filesystem — Writer does the I/O.
|
|
11
|
-
*
|
|
12
|
-
* Laravel output is ordinary Laravel code (model, migration, form request, API resource,
|
|
13
|
-
* controller, policy, routes). The web output is a Flare `.resource.ts` so JB's dashboard
|
|
14
|
-
* renders the resource without knowing Laravel is behind it.
|
|
15
|
-
*/
|
|
16
|
-
final class ResourceGenerator
|
|
17
|
-
{
|
|
18
|
-
private const M = '// '.Writer::START;
|
|
19
|
-
|
|
20
|
-
private const E = '// '.Writer::END;
|
|
21
|
-
|
|
22
|
-
/** @return list<GeneratedFile> */
|
|
23
|
-
public function forResource(Descriptor $d, ?string $timestamp = null): array
|
|
24
|
-
{
|
|
25
|
-
$timestamp ??= date('Y_m_d_His');
|
|
26
|
-
$api = GeneratedFile::TARGET_API;
|
|
27
|
-
|
|
28
|
-
return [
|
|
29
|
-
new GeneratedFile($api, "app/Models/{$d->name}.php", $this->model($d)),
|
|
30
|
-
new GeneratedFile($api, "database/migrations/{$timestamp}_create_{$d->table}_table.php", $this->migration($d), GeneratedFile::MODE_ONCE, "database/migrations/*_create_{$d->table}_table.php"),
|
|
31
|
-
new GeneratedFile($api, "app/Http/Requests/Nevela/{$d->name}Request.php", $this->request($d)),
|
|
32
|
-
new GeneratedFile($api, "app/Http/Resources/Nevela/{$d->name}Resource.php", $this->resource($d)),
|
|
33
|
-
new GeneratedFile($api, "app/Http/Controllers/Api/{$d->name}Controller.php", $this->controller($d)),
|
|
34
|
-
new GeneratedFile($api, "app/Policies/{$d->name}Policy.php", $this->policy($d), GeneratedFile::MODE_ONCE),
|
|
35
|
-
new GeneratedFile(GeneratedFile::TARGET_WEB, 'resources/'.Naming::kebab($d->name).'.resource.ts', $this->typescript($d)),
|
|
36
|
-
...$this->pages($d),
|
|
37
|
-
];
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The dashboard pages for a resource, in Flare's layout: list, new, detail and edit,
|
|
42
|
-
* each with its loading skeleton. They only name the descriptor — Flare's components
|
|
43
|
-
* render everything from it — so they are written once and are yours afterwards.
|
|
44
|
-
*
|
|
45
|
-
* @return list<GeneratedFile>
|
|
46
|
-
*/
|
|
47
|
-
public function pages(Descriptor $d): array
|
|
48
|
-
{
|
|
49
|
-
$var = Naming::camel($d->name).'Resource';
|
|
50
|
-
$import = "import {$var} from \"@/resources/".Naming::kebab($d->name).'.resource";';
|
|
51
|
-
$fields = count($d->fields);
|
|
52
|
-
$columns = min(6, $fields);
|
|
53
|
-
$rows = $fields + 2;
|
|
54
|
-
$formLoading = <<<TSX
|
|
55
|
-
import { FormSkeleton, PageHeaderSkeleton } from "@/components/dashboard/skeletons";
|
|
56
|
-
|
|
57
|
-
export default function Loading() {
|
|
58
|
-
return (
|
|
59
|
-
<>
|
|
60
|
-
<PageHeaderSkeleton actions={0} />
|
|
61
|
-
<FormSkeleton fields={{$fields}} />
|
|
62
|
-
</>
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
TSX;
|
|
67
|
-
|
|
68
|
-
$pages = [
|
|
69
|
-
'page.tsx' => <<<TSX
|
|
70
|
-
import { PageHeader } from "@/components/dashboard/page-header";
|
|
71
|
-
import { ResourceStats } from "@/components/dashboard/resource-stats";
|
|
72
|
-
import { ResourceTable } from "@/components/dashboard/resource-table";
|
|
73
|
-
import type { SearchParams } from "@/components/dashboard/query";
|
|
74
|
-
{$import}
|
|
75
|
-
|
|
76
|
-
export const metadata = { title: {$var}.pluralLabel };
|
|
77
|
-
|
|
78
|
-
export default async function {$d->name}ListPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
|
79
|
-
return (
|
|
80
|
-
<>
|
|
81
|
-
<PageHeader title={{$var}.pluralLabel} crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: {$var}.pluralLabel }]} />
|
|
82
|
-
<ResourceStats resource={{$var}} />
|
|
83
|
-
<ResourceTable resource={{$var}} searchParams={await searchParams} />
|
|
84
|
-
</>
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
TSX,
|
|
89
|
-
'loading.tsx' => <<<TSX
|
|
90
|
-
import { ResourceListSkeleton } from "@/components/dashboard/skeletons";
|
|
91
|
-
|
|
92
|
-
export default function Loading() {
|
|
93
|
-
return <ResourceListSkeleton columns={{$columns}} />;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
TSX,
|
|
97
|
-
'new/page.tsx' => <<<TSX
|
|
98
|
-
import { ResourceFormPage } from "@/components/dashboard/resource-form-page";
|
|
99
|
-
{$import}
|
|
100
|
-
|
|
101
|
-
export default function New{$d->name}Page() {
|
|
102
|
-
return <ResourceFormPage resource={{$var}} />;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
TSX,
|
|
106
|
-
'new/loading.tsx' => $formLoading,
|
|
107
|
-
'[id]/page.tsx' => <<<TSX
|
|
108
|
-
import { RecordDetail } from "@/components/dashboard/record-detail";
|
|
109
|
-
{$import}
|
|
110
|
-
|
|
111
|
-
export default async function {$d->name}DetailPage({ params }: { params: Promise<{ id: string }> }) {
|
|
112
|
-
return <RecordDetail resource={{$var}} id={(await params).id} />;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
TSX,
|
|
116
|
-
'[id]/loading.tsx' => <<<TSX
|
|
117
|
-
import { DetailSkeleton, PageHeaderSkeleton } from "@/components/dashboard/skeletons";
|
|
118
|
-
|
|
119
|
-
export default function Loading() {
|
|
120
|
-
return (
|
|
121
|
-
<>
|
|
122
|
-
<PageHeaderSkeleton />
|
|
123
|
-
<DetailSkeleton rows={{$rows}} />
|
|
124
|
-
</>
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
TSX,
|
|
129
|
-
'[id]/edit/page.tsx' => <<<TSX
|
|
130
|
-
import { ResourceFormPage } from "@/components/dashboard/resource-form-page";
|
|
131
|
-
{$import}
|
|
132
|
-
|
|
133
|
-
export default async function Edit{$d->name}Page({ params }: { params: Promise<{ id: string }> }) {
|
|
134
|
-
return <ResourceFormPage resource={{$var}} id={(await params).id} />;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
TSX,
|
|
138
|
-
'[id]/edit/loading.tsx' => $formLoading,
|
|
139
|
-
];
|
|
140
|
-
|
|
141
|
-
$files = [];
|
|
142
|
-
foreach ($pages as $path => $contents) {
|
|
143
|
-
$header = "// Generated once by Nevela for {$d->name}. This file is yours.\n";
|
|
144
|
-
$files[] = new GeneratedFile(GeneratedFile::TARGET_WEB, "app/dashboard/{$d->slug}/{$path}", $header.$contents, GeneratedFile::MODE_ONCE);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
return $files;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
$
|
|
160
|
-
$
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
{
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
{
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
{
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
$
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
{
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
$
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
$
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
{
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Generator;
|
|
4
|
+
|
|
5
|
+
use Nevela\Laravel\Support\Descriptor;
|
|
6
|
+
use Nevela\Laravel\Support\Field;
|
|
7
|
+
use Nevela\Laravel\Support\Naming;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Turns descriptors into files. Pure: no Laravel, no filesystem — Writer does the I/O.
|
|
11
|
+
*
|
|
12
|
+
* Laravel output is ordinary Laravel code (model, migration, form request, API resource,
|
|
13
|
+
* controller, policy, routes). The web output is a Flare `.resource.ts` so JB's dashboard
|
|
14
|
+
* renders the resource without knowing Laravel is behind it.
|
|
15
|
+
*/
|
|
16
|
+
final class ResourceGenerator
|
|
17
|
+
{
|
|
18
|
+
private const M = '// '.Writer::START;
|
|
19
|
+
|
|
20
|
+
private const E = '// '.Writer::END;
|
|
21
|
+
|
|
22
|
+
/** @return list<GeneratedFile> */
|
|
23
|
+
public function forResource(Descriptor $d, ?string $timestamp = null): array
|
|
24
|
+
{
|
|
25
|
+
$timestamp ??= date('Y_m_d_His');
|
|
26
|
+
$api = GeneratedFile::TARGET_API;
|
|
27
|
+
|
|
28
|
+
return [
|
|
29
|
+
new GeneratedFile($api, "app/Models/{$d->name}.php", $this->model($d)),
|
|
30
|
+
new GeneratedFile($api, "database/migrations/{$timestamp}_create_{$d->table}_table.php", $this->migration($d), GeneratedFile::MODE_ONCE, "database/migrations/*_create_{$d->table}_table.php"),
|
|
31
|
+
new GeneratedFile($api, "app/Http/Requests/Nevela/{$d->name}Request.php", $this->request($d)),
|
|
32
|
+
new GeneratedFile($api, "app/Http/Resources/Nevela/{$d->name}Resource.php", $this->resource($d)),
|
|
33
|
+
new GeneratedFile($api, "app/Http/Controllers/Api/{$d->name}Controller.php", $this->controller($d)),
|
|
34
|
+
new GeneratedFile($api, "app/Policies/{$d->name}Policy.php", $this->policy($d), GeneratedFile::MODE_ONCE),
|
|
35
|
+
new GeneratedFile(GeneratedFile::TARGET_WEB, 'resources/'.Naming::kebab($d->name).'.resource.ts', $this->typescript($d)),
|
|
36
|
+
...$this->pages($d),
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The dashboard pages for a resource, in Flare's layout: list, new, detail and edit,
|
|
42
|
+
* each with its loading skeleton. They only name the descriptor — Flare's components
|
|
43
|
+
* render everything from it — so they are written once and are yours afterwards.
|
|
44
|
+
*
|
|
45
|
+
* @return list<GeneratedFile>
|
|
46
|
+
*/
|
|
47
|
+
public function pages(Descriptor $d): array
|
|
48
|
+
{
|
|
49
|
+
$var = Naming::camel($d->name).'Resource';
|
|
50
|
+
$import = "import {$var} from \"@/resources/".Naming::kebab($d->name).'.resource";';
|
|
51
|
+
$fields = count($d->fields);
|
|
52
|
+
$columns = min(6, $fields);
|
|
53
|
+
$rows = $fields + 2;
|
|
54
|
+
$formLoading = <<<TSX
|
|
55
|
+
import { FormSkeleton, PageHeaderSkeleton } from "@/components/dashboard/skeletons";
|
|
56
|
+
|
|
57
|
+
export default function Loading() {
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
<PageHeaderSkeleton actions={0} />
|
|
61
|
+
<FormSkeleton fields={{$fields}} />
|
|
62
|
+
</>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
TSX;
|
|
67
|
+
|
|
68
|
+
$pages = [
|
|
69
|
+
'page.tsx' => <<<TSX
|
|
70
|
+
import { PageHeader } from "@/components/dashboard/page-header";
|
|
71
|
+
import { ResourceStats } from "@/components/dashboard/resource-stats";
|
|
72
|
+
import { ResourceTable } from "@/components/dashboard/resource-table";
|
|
73
|
+
import type { SearchParams } from "@/components/dashboard/query";
|
|
74
|
+
{$import}
|
|
75
|
+
|
|
76
|
+
export const metadata = { title: {$var}.pluralLabel };
|
|
77
|
+
|
|
78
|
+
export default async function {$d->name}ListPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
<PageHeader title={{$var}.pluralLabel} crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: {$var}.pluralLabel }]} />
|
|
82
|
+
<ResourceStats resource={{$var}} />
|
|
83
|
+
<ResourceTable resource={{$var}} searchParams={await searchParams} />
|
|
84
|
+
</>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
TSX,
|
|
89
|
+
'loading.tsx' => <<<TSX
|
|
90
|
+
import { ResourceListSkeleton } from "@/components/dashboard/skeletons";
|
|
91
|
+
|
|
92
|
+
export default function Loading() {
|
|
93
|
+
return <ResourceListSkeleton columns={{$columns}} />;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
TSX,
|
|
97
|
+
'new/page.tsx' => <<<TSX
|
|
98
|
+
import { ResourceFormPage } from "@/components/dashboard/resource-form-page";
|
|
99
|
+
{$import}
|
|
100
|
+
|
|
101
|
+
export default function New{$d->name}Page() {
|
|
102
|
+
return <ResourceFormPage resource={{$var}} />;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
TSX,
|
|
106
|
+
'new/loading.tsx' => $formLoading,
|
|
107
|
+
'[id]/page.tsx' => <<<TSX
|
|
108
|
+
import { RecordDetail } from "@/components/dashboard/record-detail";
|
|
109
|
+
{$import}
|
|
110
|
+
|
|
111
|
+
export default async function {$d->name}DetailPage({ params }: { params: Promise<{ id: string }> }) {
|
|
112
|
+
return <RecordDetail resource={{$var}} id={(await params).id} />;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
TSX,
|
|
116
|
+
'[id]/loading.tsx' => <<<TSX
|
|
117
|
+
import { DetailSkeleton, PageHeaderSkeleton } from "@/components/dashboard/skeletons";
|
|
118
|
+
|
|
119
|
+
export default function Loading() {
|
|
120
|
+
return (
|
|
121
|
+
<>
|
|
122
|
+
<PageHeaderSkeleton />
|
|
123
|
+
<DetailSkeleton rows={{$rows}} />
|
|
124
|
+
</>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
TSX,
|
|
129
|
+
'[id]/edit/page.tsx' => <<<TSX
|
|
130
|
+
import { ResourceFormPage } from "@/components/dashboard/resource-form-page";
|
|
131
|
+
{$import}
|
|
132
|
+
|
|
133
|
+
export default async function Edit{$d->name}Page({ params }: { params: Promise<{ id: string }> }) {
|
|
134
|
+
return <ResourceFormPage resource={{$var}} id={(await params).id} />;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
TSX,
|
|
138
|
+
'[id]/edit/loading.tsx' => $formLoading,
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
$files = [];
|
|
142
|
+
foreach ($pages as $path => $contents) {
|
|
143
|
+
$header = "// Generated once by Nevela for {$d->name}. This file is yours.\n";
|
|
144
|
+
$files[] = new GeneratedFile(GeneratedFile::TARGET_WEB, "app/dashboard/{$d->slug}/{$path}", $header.$contents, GeneratedFile::MODE_ONCE);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return $files;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `php nevela <command>` at the top of the project, so nobody has to `cd` into the
|
|
152
|
+
* Laravel app first. It hands the command to artisan in the same process: the arguments
|
|
153
|
+
* arrive exactly as they were typed, with no second round of shell quoting.
|
|
154
|
+
*
|
|
155
|
+
* @param string $api The Laravel app's folder relative to the project root, e.g. "apps/api"
|
|
156
|
+
*/
|
|
157
|
+
public function launcher(string $api): GeneratedFile
|
|
158
|
+
{
|
|
159
|
+
$api = trim(str_replace('\\', '/', $api), '/');
|
|
160
|
+
$m = self::M;
|
|
161
|
+
$e = self::E;
|
|
162
|
+
|
|
163
|
+
return new GeneratedFile(GeneratedFile::TARGET_ROOT, 'nevela', <<<PHP
|
|
164
|
+
#!/usr/bin/env php
|
|
165
|
+
<?php
|
|
166
|
+
|
|
167
|
+
// Run Nevela from the top of the project: php nevela <command>
|
|
168
|
+
// Maintained by `php artisan nevela:generate`. Add your own shortcuts below the block.
|
|
169
|
+
{$m}
|
|
170
|
+
\$api = __DIR__.'/{$api}';
|
|
171
|
+
\$args = array_slice(\$_SERVER['argv'], 1);
|
|
172
|
+
\$name = array_shift(\$args) ?? 'help';
|
|
173
|
+
|
|
174
|
+
// What you type => the artisan command it runs.
|
|
175
|
+
\$nevela = ['resource', 'generate', 'seed', 'user', 'update', 'dev', 'status', 'version'];
|
|
176
|
+
\$artisan = ['migrate', 'tinker', 'test', 'serve'];
|
|
177
|
+
if (in_array(\$name, ['--version', '-v', '-V'], true)) {
|
|
178
|
+
\$name = 'version';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (in_array(\$name, \$nevela, true)) {
|
|
182
|
+
// A new resource is no use until its table exists, so make it in the same step.
|
|
183
|
+
if (\$name === 'resource' && ! in_array('--no-migrate', \$args, true)) {
|
|
184
|
+
\$args[] = '--migrate';
|
|
185
|
+
}
|
|
186
|
+
\$args = array_values(array_diff(\$args, ['--no-migrate']));
|
|
187
|
+
\$command = ['nevela:'.\$name, ...\$args];
|
|
188
|
+
} elseif (in_array(\$name, \$artisan, true)) {
|
|
189
|
+
\$command = [\$name, ...\$args];
|
|
190
|
+
} elseif (\$name === 'artisan') {
|
|
191
|
+
\$command = \$args; // anything else: php nevela artisan route:list
|
|
192
|
+
} else {
|
|
193
|
+
echo <<<'HELP'
|
|
194
|
+
|
|
195
|
+
Nevela, from the top of your project.
|
|
196
|
+
|
|
197
|
+
php nevela dev run the API and the dashboard
|
|
198
|
+
php nevela status check versions, migrations, users and the dashboard
|
|
199
|
+
php nevela resource Product --fields="name:string, price:money" --seed
|
|
200
|
+
add a resource, create its table, fill it
|
|
201
|
+
php nevela generate regenerate after editing a descriptor
|
|
202
|
+
php nevela seed Product fill a resource with records
|
|
203
|
+
php nevela user create someone who can sign in
|
|
204
|
+
php nevela update update Nevela and the dashboard
|
|
205
|
+
php nevela version which Nevela this is
|
|
206
|
+
|
|
207
|
+
php nevela migrate php artisan migrate
|
|
208
|
+
php nevela artisan <command> any other artisan command
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
HELP;
|
|
212
|
+
exit(in_array(\$name, ['help', '--help', '-h'], true) ? 0 : 1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (! is_file(\$api.'/artisan')) {
|
|
216
|
+
fwrite(STDERR, "There is no Laravel app at {\$api}.\\n");
|
|
217
|
+
exit(1);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
chdir(\$api);
|
|
221
|
+
\$_SERVER['argv'] = \$argv = ['artisan', ...\$command];
|
|
222
|
+
\$_SERVER['argc'] = \$argc = count(\$argv);
|
|
223
|
+
|
|
224
|
+
require \$api.'/artisan';
|
|
225
|
+
{$e}
|
|
226
|
+
|
|
227
|
+
PHP);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The web app's registry of every descriptor: what the dashboard's sidebar, home page
|
|
232
|
+
* and stores are built from.
|
|
233
|
+
*
|
|
234
|
+
* @param list<Descriptor> $all
|
|
235
|
+
*/
|
|
236
|
+
public function registry(array $all): GeneratedFile
|
|
237
|
+
{
|
|
238
|
+
usort($all, fn (Descriptor $a, Descriptor $b) => strcmp($a->name, $b->name));
|
|
239
|
+
$imports = [];
|
|
240
|
+
$names = [];
|
|
241
|
+
foreach ($all as $d) {
|
|
242
|
+
$var = Naming::camel($d->name).'Resource';
|
|
243
|
+
$imports[] = "import {$var} from \"./".Naming::kebab($d->name).'.resource";';
|
|
244
|
+
$names[] = $var;
|
|
245
|
+
}
|
|
246
|
+
$list = implode(', ', $names);
|
|
247
|
+
// An app with no resources yet still needs the registry to exist and be empty.
|
|
248
|
+
$body = $names === []
|
|
249
|
+
? 'export const resources = [] as const;'
|
|
250
|
+
: implode("\n", $imports)."\n\nexport { {$list} };\nexport const resources = [{$list}] as const;";
|
|
251
|
+
$m = self::M;
|
|
252
|
+
$e = self::E;
|
|
253
|
+
|
|
254
|
+
return new GeneratedFile(GeneratedFile::TARGET_WEB, 'resources/index.ts', <<<TS
|
|
255
|
+
// Registry of every resource descriptor (maintained by `php artisan nevela:generate`).
|
|
256
|
+
{$m}
|
|
257
|
+
{$body}
|
|
258
|
+
{$e}
|
|
259
|
+
|
|
260
|
+
TS);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** @param list<Descriptor> $all */
|
|
264
|
+
public function routes(array $all): GeneratedFile
|
|
265
|
+
{
|
|
266
|
+
usort($all, fn (Descriptor $a, Descriptor $b) => strcmp($a->slug, $b->slug));
|
|
267
|
+
$lines = [];
|
|
268
|
+
foreach ($all as $d) {
|
|
269
|
+
$controller = "\\App\\Http\\Controllers\\Api\\{$d->name}Controller::class";
|
|
270
|
+
$lines[] = "Route::get('{$d->slug}/_stats', [{$controller}, 'stats'])->name('{$d->slug}.stats');";
|
|
271
|
+
$lines[] = "Route::apiResource('{$d->slug}', {$controller});";
|
|
272
|
+
}
|
|
273
|
+
$body = implode("\n", $lines);
|
|
274
|
+
$m = self::M;
|
|
275
|
+
$e = self::E;
|
|
276
|
+
|
|
277
|
+
return new GeneratedFile(GeneratedFile::TARGET_API, 'routes/nevela.php', <<<PHP
|
|
278
|
+
<?php
|
|
279
|
+
|
|
280
|
+
use Illuminate\Support\Facades\Route;
|
|
281
|
+
|
|
282
|
+
// Loaded by Nevela under the prefix and middleware in config/nevela.php
|
|
283
|
+
// (default: /api, auth:sanctum). Add your own routes below the generated block.
|
|
284
|
+
{$m}
|
|
285
|
+
{$body}
|
|
286
|
+
{$e}
|
|
287
|
+
|
|
288
|
+
PHP);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
public function model(Descriptor $d): string
|
|
292
|
+
{
|
|
293
|
+
$fillable = implode(', ', array_map(fn (Field $f) => "'{$f->column()}'", $d->fields));
|
|
294
|
+
$casts = [];
|
|
295
|
+
foreach ($d->fields as $f) {
|
|
296
|
+
$cast = match ($f->kind) {
|
|
297
|
+
'int' => 'integer',
|
|
298
|
+
'float' => 'float',
|
|
299
|
+
'boolean' => 'boolean',
|
|
300
|
+
'date' => 'date:Y-m-d',
|
|
301
|
+
'datetime' => 'datetime',
|
|
302
|
+
default => null,
|
|
303
|
+
};
|
|
304
|
+
if ($cast) {
|
|
305
|
+
$casts[] = " '{$f->column()}' => '{$cast}',";
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
$castBlock = $casts ? implode("\n", $casts) : '';
|
|
309
|
+
$m = self::M;
|
|
310
|
+
$e = self::E;
|
|
311
|
+
|
|
312
|
+
return <<<PHP
|
|
313
|
+
<?php
|
|
314
|
+
|
|
315
|
+
namespace App\Models;
|
|
316
|
+
|
|
317
|
+
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
318
|
+
use Illuminate\Database\Eloquent\Model;
|
|
319
|
+
|
|
320
|
+
class {$d->name} extends Model
|
|
321
|
+
{
|
|
322
|
+
use HasUuids;
|
|
323
|
+
|
|
324
|
+
protected \$table = '{$d->table}';
|
|
325
|
+
|
|
326
|
+
{$m}
|
|
327
|
+
protected \$fillable = [{$fillable}];
|
|
328
|
+
|
|
329
|
+
protected function casts(): array
|
|
330
|
+
{
|
|
331
|
+
return [
|
|
332
|
+
{$castBlock}
|
|
333
|
+
];
|
|
334
|
+
}
|
|
335
|
+
{$e}
|
|
336
|
+
|
|
337
|
+
// Relationships, scopes and accessors go here — outside the generated block.
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
PHP;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
public function migration(Descriptor $d): string
|
|
344
|
+
{
|
|
345
|
+
$columns = [];
|
|
346
|
+
foreach ($d->fields as $f) {
|
|
347
|
+
$c = $f->column();
|
|
348
|
+
$line = match (true) {
|
|
349
|
+
$f->kind === 'text' => "\$table->text('{$c}')",
|
|
350
|
+
$f->kind === 'int' => "\$table->integer('{$c}')",
|
|
351
|
+
$f->kind === 'float' && $f->format === 'money' => "\$table->decimal('{$c}', 12, 2)",
|
|
352
|
+
$f->kind === 'float' => "\$table->double('{$c}')",
|
|
353
|
+
$f->kind === 'boolean' => "\$table->boolean('{$c}')->default(false)",
|
|
354
|
+
$f->kind === 'date' => "\$table->date('{$c}')",
|
|
355
|
+
$f->kind === 'datetime' => "\$table->dateTime('{$c}')",
|
|
356
|
+
$f->format === 'url' => "\$table->string('{$c}', 2048)",
|
|
357
|
+
default => "\$table->string('{$c}')",
|
|
358
|
+
};
|
|
359
|
+
if (! $f->required) {
|
|
360
|
+
$line .= '->nullable()';
|
|
361
|
+
}
|
|
362
|
+
if ($f->unique) {
|
|
363
|
+
$line .= '->unique()';
|
|
364
|
+
} elseif ($f->kind === 'enum') {
|
|
365
|
+
$line .= '->index()';
|
|
366
|
+
}
|
|
367
|
+
$columns[] = " {$line};";
|
|
368
|
+
}
|
|
369
|
+
$cols = implode("\n", $columns);
|
|
370
|
+
|
|
371
|
+
return <<<PHP
|
|
372
|
+
<?php
|
|
373
|
+
|
|
374
|
+
use Illuminate\Database\Migrations\Migration;
|
|
375
|
+
use Illuminate\Database\Schema\Blueprint;
|
|
376
|
+
use Illuminate\Support\Facades\Schema;
|
|
377
|
+
|
|
378
|
+
// Generated once by Nevela. Later descriptor changes need a new migration of your own.
|
|
379
|
+
return new class extends Migration
|
|
380
|
+
{
|
|
381
|
+
public function up(): void
|
|
382
|
+
{
|
|
383
|
+
Schema::create('{$d->table}', function (Blueprint \$table) {
|
|
384
|
+
\$table->uuid('id')->primary();
|
|
385
|
+
{$cols}
|
|
386
|
+
\$table->timestamps();
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
public function down(): void
|
|
391
|
+
{
|
|
392
|
+
Schema::dropIfExists('{$d->table}');
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
PHP;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
public function request(Descriptor $d): string
|
|
400
|
+
{
|
|
401
|
+
$var = $d->variable();
|
|
402
|
+
$param = $d->routeParameter();
|
|
403
|
+
$rules = [];
|
|
404
|
+
$columns = [];
|
|
405
|
+
foreach ($d->fields as $f) {
|
|
406
|
+
$rules[] = " '{$f->name}' => [".implode(', ', $this->rules($d, $f)).'],';
|
|
407
|
+
$columns[] = "'{$f->name}' => '{$f->column()}'";
|
|
408
|
+
}
|
|
409
|
+
$ruleLines = implode("\n", $rules);
|
|
410
|
+
$columnMap = implode(', ', $columns);
|
|
411
|
+
$m = self::M;
|
|
412
|
+
$e = self::E;
|
|
413
|
+
|
|
414
|
+
return <<<PHP
|
|
415
|
+
<?php
|
|
416
|
+
|
|
417
|
+
namespace App\Http\Requests\Nevela;
|
|
418
|
+
|
|
419
|
+
use App\Models\\{$d->name};
|
|
420
|
+
use Illuminate\Support\Facades\Gate;
|
|
421
|
+
use Illuminate\Validation\Rule;
|
|
422
|
+
use Nevela\Laravel\Http\ResourceRequest;
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Validation for creating (POST), replacing (PUT) and patching (PATCH) a {$d->label}.
|
|
426
|
+
* PATCH makes every rule "sometimes"; PUT clears optional fields left out.
|
|
427
|
+
*/
|
|
428
|
+
class {$d->name}Request extends ResourceRequest
|
|
429
|
+
{
|
|
430
|
+
public function authorize(): bool
|
|
431
|
+
{
|
|
432
|
+
\${$var} = \$this->route('{$param}');
|
|
433
|
+
|
|
434
|
+
return \${$var} instanceof {$d->name}
|
|
435
|
+
? Gate::allows('update', \${$var})
|
|
436
|
+
: Gate::allows('create', {$d->name}::class);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
{$m}
|
|
440
|
+
public function rules(): array
|
|
441
|
+
{
|
|
442
|
+
\${$var} = \$this->route('{$param}');
|
|
443
|
+
|
|
444
|
+
return [
|
|
445
|
+
{$ruleLines}
|
|
446
|
+
];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
protected function columns(): array
|
|
450
|
+
{
|
|
451
|
+
return [{$columnMap}];
|
|
452
|
+
}
|
|
453
|
+
{$e}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
PHP;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** @return list<string> PHP expressions */
|
|
460
|
+
private function rules(Descriptor $d, Field $f): array
|
|
461
|
+
{
|
|
462
|
+
$rules = [$f->required ? "'required'" : "'nullable'"];
|
|
463
|
+
$type = match (true) {
|
|
464
|
+
$f->format === 'email' => ["'email'", "'max:255'"],
|
|
465
|
+
$f->format === 'url' => ["'url'", "'max:2048'"],
|
|
466
|
+
$f->format === 'tel' => ["'string'", "'max:32'"],
|
|
467
|
+
$f->format === 'slug' => ["'string'", "'alpha_dash'", "'max:255'"],
|
|
468
|
+
$f->format === 'color' => ["'string'", "'regex:/^#[0-9a-fA-F]{6}$/'"],
|
|
469
|
+
$f->kind === 'string' => ["'string'", "'max:255'"],
|
|
470
|
+
$f->kind === 'text' => ["'string'", "'max:65535'"],
|
|
471
|
+
$f->kind === 'int' && $f->format === 'percent' => ["'integer'", "'between:0,100'"],
|
|
472
|
+
$f->kind === 'int' => ["'integer'"],
|
|
473
|
+
$f->kind === 'float' && $f->format === 'percent' => ["'numeric'", "'between:0,100'"],
|
|
474
|
+
$f->kind === 'float' => ["'numeric'"],
|
|
475
|
+
$f->kind === 'boolean' => ["'boolean'"],
|
|
476
|
+
$f->kind === 'date' => ["'date_format:Y-m-d'"],
|
|
477
|
+
$f->kind === 'datetime' => ["'date'"],
|
|
478
|
+
$f->kind === 'enum' => ['Rule::in(['.implode(', ', array_map(fn ($o) => "'{$o}'", $f->options)).'])'],
|
|
479
|
+
};
|
|
480
|
+
$rules = [...$rules, ...$type];
|
|
481
|
+
if ($f->unique) {
|
|
482
|
+
$rules[] = "Rule::unique('{$d->table}', '{$f->column()}')->ignore(\${$d->variable()})";
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return $rules;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
public function resource(Descriptor $d): string
|
|
489
|
+
{
|
|
490
|
+
$lines = [];
|
|
491
|
+
foreach ($d->fields as $f) {
|
|
492
|
+
$value = match ($f->kind) {
|
|
493
|
+
'date' => "\$this->{$f->column()}?->format('Y-m-d')",
|
|
494
|
+
'datetime' => "\$this->{$f->column()}?->toJSON()",
|
|
495
|
+
default => "\$this->{$f->column()}",
|
|
496
|
+
};
|
|
497
|
+
$lines[] = " '{$f->name}' => {$value},";
|
|
498
|
+
}
|
|
499
|
+
$body = implode("\n", $lines);
|
|
500
|
+
$m = self::M;
|
|
501
|
+
$e = self::E;
|
|
502
|
+
|
|
503
|
+
return <<<PHP
|
|
504
|
+
<?php
|
|
505
|
+
|
|
506
|
+
namespace App\Http\Resources\Nevela;
|
|
507
|
+
|
|
508
|
+
use Illuminate\Http\Request;
|
|
509
|
+
use Illuminate\Http\Resources\Json\JsonResource;
|
|
510
|
+
|
|
511
|
+
/** A {$d->label} as Flare's client expects it: camelCase keys, unwrapped. */
|
|
512
|
+
class {$d->name}Resource extends JsonResource
|
|
513
|
+
{
|
|
514
|
+
public static \$wrap = null;
|
|
515
|
+
|
|
516
|
+
public function toArray(Request \$request): array
|
|
517
|
+
{
|
|
518
|
+
return [
|
|
519
|
+
'id' => \$this->id,
|
|
520
|
+
{$m}
|
|
521
|
+
{$body}
|
|
522
|
+
{$e}
|
|
523
|
+
'createdAt' => \$this->created_at?->toJSON(),
|
|
524
|
+
'updatedAt' => \$this->updated_at?->toJSON(),
|
|
525
|
+
];
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
PHP;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
public function controller(Descriptor $d): string
|
|
533
|
+
{
|
|
534
|
+
$var = $d->variable();
|
|
535
|
+
$m = self::M;
|
|
536
|
+
$e = self::E;
|
|
537
|
+
|
|
538
|
+
return <<<PHP
|
|
539
|
+
<?php
|
|
540
|
+
|
|
541
|
+
namespace App\Http\Controllers\Api;
|
|
542
|
+
|
|
543
|
+
use App\Http\Controllers\Controller;
|
|
544
|
+
use App\Http\Requests\Nevela\\{$d->name}Request;
|
|
545
|
+
use App\Http\Resources\Nevela\\{$d->name}Resource;
|
|
546
|
+
use App\Models\\{$d->name};
|
|
547
|
+
use Illuminate\Http\JsonResponse;
|
|
548
|
+
use Illuminate\Http\Request;
|
|
549
|
+
use Illuminate\Http\Response;
|
|
550
|
+
use Illuminate\Support\Facades\Gate;
|
|
551
|
+
use Nevela\Laravel\Nevela;
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* {$d->pluralLabel} over Flare's REST contract:
|
|
555
|
+
* GET /{$d->slug}?page&perPage&sort=-createdAt&q&filter[field] → { data, meta }.
|
|
556
|
+
*/
|
|
557
|
+
class {$d->name}Controller extends Controller
|
|
558
|
+
{
|
|
559
|
+
{$m}
|
|
560
|
+
public function index(Request \$request): JsonResponse
|
|
561
|
+
{
|
|
562
|
+
Gate::authorize('viewAny', {$d->name}::class);
|
|
563
|
+
|
|
564
|
+
return Nevela::list({$d->name}::query(), '{$d->name}', \$request, {$d->name}Resource::class);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
public function stats(Request \$request): JsonResponse
|
|
568
|
+
{
|
|
569
|
+
Gate::authorize('viewAny', {$d->name}::class);
|
|
570
|
+
|
|
571
|
+
return Nevela::stats({$d->name}::query(), '{$d->name}', \$request);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
public function store({$d->name}Request \$request): JsonResponse
|
|
575
|
+
{
|
|
576
|
+
\${$var} = {$d->name}::create(\$request->values());
|
|
577
|
+
|
|
578
|
+
return (new {$d->name}Resource(\${$var}->refresh()))
|
|
579
|
+
->response()
|
|
580
|
+
->setStatusCode(201)
|
|
581
|
+
->header('Location', \$request->url().'/'.\${$var}->getKey());
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
public function show({$d->name} \${$var}): {$d->name}Resource
|
|
585
|
+
{
|
|
586
|
+
Gate::authorize('view', \${$var});
|
|
587
|
+
|
|
588
|
+
return new {$d->name}Resource(\${$var});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
public function update({$d->name}Request \$request, {$d->name} \${$var}): {$d->name}Resource
|
|
592
|
+
{
|
|
593
|
+
\${$var}->update(\$request->values());
|
|
594
|
+
|
|
595
|
+
return new {$d->name}Resource(\${$var}->refresh());
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
public function destroy({$d->name} \${$var}): Response
|
|
599
|
+
{
|
|
600
|
+
Gate::authorize('delete', \${$var});
|
|
601
|
+
\${$var}->delete();
|
|
602
|
+
|
|
603
|
+
return response()->noContent();
|
|
604
|
+
}
|
|
605
|
+
{$e}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
PHP;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
public function policy(Descriptor $d): string
|
|
612
|
+
{
|
|
613
|
+
$var = $d->variable();
|
|
614
|
+
|
|
615
|
+
return <<<PHP
|
|
616
|
+
<?php
|
|
617
|
+
|
|
618
|
+
namespace App\Policies;
|
|
619
|
+
|
|
620
|
+
use App\Models\\{$d->name};
|
|
621
|
+
use App\Models\User;
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Who may do what with {$d->pluralLabel}. Generated once — this file is yours.
|
|
625
|
+
*
|
|
626
|
+
* Default: any signed-in user (routes already require auth:sanctum). Tighten this
|
|
627
|
+
* before production, e.g. `return in_array(\$user->role, ['admin', 'staff']);`.
|
|
628
|
+
*/
|
|
629
|
+
class {$d->name}Policy
|
|
630
|
+
{
|
|
631
|
+
public function viewAny(User \$user): bool
|
|
632
|
+
{
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
public function view(User \$user, {$d->name} \${$var}): bool
|
|
637
|
+
{
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
public function create(User \$user): bool
|
|
642
|
+
{
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
public function update(User \$user, {$d->name} \${$var}): bool
|
|
647
|
+
{
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
public function delete(User \$user, {$d->name} \${$var}): bool
|
|
652
|
+
{
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
PHP;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
public function typescript(Descriptor $d): string
|
|
661
|
+
{
|
|
662
|
+
$j = fn (mixed $v) => json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
663
|
+
$config = [
|
|
664
|
+
" name: {$j($d->name)},",
|
|
665
|
+
" table: {$j($d->table)},",
|
|
666
|
+
" slug: {$j($d->slug)},",
|
|
667
|
+
" label: {$j($d->label)},",
|
|
668
|
+
" pluralLabel: {$j($d->pluralLabel)},",
|
|
669
|
+
];
|
|
670
|
+
if ($d->icon) {
|
|
671
|
+
$config[] = " icon: {$j($d->icon)},";
|
|
672
|
+
}
|
|
673
|
+
if ($d->group) {
|
|
674
|
+
$config[] = " group: {$j($d->group)},";
|
|
675
|
+
}
|
|
676
|
+
if ($d->titleField) {
|
|
677
|
+
$config[] = " titleField: {$j($d->titleField)},";
|
|
678
|
+
}
|
|
679
|
+
$fields = [];
|
|
680
|
+
foreach ($d->fields as $f) {
|
|
681
|
+
$options = array_filter([
|
|
682
|
+
'required' => $f->required ? null : 'false',
|
|
683
|
+
'unique' => $f->unique ? 'true' : null,
|
|
684
|
+
'label' => $f->label ? $j($f->label) : null,
|
|
685
|
+
'format' => in_array($f->format, ['money', 'percent', 'rating'], true) ? $j($f->format) : null,
|
|
686
|
+
]);
|
|
687
|
+
$opts = $options ? '{ '.implode(', ', array_map(fn ($k, $v) => "{$k}: {$v}", array_keys($options), $options)).' }' : '';
|
|
688
|
+
$call = match (true) {
|
|
689
|
+
$f->kind === 'enum' => "field.enum({$j($f->options)}".($opts ? ", {$opts}" : '').')',
|
|
690
|
+
$f->kind === 'string' && $f->format !== null => "field.{$f->format}({$opts})",
|
|
691
|
+
default => "field.{$f->kind}({$opts})",
|
|
692
|
+
};
|
|
693
|
+
$fields[] = " {$f->name}: {$call},";
|
|
694
|
+
}
|
|
695
|
+
$configLines = implode("\n", $config);
|
|
696
|
+
$fieldLines = implode("\n", $fields);
|
|
697
|
+
$file = Naming::kebab($d->name);
|
|
698
|
+
$m = self::M;
|
|
699
|
+
$e = self::E;
|
|
700
|
+
|
|
701
|
+
return <<<TS
|
|
702
|
+
// Generated by Nevela from nevela/resources/{$file}.json in the Laravel app.
|
|
703
|
+
// Laravel owns validation, authorization and persistence; this descriptor tells the
|
|
704
|
+
// Flare dashboard how to render the resource. Change fields in the JSON and run
|
|
705
|
+
// `php artisan nevela:generate`. Display-only options may go below the block.
|
|
706
|
+
import { defineResource, field } from "@flaredev/core";
|
|
707
|
+
|
|
708
|
+
export default defineResource({
|
|
709
|
+
{$m}
|
|
710
|
+
{$configLines}
|
|
711
|
+
fields: {
|
|
712
|
+
{$fieldLines}
|
|
713
|
+
},
|
|
714
|
+
{$e}
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
TS;
|
|
718
|
+
}
|
|
719
|
+
}
|