create-nevela 0.5.1 → 0.5.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/index.mjs +32 -2
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +16 -0
- package/template/laravel/src/Concerns/Trashable.php +63 -0
- package/template/laravel/src/Console/GenerateCommand.php +43 -0
- package/template/laravel/src/Console/TrashCommand.php +33 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +84 -1
- package/template/laravel/src/Http/ResourceRequest.php +108 -0
- package/template/laravel/src/Http/TrashController.php +190 -0
- package/template/laravel/src/Nevela.php +162 -15
- package/template/laravel/src/NevelaServiceProvider.php +19 -0
- package/template/laravel/src/Support/Insights.php +78 -0
- package/template/laravel/src/Trash/Trash.php +151 -0
- package/template/laravel/src/Trash/TrashedScope.php +21 -0
- package/template/web/app/dashboard/actions.ts +79 -0
- package/template/web/app/dashboard/layout.tsx +4 -2
- package/template/web/app/dashboard/trash/actions.ts +78 -0
- package/template/web/app/dashboard/trash/page.tsx +167 -0
- package/template/web/components/dashboard/bulk-create-grid.tsx +326 -0
- package/template/web/components/dashboard/dashboard-sidebar.tsx +5 -1
- package/template/web/components/dashboard/record-actions.tsx +14 -5
- package/template/web/components/dashboard/record-detail.tsx +3 -0
- package/template/web/components/dashboard/resource-insights.tsx +219 -0
- package/template/web/components/dashboard/resource-table.tsx +10 -0
- package/template/web/components/dashboard/row-actions.tsx +12 -7
- package/template/web/components/dashboard/table-selection.tsx +27 -7
- package/template/web/components/dashboard/trash-actions.tsx +178 -0
- package/template/web/lib/trash-info.ts +13 -0
- package/template/web/lib/trash.ts +66 -0
- package/template/web/package.json +1 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Http;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Http\JsonResponse;
|
|
6
|
+
use Illuminate\Http\Request;
|
|
7
|
+
use Illuminate\Http\Response;
|
|
8
|
+
use Nevela\Laravel\Support\Descriptor;
|
|
9
|
+
use Nevela\Laravel\Trash\Trash;
|
|
10
|
+
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
11
|
+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The trash: deleted records, by resource, to restore or to remove for good.
|
|
15
|
+
*
|
|
16
|
+
* One controller for every resource, where everything else about a resource is generated.
|
|
17
|
+
* The trash asks the same three questions of all of them (what was deleted, bring it back,
|
|
18
|
+
* get rid of it), and a generated copy per resource would be the same file many times.
|
|
19
|
+
*
|
|
20
|
+
* Who may use it is the resource's own policy: `restore` and `forceDelete` where it has
|
|
21
|
+
* them, and what it says to `delete` where it was written before there was a trash.
|
|
22
|
+
*/
|
|
23
|
+
final class TrashController
|
|
24
|
+
{
|
|
25
|
+
/**
|
|
26
|
+
* GET _nevela/trash/_status: which resources have a trash this person may use, and how
|
|
27
|
+
* long it keeps things. Nothing is counted and nothing is removed: this is asked on
|
|
28
|
+
* the way to drawing a delete button, to say truthfully what pressing it will do.
|
|
29
|
+
*/
|
|
30
|
+
public function status(): JsonResponse
|
|
31
|
+
{
|
|
32
|
+
$slugs = [];
|
|
33
|
+
foreach (Trash::resources() as ['descriptor' => $descriptor, 'model' => $model]) {
|
|
34
|
+
if (Trash::maySee($model)) {
|
|
35
|
+
$slugs[] = $descriptor->slug;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return response()->json(['days' => Trash::days(), 'resources' => $slugs]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* GET _nevela/trash: how long things are kept, and how much of each resource is in it.
|
|
44
|
+
* Only the resources this person may deal with. Anything past its time goes first.
|
|
45
|
+
*/
|
|
46
|
+
public function index(): JsonResponse
|
|
47
|
+
{
|
|
48
|
+
Trash::purgeExpired();
|
|
49
|
+
|
|
50
|
+
$resources = [];
|
|
51
|
+
foreach (Trash::resources() as ['descriptor' => $descriptor, 'model' => $model]) {
|
|
52
|
+
if (! Trash::maySee($model)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
$resources[] = [
|
|
56
|
+
'name' => $descriptor->name,
|
|
57
|
+
'label' => $descriptor->label,
|
|
58
|
+
'pluralLabel' => $descriptor->pluralLabel,
|
|
59
|
+
'slug' => $descriptor->slug,
|
|
60
|
+
'icon' => $descriptor->icon,
|
|
61
|
+
'count' => $model::onlyTrashed()->count(),
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return response()->json(['days' => Trash::days(), 'resources' => $resources]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** GET _nevela/trash/{slug}?page=&perPage=: one resource's deleted records, the latest deletion first. */
|
|
69
|
+
public function show(Request $request, string $slug): JsonResponse
|
|
70
|
+
{
|
|
71
|
+
['descriptor' => $descriptor, 'model' => $model] = $this->resource($slug);
|
|
72
|
+
$input = $request->validate(['page' => ['nullable', 'integer', 'min:1'], 'perPage' => ['nullable', 'integer', 'min:1', 'max:100']]);
|
|
73
|
+
Trash::purgeExpired($model);
|
|
74
|
+
|
|
75
|
+
$instance = new $model;
|
|
76
|
+
$page = $model::onlyTrashed()
|
|
77
|
+
->orderByDesc($instance->getQualifiedDeletedAtColumn())->orderBy($instance->getQualifiedKeyName())
|
|
78
|
+
->paginate((int) ($input['perPage'] ?? 25), ['*'], 'page', (int) ($input['page'] ?? 1));
|
|
79
|
+
|
|
80
|
+
return response()->json([
|
|
81
|
+
'data' => $page->getCollection()->map(fn ($record) => [
|
|
82
|
+
'id' => (string) $record->getKey(),
|
|
83
|
+
'label' => self::label($descriptor, $record),
|
|
84
|
+
'deletedAt' => $record->{$record->getDeletedAtColumn()}?->toJSON(),
|
|
85
|
+
'expiresAt' => Trash::expires($record->{$record->getDeletedAtColumn()})?->toJSON(),
|
|
86
|
+
])->all(),
|
|
87
|
+
'meta' => ['page' => $page->currentPage(), 'perPage' => $page->perPage(), 'total' => $page->total(), 'totalPages' => max(1, $page->lastPage())],
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** POST _nevela/trash/{slug}/{id}/restore: back into the list, as it was. */
|
|
92
|
+
public function restore(string $slug, string $id): JsonResponse
|
|
93
|
+
{
|
|
94
|
+
['descriptor' => $descriptor, 'model' => $model] = $this->resource($slug);
|
|
95
|
+
$record = $model::onlyTrashed()->find($id) ?? throw new NotFoundHttpException("No deleted {$this->noun($descriptor)} with that id.");
|
|
96
|
+
if (! Trash::may('restore', $record)) {
|
|
97
|
+
throw new AccessDeniedHttpException("You don't have access to this.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A record that must belong to another can't come back while that other is itself deleted.
|
|
101
|
+
foreach ($descriptor->fields as $field) {
|
|
102
|
+
if ($field->kind !== 'belongsTo' || ! $field->required) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
$parent = "App\\Models\\{$field->target}";
|
|
106
|
+
$key = $record->{$field->column()};
|
|
107
|
+
if ($key !== null && class_exists($parent) && method_exists($parent, 'trashes') && $parent::trashes() && $parent::onlyTrashed()->whereKey($key)->exists()) {
|
|
108
|
+
$what = strtolower($field->target);
|
|
109
|
+
|
|
110
|
+
return response()->json(['error' => "Its {$what} is in the trash too. Restore the {$what} first.", 'code' => 'PARENT_IN_TRASH'], 409);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
$record->restore();
|
|
114
|
+
|
|
115
|
+
return response()->json(['id' => (string) $record->getKey(), 'restored' => true]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** DELETE _nevela/trash/{slug}/{id}: gone for good. */
|
|
119
|
+
public function destroy(string $slug, string $id): JsonResponse|Response
|
|
120
|
+
{
|
|
121
|
+
['descriptor' => $descriptor, 'model' => $model] = $this->resource($slug);
|
|
122
|
+
$record = $model::onlyTrashed()->find($id) ?? throw new NotFoundHttpException("No deleted {$this->noun($descriptor)} with that id.");
|
|
123
|
+
if (! Trash::may('forceDelete', $record)) {
|
|
124
|
+
throw new AccessDeniedHttpException("You don't have access to this.");
|
|
125
|
+
}
|
|
126
|
+
if (! Trash::purge($record)) {
|
|
127
|
+
return response()->json(['error' => 'Other records in the trash still belong to this one. Delete those for good first.', 'code' => 'STILL_REFERENCED'], 409);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return response()->noContent();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* DELETE _nevela/trash/{slug}?confirm={slug}: everything of one resource, for good.
|
|
135
|
+
* The slug has to be said again, so that this can't be the result of a mistyped address.
|
|
136
|
+
*/
|
|
137
|
+
public function empty(Request $request, string $slug): JsonResponse
|
|
138
|
+
{
|
|
139
|
+
['model' => $model] = $this->resource($slug);
|
|
140
|
+
if ($request->query('confirm') !== $slug) {
|
|
141
|
+
return response()->json(['error' => "To empty this trash, send confirm={$slug}.", 'code' => 'CONFIRM'], 422);
|
|
142
|
+
}
|
|
143
|
+
$removed = 0;
|
|
144
|
+
$kept = 0;
|
|
145
|
+
$instance = new $model;
|
|
146
|
+
$model::onlyTrashed()->orderBy($instance->getQualifiedKeyName())->chunkById(200, function ($records) use (&$removed, &$kept) {
|
|
147
|
+
foreach ($records as $record) {
|
|
148
|
+
if (Trash::may('forceDelete', $record) && Trash::purge($record)) {
|
|
149
|
+
$removed++;
|
|
150
|
+
} else {
|
|
151
|
+
$kept++;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return response()->json(['removed' => $removed, 'kept' => $kept]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @return array{descriptor: Descriptor, model: class-string<\Illuminate\Database\Eloquent\Model>} */
|
|
160
|
+
private function resource(string $slug): array
|
|
161
|
+
{
|
|
162
|
+
$resource = Trash::resource($slug) ?? throw new NotFoundHttpException('This resource has no trash.');
|
|
163
|
+
if (! Trash::maySee($resource['model'])) {
|
|
164
|
+
throw new AccessDeniedHttpException("You don't have access to this.");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return $resource;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private function noun(Descriptor $descriptor): string
|
|
171
|
+
{
|
|
172
|
+
return strtolower($descriptor->label);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** What to call a deleted record: its title, the way a list would, and its id when it has none. */
|
|
176
|
+
private static function label(Descriptor $descriptor, mixed $record): string
|
|
177
|
+
{
|
|
178
|
+
// The field the descriptor names, or the first piece of text it has, as the dashboard does.
|
|
179
|
+
$field = $descriptor->titleField !== null ? ($descriptor->fields[$descriptor->titleField] ?? null) : null;
|
|
180
|
+
foreach ($field === null ? $descriptor->fields : [] as $candidate) {
|
|
181
|
+
if ($candidate->kind === 'string') {
|
|
182
|
+
$field = $candidate;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
$value = $field ? $record->getAttribute($field->column()) : null;
|
|
187
|
+
|
|
188
|
+
return is_scalar($value) && (string) $value !== '' ? (string) $value : (string) $record->getKey();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -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.3';
|
|
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.
|
|
@@ -4,6 +4,7 @@ namespace Nevela\Laravel;
|
|
|
4
4
|
|
|
5
5
|
use Illuminate\Contracts\Debug\ExceptionHandler;
|
|
6
6
|
use Illuminate\Cache\RateLimiting\Limit;
|
|
7
|
+
use Illuminate\Console\Scheduling\Schedule;
|
|
7
8
|
use Illuminate\Foundation\Console\ServeCommand;
|
|
8
9
|
use Illuminate\Http\Request;
|
|
9
10
|
use Illuminate\Support\Facades\Gate;
|
|
@@ -18,6 +19,7 @@ use Nevela\Laravel\Console\MakeResourceCommand;
|
|
|
18
19
|
use Nevela\Laravel\Console\SeedCommand;
|
|
19
20
|
use Nevela\Laravel\Console\SetupCommand;
|
|
20
21
|
use Nevela\Laravel\Console\StatusCommand;
|
|
22
|
+
use Nevela\Laravel\Console\TrashCommand;
|
|
21
23
|
use Nevela\Laravel\Console\UpdateCommand;
|
|
22
24
|
use Nevela\Laravel\Console\UserCommand;
|
|
23
25
|
use Nevela\Laravel\Console\VersionCommand;
|
|
@@ -29,6 +31,7 @@ use Nevela\Laravel\Http\Auth\AccountController;
|
|
|
29
31
|
use Nevela\Laravel\Http\Auth\SecurityController;
|
|
30
32
|
use Nevela\Laravel\Http\Auth\SignInController;
|
|
31
33
|
use Nevela\Laravel\Http\TokenController;
|
|
34
|
+
use Nevela\Laravel\Http\TrashController;
|
|
32
35
|
use Nevela\Laravel\Http\UploadController;
|
|
33
36
|
use Nevela\Laravel\Media\Uploads;
|
|
34
37
|
|
|
@@ -56,7 +59,15 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
56
59
|
$this->commands([
|
|
57
60
|
MakeResourceCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
|
|
58
61
|
UpdateCommand::class, DevCommand::class, StatusCommand::class, VersionCommand::class, SetupCommand::class,
|
|
62
|
+
TrashCommand::class,
|
|
59
63
|
]);
|
|
64
|
+
|
|
65
|
+
// What has been in the trash too long is removed each night, wherever the app's
|
|
66
|
+
// scheduler is running (Laravel's one cron line). Where it isn't, the same thing
|
|
67
|
+
// happens whenever someone opens the trash.
|
|
68
|
+
$this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
|
|
69
|
+
$schedule->command(TrashCommand::class)->dailyAt('03:40')->withoutOverlapping();
|
|
70
|
+
});
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
$this->callAfterResolving(ExceptionHandler::class, function ($handler) {
|
|
@@ -171,6 +182,14 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
171
182
|
'data' => array_map(fn ($name) => (array) Uploads::profile($name), array_keys((array) config('nevela.uploads.profiles', ['default' => []]))),
|
|
172
183
|
]))->name('profiles');
|
|
173
184
|
|
|
185
|
+
// The trash: deleted records, to restore or to remove for good.
|
|
186
|
+
Route::get('_nevela/trash', [TrashController::class, 'index'])->name('trash.index');
|
|
187
|
+
Route::get('_nevela/trash/_status', [TrashController::class, 'status'])->name('trash.status');
|
|
188
|
+
Route::get('_nevela/trash/{slug}', [TrashController::class, 'show'])->name('trash.show');
|
|
189
|
+
Route::post('_nevela/trash/{slug}/{id}/restore', [TrashController::class, 'restore'])->name('trash.restore');
|
|
190
|
+
Route::delete('_nevela/trash/{slug}/{id}', [TrashController::class, 'destroy'])->name('trash.destroy');
|
|
191
|
+
Route::delete('_nevela/trash/{slug}', [TrashController::class, 'empty'])->name('trash.empty');
|
|
192
|
+
|
|
174
193
|
// Users and roles, for whoever holds the permissions to manage them.
|
|
175
194
|
Route::middleware(RolesAreSetUp::class)->group(function () {
|
|
176
195
|
Route::get('_nevela/users', [UsersController::class, 'index'])->name('users.index');
|
|
@@ -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
|
+
}
|