create-nevela 0.5.0 → 0.5.2
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 +16 -15
- package/index.mjs +28 -5
- package/package.json +1 -1
- package/template/laravel/README.md +6 -4
- package/template/laravel/config/nevela.php +6 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +16 -0
- package/template/laravel/src/Http/ResourceRequest.php +75 -0
- package/template/laravel/src/Nevela.php +162 -15
- package/template/laravel/src/Support/Insights.php +78 -0
- package/template/web/app/dashboard/actions.ts +79 -0
- package/template/web/app/page.tsx +3 -9
- package/template/web/components/dashboard/bulk-create-grid.tsx +326 -0
- package/template/web/components/dashboard/resource-insights.tsx +219 -0
- package/template/web/components/dashboard/resource-table.tsx +4 -0
- package/template/web/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# create-nevela
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The `nevela` command. It creates a new [Nevela](https://nevela-docs.vercel.app) app, a Laravel API and a Next.js dashboard generated from one resource description, and runs everything inside one.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install -g create-nevela # once
|
|
7
|
+
nevela new my-app
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Or without installing anything:
|
|
4
11
|
|
|
5
12
|
```sh
|
|
6
13
|
pnpm create nevela my-app
|
|
@@ -16,41 +23,35 @@ You need PHP 8.3 or newer, Composer, and Node.js 20 or newer.
|
|
|
16
23
|
my-app/
|
|
17
24
|
├─ apps/api/ a Laravel app with Sanctum and Nevela installed
|
|
18
25
|
├─ apps/web/ the Next.js dashboard, pointed at the API
|
|
19
|
-
└─ nevela
|
|
26
|
+
└─ nevela the app's own launcher: php nevela does what nevela does
|
|
20
27
|
```
|
|
21
28
|
|
|
22
29
|
Then:
|
|
23
30
|
|
|
24
31
|
```sh
|
|
25
32
|
cd my-app
|
|
26
|
-
|
|
33
|
+
nevela dev
|
|
27
34
|
```
|
|
28
35
|
|
|
29
|
-
Open http://localhost:3000/sign-in and sign in as `admin@example.com` with the password `password`. That starter account is in your local database only; add your own with `
|
|
36
|
+
Open http://localhost:3000/sign-in and sign in as `admin@example.com` with the password `password`. That starter account is in your local database only; add your own with `nevela user`.
|
|
30
37
|
|
|
31
38
|
The app starts with an administrator and ten sample users (two editors, eight users), in your local database only, so its Users and Roles screens have something to show. Leave the samples out with `--no-sample-users`.
|
|
32
39
|
|
|
33
40
|
Add your first resource, from the same folder:
|
|
34
41
|
|
|
35
42
|
```sh
|
|
36
|
-
|
|
43
|
+
nevela resource Product --fields="name:string, sku:string!, price:money"
|
|
37
44
|
```
|
|
38
45
|
|
|
39
|
-
That writes the Laravel code and the dashboard pages, and creates the table. `
|
|
46
|
+
That writes the Laravel code and the dashboard pages, and creates the table. `nevela` on its own lists everything else.
|
|
40
47
|
|
|
41
|
-
##
|
|
42
|
-
|
|
43
|
-
Install this package once and you have a `nevela` command, in every shell:
|
|
44
|
-
|
|
45
|
-
```sh
|
|
46
|
-
npm install -g create-nevela
|
|
47
|
-
```
|
|
48
|
+
## Every command
|
|
48
49
|
|
|
49
50
|
```sh
|
|
50
51
|
nevela # anywhere: everything it can do
|
|
51
52
|
nevela new my-app # anywhere: create an app (leave the name off and it asks)
|
|
52
53
|
nevela update # anywhere: update the nevela command itself
|
|
53
|
-
nevela version
|
|
54
|
+
nevela --version # anywhere: the command's version, and the app's when in one
|
|
54
55
|
nevela dev # inside an app: run the API and the dashboard
|
|
55
56
|
nevela upgrade # inside an app: bring it to the latest Nevela
|
|
56
57
|
```
|
|
@@ -79,7 +80,7 @@ Without the command installed, `npx create-nevela upgrade` does the same, in any
|
|
|
79
80
|
| `--bundled-package` | Use the copy of `nevela/laravel` inside this installer instead of the release on Packagist. |
|
|
80
81
|
| `-y`, `--yes` | Ask nothing. Only the app name is ever asked for. |
|
|
81
82
|
|
|
82
|
-
|
|
83
|
+
Through npm's `create`, put options after `--`: `npm create nevela@latest my-app -- --no-install`.
|
|
83
84
|
|
|
84
85
|
## How it installs the Laravel package
|
|
85
86
|
|
package/index.mjs
CHANGED
|
@@ -68,14 +68,14 @@ const HELP = ` Create a new Nevela app: a Laravel API and a Next.js dashboard.
|
|
|
68
68
|
nevela new [name] [options] Create an app. It asks for the name if you leave it off.
|
|
69
69
|
Without the command: pnpm create nevela <name>
|
|
70
70
|
nevela update Update the nevela command itself to the latest version
|
|
71
|
-
nevela version
|
|
71
|
+
nevela --version Which version the nevela command is, and this app when in one
|
|
72
72
|
|
|
73
73
|
${bold('Inside an app')}
|
|
74
74
|
nevela upgrade Bring this app to the latest Nevela: the package and the dashboard
|
|
75
75
|
nevela dev Run the API and the dashboard
|
|
76
76
|
nevela status Check versions, migrations, users and the dashboard
|
|
77
77
|
nevela resource <Name> --fields="…" [--seed]
|
|
78
|
-
nevela seed | user | generate |
|
|
78
|
+
nevela seed | user | generate | migrate | artisan <command>
|
|
79
79
|
|
|
80
80
|
${bold('Options')}
|
|
81
81
|
--pm <pnpm|npm|yarn|bun> Package manager for the dashboard. Default: the one you ran this with.
|
|
@@ -89,7 +89,7 @@ const HELP = ` Create a new Nevela app: a Laravel API and a Next.js dashboard.
|
|
|
89
89
|
instead of the release on Packagist.
|
|
90
90
|
-y, --yes Ask nothing. (Only the app name is ever asked for.)
|
|
91
91
|
-h, --help Show this.
|
|
92
|
-
-v, --version Show the version.
|
|
92
|
+
-v, --version Show the version. Piped or captured, the number alone.
|
|
93
93
|
|
|
94
94
|
${bold('Needs')} PHP 8.3+, Composer, Node.js 20+
|
|
95
95
|
${bold('Docs')} ${DOCS}
|
|
@@ -361,6 +361,10 @@ async function inProject(name, args) {
|
|
|
361
361
|
}
|
|
362
362
|
if (name !== 'upgrade') process.exit(forward(project, name, args));
|
|
363
363
|
|
|
364
|
+
// The name first, before anything that waits on the network. A newer installer this
|
|
365
|
+
// one hands over to knows it has been shown, and prints one line in its place.
|
|
366
|
+
showTitle();
|
|
367
|
+
|
|
364
368
|
// Upgrading is the one command that must not run on stale code itself: it is the newest
|
|
365
369
|
// installer that knows what an older app needs repaired. So even a nevela command that
|
|
366
370
|
// hasn't been updated in a while upgrades an app correctly.
|
|
@@ -371,9 +375,18 @@ async function inProject(name, args) {
|
|
|
371
375
|
if (status !== null) process.exit(status);
|
|
372
376
|
}
|
|
373
377
|
}
|
|
374
|
-
|
|
378
|
+
const before = appVersion(project);
|
|
379
|
+
const looking = args.includes('--check') ? ' (checking only: nothing will be changed)' : '';
|
|
380
|
+
console.log(` ${indigo(`Nevela upgrade — this app: ${before ? `v${before}` : 'Nevela not installed in it yet'}`)}${dim(looking)}\n`);
|
|
375
381
|
const releases = await latestRelease();
|
|
376
382
|
const status = await upgrade(project, args, { latest: releases, say: (line) => console.log(` ${green('✔')} ${line}`) });
|
|
383
|
+
// Where it ended up, read from the app itself and not from what was asked for.
|
|
384
|
+
if (status === 0 && !args.includes('--check') && !args.includes('--undo')) {
|
|
385
|
+
const now = appVersion(project);
|
|
386
|
+
if (now) {
|
|
387
|
+
console.log(`\n ${green(`✔ ${now === before ? `This app is on Nevela v${now}, the version it was on.` : `This app is now on Nevela v${now}.`}`)}\n`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
377
390
|
process.exit(status);
|
|
378
391
|
}
|
|
379
392
|
|
|
@@ -445,7 +458,10 @@ function appVersion(project) {
|
|
|
445
458
|
}
|
|
446
459
|
}
|
|
447
460
|
|
|
448
|
-
/**
|
|
461
|
+
/**
|
|
462
|
+
* `nevela --version`, from anywhere: the command's version, and the app's when in one.
|
|
463
|
+
* `nevela version` is the same thing by its older name.
|
|
464
|
+
*/
|
|
449
465
|
function showVersion() {
|
|
450
466
|
const project = findProject(process.cwd());
|
|
451
467
|
const install = installed();
|
|
@@ -574,6 +590,13 @@ async function main() {
|
|
|
574
590
|
if (argv[0] === 'new') argv.shift();
|
|
575
591
|
else if (argv[0] === 'update') return updateTool(argv.slice(1));
|
|
576
592
|
else if (argv[0] === 'version') return showVersion();
|
|
593
|
+
else if ((argv[0] === '--version' || argv[0] === '-v') && argv.length === 1) {
|
|
594
|
+
// To a person, the whole answer. To a script (and to `nevela update`, which asks
|
|
595
|
+
// this of the installed command to check its work), the number and nothing else.
|
|
596
|
+
if (process.stdout.isTTY) return showVersion();
|
|
597
|
+
console.log(VERSION);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
577
600
|
else if (COMMANDS.includes(argv[0])) await inProject(argv[0], argv.slice(1));
|
|
578
601
|
else if (argv[0] === 'help' || (startedAsNevela && argv.length === 0)) {
|
|
579
602
|
// `nevela` on its own says what it can do. `pnpm create nevela` on its own starts an
|
package/package.json
CHANGED
|
@@ -3,18 +3,20 @@
|
|
|
3
3
|
The Laravel side of [Nevela](https://nevela-docs.vercel.app): describe a resource once and get its model, migration, validation, policy and REST API, plus the Next.js dashboard screens for it.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
|
|
7
|
-
php artisan migrate
|
|
6
|
+
nevela resource Product --fields="name:string, sku:string!, price:money, kind:enum(stock|digital), notes:text?"
|
|
8
7
|
```
|
|
9
8
|
|
|
10
9
|
## Start a new app
|
|
11
10
|
|
|
12
|
-
The quickest way is the
|
|
11
|
+
The quickest way is the `nevela` command, which sets up Laravel, this package and the dashboard together:
|
|
13
12
|
|
|
14
13
|
```sh
|
|
15
|
-
|
|
14
|
+
npm install -g create-nevela # once
|
|
15
|
+
nevela new my-app
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
Without installing it, `pnpm create nevela my-app` does the same.
|
|
19
|
+
|
|
18
20
|
## Add it to an existing Laravel app
|
|
19
21
|
|
|
20
22
|
```sh
|
|
@@ -114,6 +114,12 @@ return [
|
|
|
114
114
|
'per_page' => 25,
|
|
115
115
|
'max_per_page' => 100,
|
|
116
116
|
|
|
117
|
+
/*
|
|
118
|
+
| The most rows POST /{slug}/_bulk creates in one request (the dashboard's "Add several"
|
|
119
|
+
| grid). They are saved in one transaction, so this is also how long one can run.
|
|
120
|
+
*/
|
|
121
|
+
'bulk_max' => 500,
|
|
122
|
+
|
|
117
123
|
/*
|
|
118
124
|
| File and image fields (image:image, manual:file(pdf)).
|
|
119
125
|
|
|
|
@@ -382,6 +382,8 @@ final class ResourceGenerator
|
|
|
382
382
|
foreach ($all as $d) {
|
|
383
383
|
$controller = "\\App\\Http\\Controllers\\Api\\{$d->name}Controller::class";
|
|
384
384
|
$lines[] = "Route::get('{$d->slug}/_stats', [{$controller}, 'stats'])->name('{$d->slug}.stats');";
|
|
385
|
+
$lines[] = "Route::get('{$d->slug}/_insights', [{$controller}, 'insights'])->name('{$d->slug}.insights');";
|
|
386
|
+
$lines[] = "Route::post('{$d->slug}/_bulk', [{$controller}, 'bulk'])->name('{$d->slug}.bulk');";
|
|
385
387
|
$lines[] = "Route::apiResource('{$d->slug}', {$controller});";
|
|
386
388
|
}
|
|
387
389
|
$body = implode("\n", $lines);
|
|
@@ -749,6 +751,13 @@ final class ResourceGenerator
|
|
|
749
751
|
return Nevela::stats({$d->name}::query(), '{$d->name}', \$request);
|
|
750
752
|
}
|
|
751
753
|
|
|
754
|
+
public function insights(Request \$request): JsonResponse
|
|
755
|
+
{
|
|
756
|
+
Gate::authorize('viewAny', {$d->name}::class);
|
|
757
|
+
|
|
758
|
+
return Nevela::insights({$d->name}::query(), '{$d->name}', \$request);
|
|
759
|
+
}
|
|
760
|
+
|
|
752
761
|
public function store({$d->name}Request \$request): JsonResponse
|
|
753
762
|
{
|
|
754
763
|
\${$var} = {$d->name}::create(\$request->values());
|
|
@@ -759,6 +768,13 @@ final class ResourceGenerator
|
|
|
759
768
|
->header('Location', \$request->url().'/'.\${$var}->getKey());
|
|
760
769
|
}
|
|
761
770
|
|
|
771
|
+
public function bulk(Request \$request): JsonResponse
|
|
772
|
+
{
|
|
773
|
+
Gate::authorize('create', {$d->name}::class);
|
|
774
|
+
|
|
775
|
+
return Nevela::createMany({$d->name}::class, {$d->name}Request::class, {$d->name}Resource::class, \$request);
|
|
776
|
+
}
|
|
777
|
+
|
|
762
778
|
public function show({$d->name} \${$var}): {$d->name}Resource
|
|
763
779
|
{
|
|
764
780
|
Gate::authorize('view', \${$var});
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
namespace Nevela\Laravel\Http;
|
|
4
4
|
|
|
5
5
|
use Illuminate\Foundation\Http\FormRequest;
|
|
6
|
+
use Illuminate\Support\Facades\Validator as Validation;
|
|
7
|
+
use Illuminate\Validation\Rules\Unique;
|
|
6
8
|
use Illuminate\Validation\Validator;
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -52,6 +54,79 @@ abstract class ResourceRequest extends FormRequest
|
|
|
52
54
|
});
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Many rows, each checked by the rules for creating one.
|
|
59
|
+
*
|
|
60
|
+
* The same rules as a single create, on purpose: a second way into the table with its
|
|
61
|
+
* own idea of what is valid would let through a row the form then refuses to save.
|
|
62
|
+
* Rows are numbered from 1, as a person counts them.
|
|
63
|
+
*
|
|
64
|
+
* Two rows of one batch can't share a unique value either. The database would refuse
|
|
65
|
+
* the second, but only after the first was written and with no row number to show.
|
|
66
|
+
*
|
|
67
|
+
* @param list<mixed> $rows
|
|
68
|
+
* @return array{values: list<array<string, mixed>>, problems: list<array{row: int, issues: list<array{path: string, message: string}>}>}
|
|
69
|
+
*/
|
|
70
|
+
public function many(array $rows): array
|
|
71
|
+
{
|
|
72
|
+
$rules = $this->rules();
|
|
73
|
+
$columns = $this->columns();
|
|
74
|
+
$known = array_keys($columns);
|
|
75
|
+
$computed = array_map(fn (string $field) => $field.'File', $known);
|
|
76
|
+
$unique = array_keys(array_filter($rules, fn ($rule) => is_array($rule) && array_filter($rule, fn ($one) => $one instanceof Unique) !== []));
|
|
77
|
+
|
|
78
|
+
$values = [];
|
|
79
|
+
$problems = [];
|
|
80
|
+
$taken = [];
|
|
81
|
+
foreach (array_values($rows) as $index => $row) {
|
|
82
|
+
$number = $index + 1;
|
|
83
|
+
if (! is_array($row) || array_is_list($row)) {
|
|
84
|
+
// An empty JSON object arrives as an empty list; anything else here is not fields at all.
|
|
85
|
+
$problems[] = ['row' => $number, 'issues' => [['path' => '', 'message' => $row === [] ? 'This row is empty.' : 'This row is not a record.']]];
|
|
86
|
+
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
$issues = [];
|
|
90
|
+
foreach (array_keys($row) as $key) {
|
|
91
|
+
if (! in_array($key, $known, true) && ! in_array($key, self::READ_ONLY, true) && ! in_array($key, $computed, true)) {
|
|
92
|
+
$issues[] = ['path' => (string) $key, 'message' => 'Unknown field.'];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
$validator = Validation::make($row, $rules);
|
|
96
|
+
if ($validator->fails()) {
|
|
97
|
+
array_push($issues, ...FlareErrors::issues($validator->errors()->toArray()));
|
|
98
|
+
}
|
|
99
|
+
foreach ($unique as $field) {
|
|
100
|
+
$value = $row[$field] ?? null;
|
|
101
|
+
if (! is_scalar($value) || (string) $value === '') {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
$mark = $field."\0".$value;
|
|
105
|
+
if (isset($taken[$mark])) {
|
|
106
|
+
$issues[] = ['path' => $field, 'message' => "The same as row {$taken[$mark]}. Each one needs its own."];
|
|
107
|
+
} else {
|
|
108
|
+
$taken[$mark] = $number;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if ($issues !== []) {
|
|
112
|
+
$problems[] = ['row' => $number, 'issues' => $issues];
|
|
113
|
+
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
$validated = $validator->validated();
|
|
118
|
+
$mapped = [];
|
|
119
|
+
foreach ($columns as $field => $column) {
|
|
120
|
+
if (array_key_exists($field, $validated)) {
|
|
121
|
+
$mapped[$column] = $validated[$field];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
$values[] = $mapped;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return ['values' => $values, 'problems' => $problems];
|
|
128
|
+
}
|
|
129
|
+
|
|
55
130
|
/** Validated input keyed by column, ready for create()/update(). @return array<string, mixed> */
|
|
56
131
|
public function values(): array
|
|
57
132
|
{
|
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
namespace Nevela\Laravel;
|
|
4
4
|
|
|
5
5
|
use Illuminate\Database\Eloquent\Builder;
|
|
6
|
+
use Illuminate\Database\UniqueConstraintViolationException;
|
|
6
7
|
use Illuminate\Http\JsonResponse;
|
|
7
8
|
use Illuminate\Http\Request;
|
|
9
|
+
use Illuminate\Support\Facades\DB;
|
|
8
10
|
use InvalidArgumentException;
|
|
9
11
|
use Nevela\Laravel\Media\Uploads;
|
|
10
12
|
use Nevela\Laravel\Support\Descriptor;
|
|
13
|
+
use Nevela\Laravel\Support\Insights;
|
|
11
14
|
use Nevela\Laravel\Support\ListQuery;
|
|
12
15
|
use Nevela\Laravel\Support\Naming;
|
|
13
16
|
|
|
@@ -18,7 +21,7 @@ use Nevela\Laravel\Support\Naming;
|
|
|
18
21
|
final class Nevela
|
|
19
22
|
{
|
|
20
23
|
/** Written by `pnpm release`; the same number as the repository's git tag. */
|
|
21
|
-
public const VERSION = '0.5.
|
|
24
|
+
public const VERSION = '0.5.2';
|
|
22
25
|
|
|
23
26
|
/** @var array<string, Descriptor> */
|
|
24
27
|
private static array $descriptors = [];
|
|
@@ -79,6 +82,35 @@ final class Nevela
|
|
|
79
82
|
return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
self::narrow($query, $resource, $list);
|
|
86
|
+
|
|
87
|
+
$query->reorder()
|
|
88
|
+
->orderBy($list->sort['column'], $list->sort['direction'])
|
|
89
|
+
->orderBy($query->getModel()->getKeyName(), $list->sort['direction']);
|
|
90
|
+
|
|
91
|
+
$page = $query->paginate($list->perPage, ['*'], 'page', $list->page);
|
|
92
|
+
|
|
93
|
+
// One query for every file on the page, instead of one per record.
|
|
94
|
+
foreach ($resource->fields as $field) {
|
|
95
|
+
if ($field->kind === 'file') {
|
|
96
|
+
Uploads::preload($page->getCollection()->pluck($field->column()));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return response()->json([
|
|
101
|
+
'data' => $resourceClass::collection($page->getCollection())->resolve($request),
|
|
102
|
+
'meta' => [
|
|
103
|
+
'page' => $page->currentPage(),
|
|
104
|
+
'perPage' => $page->perPage(),
|
|
105
|
+
'total' => $page->total(),
|
|
106
|
+
'totalPages' => max(1, $page->lastPage()),
|
|
107
|
+
],
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The search and the filters of a list request, applied. Its insights are narrowed the same way. */
|
|
112
|
+
private static function narrow(Builder $query, Descriptor $resource, ListQuery $list): void
|
|
113
|
+
{
|
|
82
114
|
if ($list->q !== null) {
|
|
83
115
|
$columns = array_map(fn ($f) => $f->column(), array_values(array_filter($resource->fields, fn ($f) => $f->searchable())));
|
|
84
116
|
if ($columns !== []) {
|
|
@@ -96,31 +128,146 @@ final class Nevela
|
|
|
96
128
|
? $query->whereNull($filter['column'])
|
|
97
129
|
: $query->where($filter['column'], $filter['value']);
|
|
98
130
|
}
|
|
131
|
+
}
|
|
99
132
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
133
|
+
/**
|
|
134
|
+
* GET /{slug}/_insights?unit=day|week|month&q&filter[field] → { total, unit, series, breakdown }.
|
|
135
|
+
*
|
|
136
|
+
* What the dashboard's insights panel draws: how many records were created in each
|
|
137
|
+
* period, and how they split across each field that is a choice (an enum or a yes/no).
|
|
138
|
+
*
|
|
139
|
+
* It takes the list's own search and filters, so the charts describe the rows the
|
|
140
|
+
* table is showing. A whole-table chart above a filtered table would be wrong in a
|
|
141
|
+
* prominent place.
|
|
142
|
+
*/
|
|
143
|
+
public static function insights(Builder $query, string $name, Request $request): JsonResponse
|
|
144
|
+
{
|
|
145
|
+
$resource = self::resource($name);
|
|
146
|
+
$unit = $request->query('unit', 'day');
|
|
147
|
+
if (! is_string($unit) || ! isset(Insights::PERIODS[$unit])) {
|
|
148
|
+
return response()->json(['error' => 'Invalid query.', 'issues' => [['param' => 'unit', 'message' => 'Must be day, week or month.']]], 400);
|
|
149
|
+
}
|
|
150
|
+
// The same parameters as the list. Where in the list someone is makes no difference to a count.
|
|
151
|
+
$params = array_diff_key($request->query(), array_flip(['unit', 'page', 'perPage', 'sort', 'columns']));
|
|
152
|
+
$list = ListQuery::parse($resource, $params, (int) config('nevela.per_page', 25), (int) config('nevela.max_per_page', 100));
|
|
153
|
+
if (! $list->ok()) {
|
|
154
|
+
return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
|
|
155
|
+
}
|
|
156
|
+
self::narrow($query, $resource, $list);
|
|
157
|
+
$base = fn () => (clone $query)->reorder();
|
|
103
158
|
|
|
104
|
-
|
|
159
|
+
// Counted per day by the database, one grouped query over the created_at index,
|
|
160
|
+
// and added up into weeks or months here: see Support\Insights.
|
|
161
|
+
$now = now();
|
|
162
|
+
$table = $query->getModel()->getTable();
|
|
163
|
+
$day = $query->getConnection()->getDriverName() === 'sqlsrv' ? "CAST({$table}.created_at AS date)" : "DATE({$table}.created_at)";
|
|
164
|
+
$perDay = $base()->toBase()
|
|
165
|
+
->where("{$table}.created_at", '>=', Insights::since($unit, $now))
|
|
166
|
+
->selectRaw("{$day} as day, count(*) as aggregate")
|
|
167
|
+
->groupByRaw($day)
|
|
168
|
+
->get()
|
|
169
|
+
->map(fn ($row) => [(string) $row->day, (int) $row->aggregate]);
|
|
105
170
|
|
|
106
|
-
|
|
171
|
+
$breakdown = [];
|
|
107
172
|
foreach ($resource->fields as $field) {
|
|
108
|
-
if ($field->kind
|
|
109
|
-
|
|
173
|
+
if (! in_array($field->kind, ['enum', 'boolean'], true)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (count($breakdown) === 4) {
|
|
177
|
+
break; // one grouped query each, and four charts is already a lot to read
|
|
110
178
|
}
|
|
179
|
+
$column = $field->column();
|
|
180
|
+
$counts = [];
|
|
181
|
+
foreach ($base()->toBase()->select($column.' as value')->selectRaw('count(*) as aggregate')->groupBy($column)->get() as $row) {
|
|
182
|
+
$value = $row->value === null ? '' : ($field->kind === 'boolean' ? ($row->value ? 'true' : 'false') : (string) $row->value);
|
|
183
|
+
$counts[$value] = ($counts[$value] ?? 0) + (int) $row->aggregate;
|
|
184
|
+
}
|
|
185
|
+
// Every choice the field has, in its own order, so one nobody has picked shows as zero.
|
|
186
|
+
$values = $field->kind === 'boolean' ? ['true', 'false'] : $field->options;
|
|
187
|
+
$slices = array_map(fn (string $value) => ['value' => $value, 'count' => $counts[$value] ?? 0], $values);
|
|
188
|
+
if (($counts[''] ?? 0) > 0) {
|
|
189
|
+
$slices[] = ['value' => '', 'count' => $counts['']];
|
|
190
|
+
}
|
|
191
|
+
$breakdown[] = ['field' => $field->name, 'kind' => $field->kind, 'slices' => $slices];
|
|
111
192
|
}
|
|
112
193
|
|
|
113
194
|
return response()->json([
|
|
114
|
-
'
|
|
115
|
-
'
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
'total' => $page->total(),
|
|
119
|
-
'totalPages' => max(1, $page->lastPage()),
|
|
120
|
-
],
|
|
195
|
+
'total' => $base()->count(),
|
|
196
|
+
'unit' => $unit,
|
|
197
|
+
'series' => Insights::series($unit, $now, $perDay),
|
|
198
|
+
'breakdown' => $breakdown,
|
|
121
199
|
]);
|
|
122
200
|
}
|
|
123
201
|
|
|
202
|
+
/**
|
|
203
|
+
* POST /{slug}/_bulk { items: [ … ] } → 201 { created, data }, or 422 { error, rows }.
|
|
204
|
+
*
|
|
205
|
+
* Several records in one go, for the dashboard's grid. Every row is checked before
|
|
206
|
+
* anything is written, and they are saved in one transaction: a mistake on row 7
|
|
207
|
+
* is reported against row 7 and nothing is created, where saving six and stopping
|
|
208
|
+
* would leave someone working out which six.
|
|
209
|
+
*
|
|
210
|
+
* @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass
|
|
211
|
+
* @param class-string<Http\ResourceRequest> $requestClass
|
|
212
|
+
* @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass
|
|
213
|
+
*/
|
|
214
|
+
public static function createMany(string $modelClass, string $requestClass, string $resourceClass, Request $request): JsonResponse
|
|
215
|
+
{
|
|
216
|
+
$most = max(1, (int) config('nevela.bulk_max', 500));
|
|
217
|
+
$items = $request->input('items');
|
|
218
|
+
if (! is_array($items) || ! array_is_list($items) || $items === []) {
|
|
219
|
+
return response()->json(['error' => 'Send the rows to create as "items", a list with at least one row.'], 422);
|
|
220
|
+
}
|
|
221
|
+
if (count($items) > $most) {
|
|
222
|
+
return response()->json(['error' => 'That is '.number_format(count($items))." rows. At most {$most} can be created at once; for more, import a file."], 422);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
$checked = (new $requestClass)->many($items);
|
|
226
|
+
if ($checked['problems'] !== []) {
|
|
227
|
+
return self::rowsRefused($checked['problems'], count($items));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
$created = DB::transaction(function () use ($modelClass, $checked) {
|
|
232
|
+
$created = [];
|
|
233
|
+
foreach ($checked['values'] as $index => $values) {
|
|
234
|
+
try {
|
|
235
|
+
$created[] = $modelClass::create($values);
|
|
236
|
+
} catch (UniqueConstraintViolationException $e) {
|
|
237
|
+
// Someone else created the same value between the check and the write.
|
|
238
|
+
throw new \RuntimeException((string) ($index + 1), 0, $e);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return $created;
|
|
243
|
+
});
|
|
244
|
+
} catch (\RuntimeException $e) {
|
|
245
|
+
if (! $e->getPrevious() instanceof UniqueConstraintViolationException) {
|
|
246
|
+
throw $e;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return self::rowsRefused([['row' => (int) $e->getMessage(), 'issues' => [['path' => '', 'message' => 'A record with one of these values was created a moment ago.']]]], count($items));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return response()->json([
|
|
253
|
+
'created' => count($created),
|
|
254
|
+
'data' => array_map(fn ($model) => (new $resourceClass($model->refresh()))->resolve($request), $created),
|
|
255
|
+
], 201);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** @param list<array{row: int, issues: list<array{path: string, message: string}>}> $problems */
|
|
259
|
+
private static function rowsRefused(array $problems, int $sent): JsonResponse
|
|
260
|
+
{
|
|
261
|
+
$wrong = count($problems);
|
|
262
|
+
|
|
263
|
+
return response()->json([
|
|
264
|
+
'error' => $wrong === 1
|
|
265
|
+
? "Nothing was created: row {$problems[0]['row']} needs fixing."
|
|
266
|
+
: "Nothing was created: {$wrong} of the {$sent} rows need fixing.",
|
|
267
|
+
'rows' => $problems,
|
|
268
|
+
], 422);
|
|
269
|
+
}
|
|
270
|
+
|
|
124
271
|
/**
|
|
125
272
|
* GET /{slug}/_stats?field=status&days=7 → { total, current, previous, values }.
|
|
126
273
|
* What Flare's ResourceStats card shows above a table.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Support;
|
|
4
|
+
|
|
5
|
+
use Carbon\CarbonImmutable;
|
|
6
|
+
use Carbon\CarbonInterface;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The periods a "created per day, week or month" chart is drawn in.
|
|
10
|
+
*
|
|
11
|
+
* The database is only ever asked for counts per day, which every database can give
|
|
12
|
+
* with the same expression. Weeks and months are added up here, so a week starts on
|
|
13
|
+
* Monday and a month is a calendar month whichever database is underneath.
|
|
14
|
+
*/
|
|
15
|
+
final class Insights
|
|
16
|
+
{
|
|
17
|
+
/** How many periods of each kind a chart shows: a month of days, half a year of weeks, a year of months. */
|
|
18
|
+
public const PERIODS = ['day' => 30, 'week' => 26, 'month' => 12];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The periods up to and including the one `$now` is in, oldest first.
|
|
22
|
+
*
|
|
23
|
+
* @return list<string> A day or a week as "2026-10-05" (a week by its Monday), a month as "2026-10"
|
|
24
|
+
*/
|
|
25
|
+
public static function buckets(string $unit, CarbonInterface $now): array
|
|
26
|
+
{
|
|
27
|
+
$current = CarbonImmutable::instance($now)->startOfDay();
|
|
28
|
+
$buckets = [];
|
|
29
|
+
for ($back = self::PERIODS[$unit] - 1; $back >= 0; $back--) {
|
|
30
|
+
$buckets[] = match ($unit) {
|
|
31
|
+
'day' => $current->subDays($back)->format('Y-m-d'),
|
|
32
|
+
'week' => $current->startOfWeek(CarbonInterface::MONDAY)->subWeeks($back)->format('Y-m-d'),
|
|
33
|
+
// From the first of the month: stepping back from the 31st would skip the short months.
|
|
34
|
+
'month' => $current->startOfMonth()->subMonthsNoOverflow($back)->format('Y-m'),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return $buckets;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The first moment the chart covers. */
|
|
42
|
+
public static function since(string $unit, CarbonInterface $now): CarbonImmutable
|
|
43
|
+
{
|
|
44
|
+
$first = self::buckets($unit, $now)[0];
|
|
45
|
+
|
|
46
|
+
return CarbonImmutable::parse($unit === 'month' ? "{$first}-01" : $first, $now->getTimezone())->startOfDay();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Which period a day ("2026-10-09") belongs to. */
|
|
50
|
+
public static function bucketOf(string $unit, string $day): string
|
|
51
|
+
{
|
|
52
|
+
return match ($unit) {
|
|
53
|
+
'day' => substr($day, 0, 10),
|
|
54
|
+
'week' => CarbonImmutable::parse(substr($day, 0, 10))->startOfWeek(CarbonInterface::MONDAY)->format('Y-m-d'),
|
|
55
|
+
'month' => substr($day, 0, 7),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Counts per day, added up into the chart's periods. A period with nothing in it is
|
|
61
|
+
* still there, as zero: a gap in a chart should look like a gap.
|
|
62
|
+
*
|
|
63
|
+
* @param iterable<array{0: string, 1: int}> $perDay Pairs of day and count
|
|
64
|
+
* @return list<array{bucket: string, count: int}>
|
|
65
|
+
*/
|
|
66
|
+
public static function series(string $unit, CarbonInterface $now, iterable $perDay): array
|
|
67
|
+
{
|
|
68
|
+
$counts = array_fill_keys(self::buckets($unit, $now), 0);
|
|
69
|
+
foreach ($perDay as [$day, $count]) {
|
|
70
|
+
$bucket = self::bucketOf($unit, $day);
|
|
71
|
+
if (isset($counts[$bucket])) {
|
|
72
|
+
$counts[$bucket] += $count;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return array_map(fn (string $bucket, int $count) => ['bucket' => $bucket, 'count' => $count], array_keys($counts), array_values($counts));
|
|
77
|
+
}
|
|
78
|
+
}
|