create-nevela 0.5.1 → 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/index.mjs +14 -1
- package/package.json +1 -1
- 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/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/index.mjs
CHANGED
|
@@ -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
|
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -4,7 +4,9 @@ import { revalidatePath } from "next/cache";
|
|
|
4
4
|
import { storedFields, type PolicyAction } from "@flaredev/core";
|
|
5
5
|
import type { FieldIssue } from "@/lib/resource/store";
|
|
6
6
|
import { toCsv } from "@/lib/csv";
|
|
7
|
+
import { revalidateResource } from "@/lib/cache";
|
|
7
8
|
import { fileUrl } from "@/lib/files";
|
|
9
|
+
import { laravel } from "@/lib/laravel";
|
|
8
10
|
import { allResources, resourcePath, dashboardSession, dashboardStore, mayDo } from "@/lib/dashboard";
|
|
9
11
|
|
|
10
12
|
export type ActionResult<T = unknown> =
|
|
@@ -103,6 +105,83 @@ export async function createRecordAction(resourceName: string, input: unknown):
|
|
|
103
105
|
return result;
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
export type InsightsUnit = "day" | "week" | "month";
|
|
109
|
+
|
|
110
|
+
export interface InsightsData {
|
|
111
|
+
/** Everything the list matches, whenever it was created. */
|
|
112
|
+
total: number;
|
|
113
|
+
unit: InsightsUnit;
|
|
114
|
+
/** How many were created in each period, oldest first. A day or a week is "2026-10-05" (a week by its Monday), a month "2026-10". */
|
|
115
|
+
series: { bucket: string; count: number }[];
|
|
116
|
+
/** How the records split across each field that is a choice. */
|
|
117
|
+
breakdown: { field: string; kind: "enum" | "boolean"; slices: { value: string; count: number }[] }[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The counts behind the insights panel: `GET /{slug}/_insights`.
|
|
122
|
+
*
|
|
123
|
+
* `query` is the list's own search and filters, so the charts are of the rows the table
|
|
124
|
+
* is showing. Asked for only when the panel is opened.
|
|
125
|
+
*/
|
|
126
|
+
export async function resourceInsightsAction(resourceName: string, query: string, unit: InsightsUnit): Promise<ActionResult<InsightsData>> {
|
|
127
|
+
const denied = await allowed(resourceName, "read");
|
|
128
|
+
if (denied) return denied;
|
|
129
|
+
|
|
130
|
+
const { resource } = dashboardStore(resourceName);
|
|
131
|
+
const params = new URLSearchParams();
|
|
132
|
+
for (const [key, value] of new URLSearchParams(query)) if (key === "q" || key.startsWith("filter[")) params.append(key, value);
|
|
133
|
+
params.set("unit", unit);
|
|
134
|
+
const response = await laravel(`${resource.slug}/_insights?${params}`);
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
const body = (response.body ?? {}) as { error?: string };
|
|
137
|
+
// An app whose Laravel side is from before this existed has no such address.
|
|
138
|
+
const missing = response.status === 404 || response.status === 405;
|
|
139
|
+
return { ok: false, status: response.status, error: missing ? "This app's API has no insights yet. Run: nevela upgrade" : (body.error ?? "Request failed.") };
|
|
140
|
+
}
|
|
141
|
+
return { ok: true, data: response.body as InsightsData };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** What was wrong with one row of several, numbered from 1 among the rows that were sent. */
|
|
145
|
+
export interface RowProblem {
|
|
146
|
+
row: number;
|
|
147
|
+
issues: FieldIssue[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type CreateManyResult = { ok: true; data: { created: number } } | { ok: false; status: number; error: string; rows?: RowProblem[] };
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Several records at once, for the "Add several" grid: `POST /{slug}/_bulk`.
|
|
154
|
+
*
|
|
155
|
+
* Laravel checks every row with the rules it uses for one, and saves them in a single
|
|
156
|
+
* transaction. So the answer is all of them, or none and the rows that were wrong.
|
|
157
|
+
*/
|
|
158
|
+
export async function createManyAction(resourceName: string, rows: Record<string, unknown>[]): Promise<CreateManyResult> {
|
|
159
|
+
const denied = await allowed(resourceName, "create");
|
|
160
|
+
if (denied) return denied;
|
|
161
|
+
if (rows.length === 0) return { ok: false, status: 400, error: "Nothing to create: every row is empty." };
|
|
162
|
+
|
|
163
|
+
const { resource } = dashboardStore(resourceName);
|
|
164
|
+
const response = await laravel(`${resource.slug}/_bulk`, { method: "POST", body: { items: rows } });
|
|
165
|
+
const body = (response.body ?? {}) as { error?: string; rows?: RowProblem[]; created?: number; data?: { id?: unknown }[] };
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
status: response.status,
|
|
170
|
+
// An app whose Laravel side is from before this existed answers 404 or 405.
|
|
171
|
+
error: response.status === 404 || response.status === 405 ? "This app's API can't create several at once yet. Run: nevela upgrade" : (body.error ?? "Request failed."),
|
|
172
|
+
...(body.rows ? { rows: body.rows } : {}),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
revalidateResource({ resource: resource.name, action: "create", id: String(body.data?.[0]?.id ?? "") });
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.error(`[nevela] ${resource.name} bulk create cache invalidation failed:`, error);
|
|
180
|
+
}
|
|
181
|
+
revalidatePath(resourcePath(resource));
|
|
182
|
+
return { ok: true, data: { created: body.created ?? rows.length } };
|
|
183
|
+
}
|
|
184
|
+
|
|
106
185
|
export async function updateRecordAction(resourceName: string, id: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
|
|
107
186
|
const denied = await allowed(resourceName, "update");
|
|
108
187
|
if (denied) return denied;
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useMemo, useRef, useState, useTransition } from "react";
|
|
5
|
+
import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type ClientResource, type StoredField } from "@flaredev/core";
|
|
6
|
+
import { ListPlusIcon, PlusIcon, XIcon } from "lucide-react";
|
|
7
|
+
import { toast } from "sonner";
|
|
8
|
+
import { createManyAction } from "@/app/dashboard/actions";
|
|
9
|
+
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
12
|
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
13
|
+
import { Input } from "@/components/ui/input";
|
|
14
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
15
|
+
import { cn } from "@/lib/utils";
|
|
16
|
+
import { FieldWidget, type RelationMeta } from "./fields/field-widget";
|
|
17
|
+
|
|
18
|
+
type FieldDef = StoredField & { label: string };
|
|
19
|
+
type Values = Record<string, string | boolean>;
|
|
20
|
+
type Relations = Record<string, RelationMeta & { initialTitle?: string }>;
|
|
21
|
+
|
|
22
|
+
interface Row {
|
|
23
|
+
/** Stays with the row as others are added and removed around it. */
|
|
24
|
+
key: number;
|
|
25
|
+
values: Values;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Enough to see that more can be added, and few enough not to look like a form to fill
|
|
30
|
+
* in. An empty grid gives nothing to type into; one row means clicking "Add row" before
|
|
31
|
+
* you have begun.
|
|
32
|
+
*/
|
|
33
|
+
const STARTING_ROWS = 5;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where the grid stops adding rows: what Laravel accepts in one request unless an app
|
|
37
|
+
* changes it (config/nevela.php, bulk_max). An app that sets it lower is still safe:
|
|
38
|
+
* Laravel refuses the larger request, and its message, which says the limit, is shown.
|
|
39
|
+
*/
|
|
40
|
+
const MOST_ROWS = 500;
|
|
41
|
+
|
|
42
|
+
/** Kinds that fit in a cell: one control, one value. A file needs its own upload, so it is left to the form. */
|
|
43
|
+
const IN_A_CELL = new Set(["string", "text", "int", "float", "boolean", "date", "datetime", "enum", "belongsTo"]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Add several records at once, in a grid.
|
|
47
|
+
*
|
|
48
|
+
* Between "New" and "Import". Import is for a file somebody already has, and New is for
|
|
49
|
+
* one record. This is for the twelve you have in your head or on a piece of paper, which
|
|
50
|
+
* until now meant opening the form twelve times.
|
|
51
|
+
*
|
|
52
|
+
* Rows left empty are ignored, so nobody has to delete the ones they didn't use. The rows
|
|
53
|
+
* that were filled in are saved together or not at all: Laravel checks every one first,
|
|
54
|
+
* and a mistake comes back against its row with nothing created.
|
|
55
|
+
*/
|
|
56
|
+
export function BulkCreateButton({ resource, relations = {} }: { resource: ClientResource; relations?: Relations }) {
|
|
57
|
+
const [open, setOpen] = useState(false);
|
|
58
|
+
const plural = resource.pluralLabel.toLowerCase();
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
62
|
+
<DialogTrigger asChild>
|
|
63
|
+
<Button variant="outline">
|
|
64
|
+
<ListPlusIcon data-icon="inline-start" />
|
|
65
|
+
Add several
|
|
66
|
+
</Button>
|
|
67
|
+
</DialogTrigger>
|
|
68
|
+
<DialogContent className="flex max-h-[90vh] flex-col gap-4 sm:max-w-[min(96vw,84rem)]">
|
|
69
|
+
<DialogHeader>
|
|
70
|
+
<DialogTitle>Add several {plural}</DialogTitle>
|
|
71
|
+
<DialogDescription>One row for each. Rows you leave empty are ignored, and they are all saved together or not at all.</DialogDescription>
|
|
72
|
+
</DialogHeader>
|
|
73
|
+
{/* Mounted only while open, so what was typed and abandoned last time is gone. */}
|
|
74
|
+
{open && <Grid resource={resource} relations={relations} onDone={() => setOpen(false)} />}
|
|
75
|
+
</DialogContent>
|
|
76
|
+
</Dialog>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function Grid({ resource, relations, onDone }: { resource: ClientResource; relations: Relations; onDone: () => void }) {
|
|
81
|
+
const router = useRouter();
|
|
82
|
+
const [pending, startTransition] = useTransition();
|
|
83
|
+
const validators = useMemo(() => createValidators(resource), [resource]);
|
|
84
|
+
const all = useMemo(() => storedFields(resource) as [string, FieldDef][], [resource]);
|
|
85
|
+
const fields = useMemo(() => all.filter(([, def]) => IN_A_CELL.has(def.kind)), [all]);
|
|
86
|
+
// A field this grid can't show and Laravel requires would fail every row, with nothing
|
|
87
|
+
// on screen to fix. Say so, and leave those records to the form.
|
|
88
|
+
const missing = useMemo(() => all.filter(([, def]) => !IN_A_CELL.has(def.kind) && def.required !== false), [all]);
|
|
89
|
+
|
|
90
|
+
const blank = useMemo(() => initialFormValues(resource, null) as Values, [resource]);
|
|
91
|
+
const nextKey = useRef(STARTING_ROWS);
|
|
92
|
+
const [rows, setRows] = useState<Row[]>(() => Array.from({ length: STARTING_ROWS }, (_, key) => ({ key, values: { ...blank } })));
|
|
93
|
+
/** Problems by row key, then by field. "" holds what isn't about one field. */
|
|
94
|
+
const [problems, setProblems] = useState<Record<number, Record<string, string>>>({});
|
|
95
|
+
const [error, setError] = useState<string | null>(null);
|
|
96
|
+
|
|
97
|
+
// A row is empty when it is exactly as it started: a tick box left unticked is not an entry.
|
|
98
|
+
const isEmpty = (values: Values) => fields.every(([key]) => values[key] === blank[key]);
|
|
99
|
+
const filled = rows.filter((row) => !isEmpty(row.values));
|
|
100
|
+
const noun = filled.length === 1 ? resource.label.toLowerCase() : resource.pluralLabel.toLowerCase();
|
|
101
|
+
|
|
102
|
+
function change(rowKey: number, field: string, value: string | boolean) {
|
|
103
|
+
setRows((current) => current.map((row) => (row.key === rowKey ? { ...row, values: { ...row.values, [field]: value } } : row)));
|
|
104
|
+
if (problems[rowKey]?.[field]) {
|
|
105
|
+
setProblems(({ [rowKey]: row = {}, ...rest }) => {
|
|
106
|
+
const { [field]: _fixed, ...others } = row;
|
|
107
|
+
return Object.keys(others).length > 0 ? { ...rest, [rowKey]: others } : rest;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function addRows(count: number) {
|
|
113
|
+
setRows((current) => [...current, ...Array.from({ length: Math.min(count, MOST_ROWS - current.length) }, () => ({ key: nextKey.current++, values: { ...blank } }))]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function removeRow(rowKey: number) {
|
|
117
|
+
setRows((current) => (current.length > 1 ? current.filter((row) => row.key !== rowKey) : [{ key: nextKey.current++, values: { ...blank } }]));
|
|
118
|
+
setProblems(({ [rowKey]: _gone, ...rest }) => rest);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function save() {
|
|
122
|
+
setError(null);
|
|
123
|
+
if (filled.length === 0) {
|
|
124
|
+
setError("Nothing to create yet: every row is empty.");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Checked here first with the descriptor's own rules, so most mistakes never leave the page.
|
|
128
|
+
const found: Record<number, Record<string, string>> = {};
|
|
129
|
+
const inputs = filled.map((row) => {
|
|
130
|
+
const input = formValuesToInput(resource, row.values, "create") as Record<string, unknown>;
|
|
131
|
+
const parsed = validators.create.safeParse(input);
|
|
132
|
+
if (!parsed.success) {
|
|
133
|
+
const visible = Object.fromEntries(Object.entries(issuesByField(parsed.error.issues)).filter(([key]) => fields.some(([field]) => field === key)));
|
|
134
|
+
if (Object.keys(visible).length > 0) found[row.key] = visible;
|
|
135
|
+
}
|
|
136
|
+
// Only what the grid shows is sent; a field it can't show is left for Laravel to judge.
|
|
137
|
+
return Object.fromEntries(Object.entries(input).filter(([key]) => fields.some(([field]) => field === key)));
|
|
138
|
+
});
|
|
139
|
+
if (Object.keys(found).length > 0) {
|
|
140
|
+
setProblems(found);
|
|
141
|
+
setError(Object.keys(found).length === 1 ? "One row needs fixing." : `${Object.keys(found).length} rows need fixing.`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
startTransition(async () => {
|
|
146
|
+
const result = await createManyAction(resource.name, inputs);
|
|
147
|
+
if (result.ok) {
|
|
148
|
+
toast.success(`${result.data.created} ${result.data.created === 1 ? resource.label.toLowerCase() : resource.pluralLabel.toLowerCase()} created.`);
|
|
149
|
+
onDone();
|
|
150
|
+
router.refresh();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// Laravel numbers the rows it was sent, which are only the ones filled in. Here a
|
|
154
|
+
// row is called by its number on screen, so every number it mentions is translated.
|
|
155
|
+
const onScreen = (sent: number) => {
|
|
156
|
+
const row = filled[sent - 1];
|
|
157
|
+
return row ? rows.findIndex((candidate) => candidate.key === row.key) + 1 : sent;
|
|
158
|
+
};
|
|
159
|
+
const fromServer: Record<number, Record<string, string>> = {};
|
|
160
|
+
const numbers: number[] = [];
|
|
161
|
+
for (const problem of result.rows ?? []) {
|
|
162
|
+
const row = filled[problem.row - 1];
|
|
163
|
+
if (!row) continue;
|
|
164
|
+
numbers.push(onScreen(problem.row));
|
|
165
|
+
fromServer[row.key] = {};
|
|
166
|
+
for (const issue of problem.issues) {
|
|
167
|
+
const field = issue.path.split(".")[0] ?? "";
|
|
168
|
+
const known = fields.some(([key]) => key === field);
|
|
169
|
+
const label = all.find(([key]) => key === field)?.[1].label;
|
|
170
|
+
const message = issue.message.replace(/\brow (\d+)\b/g, (_, sent: string) => `row ${onScreen(Number(sent))}`);
|
|
171
|
+
// A problem with a field that has no column here is said beside the row instead.
|
|
172
|
+
fromServer[row.key]![known ? field : ""] ??= known || !label ? message : `${label}: ${message}`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
setProblems(fromServer);
|
|
176
|
+
setError(numbers.length === 0 ? result.error : numbers.length === 1 ? `Nothing was created: row ${numbers[0]} needs fixing.` : `Nothing was created: ${numbers.length} rows need fixing.`);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<>
|
|
182
|
+
{missing.length > 0 && (
|
|
183
|
+
<Alert>
|
|
184
|
+
<AlertDescription>
|
|
185
|
+
{missing.map(([, def]) => def.label).join(", ")} {missing.length === 1 ? "is" : "are"} required and can't be filled in here, so these rows would be
|
|
186
|
+
refused. Use New {resource.label.toLowerCase()} for {resource.pluralLabel.toLowerCase()} instead.
|
|
187
|
+
</AlertDescription>
|
|
188
|
+
</Alert>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
|
|
192
|
+
<table className="w-full border-collapse text-sm">
|
|
193
|
+
<thead className="sticky top-0 z-10 bg-muted text-left text-muted-foreground">
|
|
194
|
+
<tr>
|
|
195
|
+
<th scope="col" className="w-10 px-2 py-2 text-center font-medium">
|
|
196
|
+
#
|
|
197
|
+
</th>
|
|
198
|
+
{fields.map(([key, def]) => (
|
|
199
|
+
<th key={key} scope="col" className={cn("px-2 py-2 font-medium whitespace-nowrap", def.kind === "boolean" ? "w-24 text-center" : "min-w-44")}>
|
|
200
|
+
{def.label}
|
|
201
|
+
{def.required !== false && def.kind !== "boolean" && (
|
|
202
|
+
<span className="text-destructive" aria-label="required">
|
|
203
|
+
{" "}
|
|
204
|
+
*
|
|
205
|
+
</span>
|
|
206
|
+
)}
|
|
207
|
+
</th>
|
|
208
|
+
))}
|
|
209
|
+
<th scope="col" className="w-10">
|
|
210
|
+
<span className="sr-only">Remove</span>
|
|
211
|
+
</th>
|
|
212
|
+
</tr>
|
|
213
|
+
</thead>
|
|
214
|
+
<tbody>
|
|
215
|
+
{rows.map((row, index) => {
|
|
216
|
+
const wrong = problems[row.key] ?? {};
|
|
217
|
+
const messages = Object.entries(wrong).map(([field, message]) => {
|
|
218
|
+
const label = fields.find(([key]) => key === field)?.[1].label;
|
|
219
|
+
return label ? `${label}: ${message}` : message;
|
|
220
|
+
});
|
|
221
|
+
return (
|
|
222
|
+
<tr key={row.key} className="border-t align-top">
|
|
223
|
+
<td className="px-2 py-3 text-center text-muted-foreground tabular-nums">{index + 1}</td>
|
|
224
|
+
{fields.map(([key, def]) => (
|
|
225
|
+
<td key={key} className={cn("px-1.5 py-1.5", def.kind === "boolean" && "text-center")}>
|
|
226
|
+
<Cell
|
|
227
|
+
id={`bulk-${row.key}-${key}`}
|
|
228
|
+
resourceName={resource.name}
|
|
229
|
+
fieldKey={key}
|
|
230
|
+
field={def}
|
|
231
|
+
label={`${def.label}, row ${index + 1}`}
|
|
232
|
+
value={row.values[key] ?? ""}
|
|
233
|
+
invalid={Boolean(wrong[key])}
|
|
234
|
+
disabled={pending}
|
|
235
|
+
relation={relations[key]}
|
|
236
|
+
onChange={(value) => change(row.key, key, value)}
|
|
237
|
+
/>
|
|
238
|
+
{/* Said under the cell it is about. One with no cell of its own goes under the first. */}
|
|
239
|
+
{key === fields[0]?.[0] && messages.length > 0 && (
|
|
240
|
+
<p role="alert" className="mt-1 w-max max-w-[60vw] text-left text-xs text-destructive">
|
|
241
|
+
{messages.join(" · ")}
|
|
242
|
+
</p>
|
|
243
|
+
)}
|
|
244
|
+
</td>
|
|
245
|
+
))}
|
|
246
|
+
<td className="px-1 py-1.5">
|
|
247
|
+
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Remove row ${index + 1}`} disabled={pending} onClick={() => removeRow(row.key)}>
|
|
248
|
+
<XIcon />
|
|
249
|
+
</Button>
|
|
250
|
+
</td>
|
|
251
|
+
</tr>
|
|
252
|
+
);
|
|
253
|
+
})}
|
|
254
|
+
</tbody>
|
|
255
|
+
</table>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
259
|
+
<Button type="button" variant="outline" size="sm" disabled={pending || rows.length >= MOST_ROWS} onClick={() => addRows(1)}>
|
|
260
|
+
<PlusIcon data-icon="inline-start" />
|
|
261
|
+
Add row
|
|
262
|
+
</Button>
|
|
263
|
+
<Button type="button" variant="ghost" size="sm" disabled={pending || rows.length >= MOST_ROWS} onClick={() => addRows(5)}>
|
|
264
|
+
Add 5 rows
|
|
265
|
+
</Button>
|
|
266
|
+
{error && (
|
|
267
|
+
<p role="alert" className="text-sm text-destructive">
|
|
268
|
+
{error}
|
|
269
|
+
</p>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<DialogFooter>
|
|
274
|
+
<Button type="button" variant="ghost" disabled={pending} onClick={onDone}>
|
|
275
|
+
Cancel
|
|
276
|
+
</Button>
|
|
277
|
+
<Button type="button" disabled={pending || filled.length === 0 || missing.length > 0} onClick={save}>
|
|
278
|
+
{pending && <Spinner />}
|
|
279
|
+
{filled.length === 0 ? `Create ${resource.pluralLabel.toLowerCase()}` : `Create ${filled.length} ${noun}`}
|
|
280
|
+
</Button>
|
|
281
|
+
</DialogFooter>
|
|
282
|
+
</>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* One cell. Long text is a single line here and a tick box is a tick box: a grid is for
|
|
288
|
+
* typing across, and a four-line text area or a labelled switch in every row is neither
|
|
289
|
+
* quick nor readable. Everything else is the form's own control, so a date, a price or a
|
|
290
|
+
* link to another record behaves exactly as it does there.
|
|
291
|
+
*/
|
|
292
|
+
function Cell({
|
|
293
|
+
id,
|
|
294
|
+
resourceName,
|
|
295
|
+
fieldKey,
|
|
296
|
+
field,
|
|
297
|
+
label,
|
|
298
|
+
value,
|
|
299
|
+
invalid,
|
|
300
|
+
disabled,
|
|
301
|
+
relation,
|
|
302
|
+
onChange,
|
|
303
|
+
}: {
|
|
304
|
+
id: string;
|
|
305
|
+
resourceName: string;
|
|
306
|
+
fieldKey: string;
|
|
307
|
+
field: FieldDef;
|
|
308
|
+
label: string;
|
|
309
|
+
value: string | boolean;
|
|
310
|
+
invalid: boolean;
|
|
311
|
+
disabled: boolean;
|
|
312
|
+
relation?: RelationMeta & { initialTitle?: string };
|
|
313
|
+
onChange: (value: string | boolean) => void;
|
|
314
|
+
}) {
|
|
315
|
+
if (field.kind === "boolean") {
|
|
316
|
+
return <Checkbox id={id} aria-label={label} checked={value === true} disabled={disabled} onCheckedChange={(checked) => onChange(checked === true)} className="mt-2" />;
|
|
317
|
+
}
|
|
318
|
+
if (field.kind === "text") {
|
|
319
|
+
return <Input id={id} aria-label={label} value={typeof value === "string" ? value : ""} disabled={disabled} aria-invalid={invalid || undefined} onChange={(event) => onChange(event.target.value)} />;
|
|
320
|
+
}
|
|
321
|
+
return (
|
|
322
|
+
<div aria-label={label} role="group">
|
|
323
|
+
<FieldWidget id={id} name={id} resourceName={resourceName} fieldKey={fieldKey} field={field} value={value} onChange={onChange} invalid={invalid} disabled={disabled} relation={relation} />
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSearchParams } from "next/navigation";
|
|
4
|
+
import { useEffect, useId, useMemo, useState } from "react";
|
|
5
|
+
import { optionLabel, type ClientResource } from "@flaredev/core";
|
|
6
|
+
import { BarChart3Icon, ChevronDownIcon } from "lucide-react";
|
|
7
|
+
import { resourceInsightsAction, type InsightsData, type InsightsUnit } from "@/app/dashboard/actions";
|
|
8
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
9
|
+
import { cn } from "@/lib/utils";
|
|
10
|
+
|
|
11
|
+
const UNITS: { key: InsightsUnit; label: string; per: string; span: string }[] = [
|
|
12
|
+
{ key: "day", label: "Daily", per: "day", span: "the last 30 days" },
|
|
13
|
+
{ key: "week", label: "Weekly", per: "week", span: "the last 26 weeks" },
|
|
14
|
+
{ key: "month", label: "Monthly", per: "month", span: "the last 12 months" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/** A period as someone would say it: "9 Oct", "the week of 5 Oct", "Oct 2026". */
|
|
18
|
+
function periodLabel(unit: InsightsUnit, bucket: string, long = false): string {
|
|
19
|
+
// UTC throughout: a bucket is a calendar day or month, not a moment, and must not slide a day in another timezone.
|
|
20
|
+
const date = new Date(`${unit === "month" ? `${bucket}-01` : bucket}T00:00:00Z`);
|
|
21
|
+
if (unit === "month") return new Intl.DateTimeFormat("en-GB", { month: "short", year: "numeric", timeZone: "UTC" }).format(date);
|
|
22
|
+
const day = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", ...(long ? { year: "numeric" } : {}), timeZone: "UTC" }).format(date);
|
|
23
|
+
return unit === "week" && long ? `the week of ${day}` : day;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Charts above a list, closed until somebody wants them.
|
|
28
|
+
*
|
|
29
|
+
* Closed is the default on purpose. The panel's numbers are a grouped query each over
|
|
30
|
+
* everything the list matches, which is work an ordinary visit to a list doesn't need.
|
|
31
|
+
* So nothing is asked for until it is opened, and whether it was left open is remembered
|
|
32
|
+
* for the next visit.
|
|
33
|
+
*
|
|
34
|
+
* What it charts is the rows the table is showing: the same search and filters go on the
|
|
35
|
+
* request, so narrowing the list to one category redraws the charts for that category.
|
|
36
|
+
*
|
|
37
|
+
* Drawn with plain elements and no chart library: bars are all these are, and a library
|
|
38
|
+
* for bars would be several hundred kilobytes to draw rectangles.
|
|
39
|
+
*/
|
|
40
|
+
export function ResourceInsights({ resource, total }: { resource: ClientResource; /** The list's own count: when it changes, so have the charts. */ total: number }) {
|
|
41
|
+
const panel = useId();
|
|
42
|
+
const remembered = `nevela.insights.${resource.name}`;
|
|
43
|
+
const [open, setOpen] = useState(false);
|
|
44
|
+
const [unit, setUnit] = useState<InsightsUnit>("day");
|
|
45
|
+
const [data, setData] = useState<InsightsData | null>(null);
|
|
46
|
+
const [loading, setLoading] = useState(false);
|
|
47
|
+
const [error, setError] = useState<string | null>(null);
|
|
48
|
+
|
|
49
|
+
// Only what narrows the list. A page number or a sort order changes no count.
|
|
50
|
+
const params = useSearchParams();
|
|
51
|
+
const narrowed = useMemo(() => {
|
|
52
|
+
const kept = new URLSearchParams();
|
|
53
|
+
for (const [key, value] of params) if (key === "q" || key.startsWith("filter[")) kept.append(key, value);
|
|
54
|
+
kept.sort();
|
|
55
|
+
return kept.toString();
|
|
56
|
+
}, [params]);
|
|
57
|
+
|
|
58
|
+
// Read after the first paint: the server can't know what this browser remembers.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
try {
|
|
61
|
+
if (window.localStorage.getItem(remembered) === "open") setOpen(true);
|
|
62
|
+
} catch {
|
|
63
|
+
// No storage (a private window, or it is switched off): it just starts closed.
|
|
64
|
+
}
|
|
65
|
+
}, [remembered]);
|
|
66
|
+
|
|
67
|
+
function toggle() {
|
|
68
|
+
const next = !open;
|
|
69
|
+
setOpen(next);
|
|
70
|
+
try {
|
|
71
|
+
window.localStorage.setItem(remembered, next ? "open" : "closed");
|
|
72
|
+
} catch {
|
|
73
|
+
// Not remembered, and that is all.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!open) return;
|
|
79
|
+
let current = true;
|
|
80
|
+
setLoading(true);
|
|
81
|
+
void resourceInsightsAction(resource.name, narrowed, unit).then((result) => {
|
|
82
|
+
if (!current) return;
|
|
83
|
+
setLoading(false);
|
|
84
|
+
if (result.ok) {
|
|
85
|
+
setData(result.data);
|
|
86
|
+
setError(null);
|
|
87
|
+
} else {
|
|
88
|
+
setError(result.error);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return () => {
|
|
92
|
+
current = false;
|
|
93
|
+
};
|
|
94
|
+
}, [open, unit, narrowed, total, resource.name]);
|
|
95
|
+
|
|
96
|
+
const chosen = UNITS.find((entry) => entry.key === unit)!;
|
|
97
|
+
const plural = resource.pluralLabel.toLowerCase();
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<section className="rounded-xl border bg-card">
|
|
101
|
+
<button type="button" onClick={toggle} aria-expanded={open} aria-controls={panel} className="flex w-full items-center gap-2 rounded-xl px-4 py-3 text-left text-sm font-medium hover:bg-muted/50">
|
|
102
|
+
<BarChart3Icon className="size-4 text-muted-foreground" />
|
|
103
|
+
Insights
|
|
104
|
+
<span className="truncate font-normal text-muted-foreground">{narrowed ? `for the ${plural} this list is showing` : `when ${plural} were created, and how they split`}</span>
|
|
105
|
+
{loading && <Spinner className="ml-1" />}
|
|
106
|
+
<ChevronDownIcon className={cn("ml-auto size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
|
|
107
|
+
</button>
|
|
108
|
+
|
|
109
|
+
{open && (
|
|
110
|
+
<div id={panel} className="border-t p-4">
|
|
111
|
+
{error && !data && (
|
|
112
|
+
<p role="alert" className="text-sm text-destructive">
|
|
113
|
+
{error}
|
|
114
|
+
</p>
|
|
115
|
+
)}
|
|
116
|
+
{!data && !error && <p className="text-sm text-muted-foreground">Counting…</p>}
|
|
117
|
+
{data && (
|
|
118
|
+
<div className={cn("grid gap-x-10 gap-y-8", data.breakdown.length > 0 && "lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]", loading && "opacity-60")}>
|
|
119
|
+
<div className="flex min-w-0 flex-col gap-3">
|
|
120
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
121
|
+
<p className="text-sm">
|
|
122
|
+
<span className="font-medium">Created per {chosen.per}</span> <span className="text-muted-foreground">· {chosen.span}</span>
|
|
123
|
+
</p>
|
|
124
|
+
<div role="group" aria-label="Period" className="flex rounded-md border p-0.5 text-xs">
|
|
125
|
+
{UNITS.map((entry) => (
|
|
126
|
+
<button
|
|
127
|
+
key={entry.key}
|
|
128
|
+
type="button"
|
|
129
|
+
aria-pressed={entry.key === unit}
|
|
130
|
+
onClick={() => setUnit(entry.key)}
|
|
131
|
+
className={cn("rounded px-2 py-1", entry.key === unit ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}
|
|
132
|
+
>
|
|
133
|
+
{entry.label}
|
|
134
|
+
</button>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
<Series unit={data.unit} series={data.series} noun={plural} />
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{data.breakdown.length > 0 && (
|
|
142
|
+
<div className="flex min-w-0 flex-col gap-6">
|
|
143
|
+
{data.breakdown.map((group) => (
|
|
144
|
+
<Breakdown key={group.field} resource={resource} group={group} />
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
</section>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function Series({ unit, series, noun }: { unit: InsightsUnit; series: InsightsData["series"]; noun: string }) {
|
|
157
|
+
const most = Math.max(0, ...series.map((point) => point.count));
|
|
158
|
+
const sum = series.reduce((all, point) => all + point.count, 0);
|
|
159
|
+
const peak = series.find((point) => point.count === most);
|
|
160
|
+
const middle = series[Math.floor(series.length / 2)];
|
|
161
|
+
const summary =
|
|
162
|
+
sum === 0 || !peak
|
|
163
|
+
? `No ${noun} were created in this time.`
|
|
164
|
+
: `${sum.toLocaleString()} created in this time. The most was ${most.toLocaleString()}, ${unit === "day" ? "on" : "in"} ${periodLabel(unit, peak.bucket, true)}.`;
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<figure className="flex flex-col gap-2">
|
|
168
|
+
<div role="img" aria-label={summary} className="flex h-40 items-end gap-0.5 border-b pb-px">
|
|
169
|
+
{series.map((point) => (
|
|
170
|
+
<div key={point.bucket} title={`${point.count.toLocaleString()} · ${periodLabel(unit, point.bucket, true)}`} className="group flex h-full min-w-0 flex-1 items-end">
|
|
171
|
+
<div
|
|
172
|
+
// A count of one in a tall chart still has to be seen: never thinner than a hairline.
|
|
173
|
+
style={{ height: most > 0 && point.count > 0 ? `max(${(point.count / most) * 100}%, 3px)` : "1px" }}
|
|
174
|
+
className={cn("w-full rounded-t-sm", point.count > 0 ? "bg-primary/75 group-hover:bg-primary" : "bg-border")}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
<div className="flex justify-between text-xs text-muted-foreground tabular-nums" aria-hidden="true">
|
|
180
|
+
<span>{series[0] && periodLabel(unit, series[0].bucket)}</span>
|
|
181
|
+
<span>{middle && periodLabel(unit, middle.bucket)}</span>
|
|
182
|
+
<span>{series.at(-1) && periodLabel(unit, series.at(-1)!.bucket)}</span>
|
|
183
|
+
</div>
|
|
184
|
+
<figcaption className="text-xs text-muted-foreground">{summary}</figcaption>
|
|
185
|
+
</figure>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function Breakdown({ resource, group }: { resource: ClientResource; group: InsightsData["breakdown"][number] }) {
|
|
190
|
+
const def = resource.fields[group.field];
|
|
191
|
+
const sum = group.slices.reduce((all, slice) => all + slice.count, 0);
|
|
192
|
+
const name = (value: string) => {
|
|
193
|
+
if (value === "") return "Not set";
|
|
194
|
+
if (group.kind === "boolean") return value === "true" ? "Yes" : "No";
|
|
195
|
+
return def && def.kind === "enum" ? optionLabel(def, value) : value;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<div className="flex flex-col gap-2">
|
|
200
|
+
<p className="text-sm font-medium">{def && "label" in def && def.label ? String(def.label) : group.field}</p>
|
|
201
|
+
<ul className="flex flex-col gap-1.5">
|
|
202
|
+
{group.slices.map((slice) => {
|
|
203
|
+
const share = sum > 0 ? (slice.count / sum) * 100 : 0;
|
|
204
|
+
return (
|
|
205
|
+
<li key={slice.value} className="grid grid-cols-[minmax(4rem,7rem)_1fr_auto] items-center gap-3 text-sm">
|
|
206
|
+
<span className="truncate text-muted-foreground">{name(slice.value)}</span>
|
|
207
|
+
<span className="h-2 overflow-hidden rounded-full bg-muted" aria-hidden="true">
|
|
208
|
+
<span className="block h-full rounded-full bg-primary/75" style={{ width: `${share}%` }} />
|
|
209
|
+
</span>
|
|
210
|
+
<span className="tabular-nums">
|
|
211
|
+
{slice.count.toLocaleString()} <span className="text-muted-foreground">· {Math.round(share)}%</span>
|
|
212
|
+
</span>
|
|
213
|
+
</li>
|
|
214
|
+
);
|
|
215
|
+
})}
|
|
216
|
+
</ul>
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
@@ -24,6 +24,8 @@ import { ExportButton } from "./export-button";
|
|
|
24
24
|
import { isInlineEditable } from "./editable";
|
|
25
25
|
import { InlineCell } from "./inline-cell";
|
|
26
26
|
import type { RelationMeta } from "./fields/field-widget";
|
|
27
|
+
import { BulkCreateButton } from "./bulk-create-grid";
|
|
28
|
+
import { ResourceInsights } from "./resource-insights";
|
|
27
29
|
import { ImportDialog } from "./import-dialog";
|
|
28
30
|
import { LocalTime } from "./local-time";
|
|
29
31
|
import { hrefWith, toSearchParams, type SearchParams } from "./query";
|
|
@@ -104,12 +106,14 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
104
106
|
return (
|
|
105
107
|
<TableSelection ids={rowIds}>
|
|
106
108
|
<div className="flex flex-col gap-4">
|
|
109
|
+
<ResourceInsights resource={forClient} total={meta.total} />
|
|
107
110
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
108
111
|
<ResourceTableToolbar resource={forClient} />
|
|
109
112
|
<div className="flex flex-wrap items-center gap-2">
|
|
110
113
|
<ColumnMenu columns={allColumns.map(({ key, label }) => ({ key, label }))} visible={columns.map((column) => column.key)} />
|
|
111
114
|
<ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
|
|
112
115
|
{permissions.create && <ImportDialog resource={forClient} />}
|
|
116
|
+
{permissions.create && <BulkCreateButton resource={forClient} relations={relations} />}
|
|
113
117
|
{permissions.create &&
|
|
114
118
|
(overlayForms ? (
|
|
115
119
|
<NewRecordButton resource={forClient} relations={relations} listHref={basePath} />
|