create-nevela 0.7.1 → 0.9.1
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 +25 -7
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +66 -3
- package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
- package/template/laravel/resources/disposable-domains.txt +9880 -0
- package/template/laravel/src/Access/ClosedAccounts.php +2 -1
- package/template/laravel/src/Access/Permissions.php +36 -5
- package/template/laravel/src/Concerns/Confirmable.php +81 -0
- package/template/laravel/src/Concerns/Trashable.php +23 -0
- package/template/laravel/src/Console/AdoptCommand.php +139 -0
- package/template/laravel/src/Console/BlogCommand.php +254 -0
- package/template/laravel/src/Console/DevCommand.php +9 -0
- package/template/laravel/src/Console/DigestCommand.php +28 -0
- package/template/laravel/src/Console/DisposableCommand.php +49 -0
- package/template/laravel/src/Console/GenerateCommand.php +38 -3
- package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
- package/template/laravel/src/Console/SeedCommand.php +3 -1
- package/template/laravel/src/Console/SetupCommand.php +15 -2
- package/template/laravel/src/Console/StatusCommand.php +14 -0
- package/template/laravel/src/Console/TrashCommand.php +17 -0
- package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
- package/template/laravel/src/Forms/Confirmation.php +88 -0
- package/template/laravel/src/Forms/Disposable.php +74 -0
- package/template/laravel/src/Forms/FormBlocks.php +76 -0
- package/template/laravel/src/Forms/FormGuard.php +359 -0
- package/template/laravel/src/Forms/OwnerDigest.php +63 -0
- package/template/laravel/src/Forms/PublicMail.php +217 -0
- package/template/laravel/src/Forms/Refusal.php +50 -0
- package/template/laravel/src/Generator/BlogPages.php +409 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +356 -32
- package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
- package/template/laravel/src/Http/Access/RolesController.php +2 -1
- package/template/laravel/src/Http/Access/UsersController.php +2 -1
- package/template/laravel/src/Http/ActionController.php +223 -0
- package/template/laravel/src/Http/Auth/AccountController.php +6 -3
- package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
- package/template/laravel/src/Http/FormsController.php +45 -0
- package/template/laravel/src/Http/PublicController.php +294 -0
- package/template/laravel/src/Http/PublicThrottle.php +48 -0
- package/template/laravel/src/Http/ResourceRequest.php +193 -4
- package/template/laravel/src/Http/TrashController.php +19 -2
- package/template/laravel/src/Http/UploadController.php +42 -1
- package/template/laravel/src/Media/Uploads.php +111 -0
- package/template/laravel/src/Nevela.php +83 -2
- package/template/laravel/src/NevelaServiceProvider.php +27 -2
- package/template/laravel/src/Support/Action.php +168 -0
- package/template/laravel/src/Support/Adoption.php +352 -0
- package/template/laravel/src/Support/Descriptor.php +83 -4
- package/template/laravel/src/Support/Fake.php +15 -1
- package/template/laravel/src/Support/Field.php +91 -3
- package/template/laravel/src/Support/Html.php +185 -0
- package/template/laravel/src/Support/Key.php +57 -0
- package/template/laravel/src/Support/ListQuery.php +29 -1
- package/template/laravel/src/Support/PublicAccess.php +208 -0
- package/template/laravel/src/Support/Slug.php +43 -0
- package/template/laravel/src/Trash/Trash.php +3 -1
- package/template/web/.env.example +4 -0
- package/template/web/app/api/[...path]/route.ts +42 -9
- package/template/web/app/confirm/page.tsx +51 -0
- package/template/web/app/dashboard/actions.ts +48 -0
- package/template/web/app/globals.css +46 -0
- package/template/web/components/access/role-form.tsx +40 -5
- package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
- package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
- package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
- package/template/web/components/dashboard/fields/file-field.tsx +9 -5
- package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
- package/template/web/components/dashboard/form-activity.tsx +53 -0
- package/template/web/components/dashboard/record-actions.tsx +33 -4
- package/template/web/components/dashboard/record-detail.tsx +10 -2
- package/template/web/components/dashboard/resource-action.tsx +281 -0
- package/template/web/components/dashboard/resource-form.tsx +19 -2
- package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
- package/template/web/components/dashboard/resource-table.tsx +46 -4
- package/template/web/components/dashboard/row-actions.tsx +56 -2
- package/template/web/components/dashboard/table-selection.tsx +19 -1
- package/template/web/components/marketing/site-header.tsx +9 -2
- package/template/web/components/public-form-guard.tsx +125 -0
- package/template/web/hooks/use-public-form.ts +175 -0
- package/template/web/lib/access.ts +3 -1
- package/template/web/lib/dashboard.ts +10 -0
- package/template/web/lib/extras.ts +105 -0
- package/template/web/lib/files.ts +14 -0
- package/template/web/lib/laravel.ts +60 -0
- package/template/web/lib/rich.ts +79 -0
- package/template/web/package.json +1 -1
|
@@ -12,7 +12,18 @@ use JsonException;
|
|
|
12
12
|
*/
|
|
13
13
|
final class Descriptor
|
|
14
14
|
{
|
|
15
|
-
/**
|
|
15
|
+
/** What a record's id can be: a UUID, which is what Nevela makes, or the auto-incrementing integer of `$table->id()`. */
|
|
16
|
+
public const ID_TYPES = ['uuid', 'int'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param array<string, Field> $fields keyed by field name, in declaration order
|
|
20
|
+
* @param string $idType "uuid" or "int". Chosen when the resource is made: changing it later means migrating the table yourself.
|
|
21
|
+
* @param bool $adopted The table was there before Nevela, so no migration is written to create it
|
|
22
|
+
* @param array<string, Action> $actions What can be done to a record or to the list beyond create, edit and delete, by name
|
|
23
|
+
* @param PublicAccess|null $public What of it anyone may read or create without signing in. Nothing, when null.
|
|
24
|
+
* @param string|null $publish The date-and-time field that says when a record is published: empty is a draft, a time to come is scheduled
|
|
25
|
+
* @param string|null $blog Where on the site its public records are a blog, e.g. "/blog". The pages are written by `nevela:blog`.
|
|
26
|
+
*/
|
|
16
27
|
public function __construct(
|
|
17
28
|
public readonly string $name,
|
|
18
29
|
public readonly array $fields,
|
|
@@ -23,20 +34,59 @@ final class Descriptor
|
|
|
23
34
|
public readonly ?string $icon = null,
|
|
24
35
|
public readonly ?string $group = null,
|
|
25
36
|
public readonly ?string $titleField = null,
|
|
37
|
+
public readonly string $idType = 'uuid',
|
|
38
|
+
public readonly bool $adopted = false,
|
|
39
|
+
public readonly array $actions = [],
|
|
40
|
+
public readonly ?PublicAccess $public = null,
|
|
41
|
+
public readonly ?string $publish = null,
|
|
42
|
+
public readonly ?string $blog = null,
|
|
26
43
|
) {
|
|
27
44
|
if (! preg_match('/^[A-Z][A-Za-z0-9]*$/', $name)) {
|
|
28
45
|
throw new InvalidArgumentException('Resource name must be PascalCase, e.g. Product or BlogPost.');
|
|
29
46
|
}
|
|
47
|
+
if (! in_array($idType, self::ID_TYPES, true)) {
|
|
48
|
+
throw new InvalidArgumentException("\"{$idType}\" is not an id type. Use uuid or int.");
|
|
49
|
+
}
|
|
30
50
|
if ($fields === []) {
|
|
31
51
|
throw new InvalidArgumentException("Resource {$name} needs at least one field.");
|
|
32
52
|
}
|
|
33
53
|
if ($titleField !== null && ! isset($fields[$titleField])) {
|
|
34
54
|
throw new InvalidArgumentException("titleField \"{$titleField}\" is not a field of {$name}.");
|
|
35
55
|
}
|
|
56
|
+
$keys = [];
|
|
57
|
+
foreach ($actions as $action) {
|
|
58
|
+
foreach (array_keys($action->when) as $field) {
|
|
59
|
+
if (! isset($fields[$field])) {
|
|
60
|
+
throw new InvalidArgumentException("Action \"{$action->name}\" of {$name} depends on \"{$field}\", which is not one of its fields.");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// sendNow and send_now would be the same permission.
|
|
64
|
+
if (isset($keys[$action->key()])) {
|
|
65
|
+
throw new InvalidArgumentException("{$name} has two actions that come to \"{$action->key()}\".");
|
|
66
|
+
}
|
|
67
|
+
$keys[$action->key()] = true;
|
|
68
|
+
}
|
|
69
|
+
$public?->check($name, $fields);
|
|
70
|
+
foreach ($fields as $field) {
|
|
71
|
+
if ($field->from !== null && (! isset($fields[$field->from]) || ! in_array($fields[$field->from]->kind, ['string', 'text'], true))) {
|
|
72
|
+
throw new InvalidArgumentException("{$name}: \"{$field->name}\" is made from \"{$field->from}\", which has to be a string field of {$name}.");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if ($publish !== null && (! isset($fields[$publish]) || $fields[$publish]->kind !== 'datetime' || $fields[$publish]->required)) {
|
|
76
|
+
throw new InvalidArgumentException("{$name}: \"publish\" names the field that says when a record is published. It has to be an optional datetime: {$publish}:datetime?");
|
|
77
|
+
}
|
|
78
|
+
if ($blog !== null) {
|
|
79
|
+
if (! preg_match('#^/[a-z0-9][a-z0-9/-]*$#', $blog)) {
|
|
80
|
+
throw new InvalidArgumentException("{$name}: \"blog\" is where the blog is on the site, such as \"/blog\".");
|
|
81
|
+
}
|
|
82
|
+
if ($public?->read === null || $public->by === null) {
|
|
83
|
+
throw new InvalidArgumentException("{$name}: a blog reads its posts publicly, each by a unique field. Give it \"public\": { \"read\": { \"by\": \"slug\", \"fields\": […] } }.");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
36
86
|
}
|
|
37
87
|
|
|
38
88
|
/** Build from a name and a `--fields` string: "name:string, price:money, status:enum(draft|live)?". */
|
|
39
|
-
public static function fromSpec(string $name, string $spec, ?string $icon = null, ?string $group = null): self
|
|
89
|
+
public static function fromSpec(string $name, string $spec, ?string $icon = null, ?string $group = null, string $idType = 'uuid'): self
|
|
40
90
|
{
|
|
41
91
|
$fields = [];
|
|
42
92
|
foreach (array_filter(array_map('trim', self::splitSpec($spec))) as $token) {
|
|
@@ -47,11 +97,11 @@ final class Descriptor
|
|
|
47
97
|
$fields[$field->name] = $field;
|
|
48
98
|
}
|
|
49
99
|
|
|
50
|
-
return self::make(Naming::pascal($name), $fields, icon: $icon, group: $group);
|
|
100
|
+
return self::make(Naming::pascal($name), $fields, icon: $icon, group: $group, idType: $idType);
|
|
51
101
|
}
|
|
52
102
|
|
|
53
103
|
/** @param array<string, Field> $fields */
|
|
54
|
-
public static function make(string $name, array $fields, ?string $icon = null, ?string $group = null, ?string $titleField = null): self
|
|
104
|
+
public static function make(string $name, array $fields, ?string $icon = null, ?string $group = null, ?string $titleField = null, string $idType = 'uuid'): self
|
|
55
105
|
{
|
|
56
106
|
$plural = Naming::plural($name);
|
|
57
107
|
|
|
@@ -65,6 +115,7 @@ final class Descriptor
|
|
|
65
115
|
$icon,
|
|
66
116
|
$group,
|
|
67
117
|
$titleField,
|
|
118
|
+
$idType,
|
|
68
119
|
);
|
|
69
120
|
}
|
|
70
121
|
|
|
@@ -77,6 +128,10 @@ final class Descriptor
|
|
|
77
128
|
$fields[$key] = Field::fromArray($key, (array) $field);
|
|
78
129
|
}
|
|
79
130
|
$base = self::make($name, $fields);
|
|
131
|
+
$actions = [];
|
|
132
|
+
foreach ((array) ($data['actions'] ?? []) as $key => $action) {
|
|
133
|
+
$actions[(string) $key] = Action::fromArray((string) $key, (array) $action);
|
|
134
|
+
}
|
|
80
135
|
|
|
81
136
|
return new self(
|
|
82
137
|
$name,
|
|
@@ -88,6 +143,12 @@ final class Descriptor
|
|
|
88
143
|
$data['icon'] ?? null,
|
|
89
144
|
$data['group'] ?? null,
|
|
90
145
|
$data['titleField'] ?? null,
|
|
146
|
+
(string) ($data['id'] ?? 'uuid'),
|
|
147
|
+
(bool) ($data['adopted'] ?? false),
|
|
148
|
+
$actions,
|
|
149
|
+
isset($data['public']) ? PublicAccess::fromArray((array) $data['public']) : null,
|
|
150
|
+
$data['publish'] ?? null,
|
|
151
|
+
$data['blog'] ?? null,
|
|
91
152
|
);
|
|
92
153
|
}
|
|
93
154
|
|
|
@@ -107,13 +168,20 @@ final class Descriptor
|
|
|
107
168
|
'$schema' => 'https://nevela.dev/schema/resource.v1.json',
|
|
108
169
|
'name' => $this->name,
|
|
109
170
|
'table' => $this->table,
|
|
171
|
+
// Left out when they are what every descriptor written before them means.
|
|
172
|
+
'id' => $this->idType === 'uuid' ? null : $this->idType,
|
|
173
|
+
'adopted' => $this->adopted ?: null,
|
|
110
174
|
'slug' => $this->slug,
|
|
111
175
|
'label' => $this->label,
|
|
112
176
|
'pluralLabel' => $this->pluralLabel,
|
|
113
177
|
'icon' => $this->icon,
|
|
114
178
|
'group' => $this->group,
|
|
115
179
|
'titleField' => $this->titleField,
|
|
180
|
+
'publish' => $this->publish,
|
|
181
|
+
'blog' => $this->blog,
|
|
116
182
|
'fields' => array_map(fn (Field $f) => $f->toArray(), $this->fields),
|
|
183
|
+
'actions' => $this->actions ? array_map(fn (Action $a) => $a->toArray(), $this->actions) : null,
|
|
184
|
+
'public' => $this->public?->toArray(),
|
|
117
185
|
], fn ($v) => $v !== null);
|
|
118
186
|
}
|
|
119
187
|
|
|
@@ -137,6 +205,17 @@ final class Descriptor
|
|
|
137
205
|
return 'id';
|
|
138
206
|
}
|
|
139
207
|
|
|
208
|
+
/**
|
|
209
|
+
* This resource with only some of its fields: what a list of it is sorted, filtered
|
|
210
|
+
* and searched by when the list is the public one.
|
|
211
|
+
*
|
|
212
|
+
* @param list<string> $fields
|
|
213
|
+
*/
|
|
214
|
+
public function only(array $fields): self
|
|
215
|
+
{
|
|
216
|
+
return new self($this->name, array_intersect_key($this->fields, array_flip($fields)), $this->table, $this->slug, $this->label, $this->pluralLabel, $this->icon, $this->group, null, $this->idType, $this->adopted);
|
|
217
|
+
}
|
|
218
|
+
|
|
140
219
|
public function variable(): string
|
|
141
220
|
{
|
|
142
221
|
return Naming::camel($this->name);
|
|
@@ -133,11 +133,25 @@ final class Fake
|
|
|
133
133
|
'datetime' => $field->unique
|
|
134
134
|
? gmdate('Y-m-d H:i:s', self::EPOCH + $number)
|
|
135
135
|
: date('Y-m-d H:i:s', time() - mt_rand(0, 365 * 86400)),
|
|
136
|
-
'text' =>
|
|
136
|
+
'text' => $field->isRich()
|
|
137
|
+
? self::article($field->unique ? " {$number}" : '')
|
|
138
|
+
: self::sentence(mt_rand(8, 24)).($field->unique ? " {$number}" : ''),
|
|
137
139
|
default => self::string($field, $number, $first, $last, $isPerson),
|
|
138
140
|
};
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
/** A few paragraphs with a heading and a list, so a seeded record reads like one somebody wrote. */
|
|
144
|
+
private static function article(string $suffix): string
|
|
145
|
+
{
|
|
146
|
+
$items = implode('', array_map(fn () => '<li>'.ucfirst(self::sentence(mt_rand(3, 6))).'</li>', range(1, 3)));
|
|
147
|
+
|
|
148
|
+
return '<p>'.self::sentence(mt_rand(14, 24)).'</p>'
|
|
149
|
+
.'<h2>'.rtrim(self::sentence(mt_rand(3, 5)), '.').$suffix.'</h2>'
|
|
150
|
+
.'<p>'.self::sentence(mt_rand(12, 20)).' <strong>'.rtrim(self::sentence(mt_rand(2, 4)), '.').'</strong>, '.lcfirst(self::sentence(mt_rand(6, 10))).'</p>'
|
|
151
|
+
."<ul>{$items}</ul>"
|
|
152
|
+
.'<p>'.self::sentence(mt_rand(10, 18)).'</p>';
|
|
153
|
+
}
|
|
154
|
+
|
|
141
155
|
private static function string(Field $field, int $number, string $first, string $last, bool $isPerson): string
|
|
142
156
|
{
|
|
143
157
|
$name = strtolower($field->name);
|
|
@@ -14,6 +14,7 @@ final class Field
|
|
|
14
14
|
|
|
15
15
|
public const FORMATS = [
|
|
16
16
|
'string' => ['email', 'url', 'tel', 'slug', 'color'],
|
|
17
|
+
'text' => ['rich'],
|
|
17
18
|
'float' => ['money', 'percent', 'rating'],
|
|
18
19
|
'int' => ['percent', 'rating'],
|
|
19
20
|
];
|
|
@@ -23,7 +24,7 @@ final class Field
|
|
|
23
24
|
|
|
24
25
|
/** Shorthand types accepted by --fields, mapped to kind + format. */
|
|
25
26
|
private const ALIASES = [
|
|
26
|
-
'string' => ['string', null], 'text' => ['text', null],
|
|
27
|
+
'string' => ['string', null], 'text' => ['text', null], 'richtext' => ['text', 'rich'],
|
|
27
28
|
'email' => ['string', 'email'], 'url' => ['string', 'url'], 'tel' => ['string', 'tel'],
|
|
28
29
|
'slug' => ['string', 'slug'], 'color' => ['string', 'color'],
|
|
29
30
|
'int' => ['int', null], 'integer' => ['int', null],
|
|
@@ -40,6 +41,10 @@ final class Field
|
|
|
40
41
|
* @param string|null $target belongsTo: the resource this points at, e.g. "Category"
|
|
41
42
|
* @param list<string> $accept file: the categories it takes, e.g. ["image"]
|
|
42
43
|
* @param string|null $profile file: the image profile uploads are optimised with (config nevela.uploads.profiles)
|
|
44
|
+
* @param int|null $maxLength string and text: the most characters it holds, where that isn't the usual 255 or 65,535
|
|
45
|
+
* @param bool $private file: kept where the web server doesn't reach, and handed only to someone who may view the record
|
|
46
|
+
* @param string|int|float|bool|null $default What the field is when a create leaves it out. Also the column's default.
|
|
47
|
+
* @param string|null $from slug: the field it is made from when a create leaves it empty, e.g. "title"
|
|
43
48
|
*/
|
|
44
49
|
public function __construct(
|
|
45
50
|
public readonly string $name,
|
|
@@ -52,6 +57,10 @@ final class Field
|
|
|
52
57
|
public readonly ?string $target = null,
|
|
53
58
|
public readonly array $accept = [],
|
|
54
59
|
public readonly ?string $profile = null,
|
|
60
|
+
public readonly ?int $maxLength = null,
|
|
61
|
+
public readonly bool $private = false,
|
|
62
|
+
public readonly string|int|float|bool|null $default = null,
|
|
63
|
+
public readonly ?string $from = null,
|
|
55
64
|
) {
|
|
56
65
|
if (! preg_match('/^[a-z][A-Za-z0-9]*$/', $name)) {
|
|
57
66
|
throw new InvalidArgumentException("Field \"{$name}\" must be camelCase (e.g. publishedAt).");
|
|
@@ -65,6 +74,9 @@ final class Field
|
|
|
65
74
|
if ($format !== null && ! in_array($format, self::FORMATS[$kind] ?? [], true)) {
|
|
66
75
|
throw new InvalidArgumentException("Format \"{$format}\" is not valid for {$kind} field \"{$name}\".");
|
|
67
76
|
}
|
|
77
|
+
if ($maxLength !== null && (! in_array($kind, ['string', 'text'], true) || $format === 'color' || $maxLength < 1)) {
|
|
78
|
+
throw new InvalidArgumentException("\"{$name}\" can't have a length of {$maxLength}: a length is a whole number of 1 or more, on a string or text field.");
|
|
79
|
+
}
|
|
68
80
|
if ($kind === 'enum' && $options === []) {
|
|
69
81
|
throw new InvalidArgumentException("Enum field \"{$name}\" needs values, e.g. {$name}:enum(draft|published).");
|
|
70
82
|
}
|
|
@@ -81,6 +93,33 @@ final class Field
|
|
|
81
93
|
throw new InvalidArgumentException("A belongsTo field holds an id, so its name ends in \"Id\": {$name}Id.");
|
|
82
94
|
}
|
|
83
95
|
}
|
|
96
|
+
if ($default !== null) {
|
|
97
|
+
$fits = match ($kind) {
|
|
98
|
+
'string', 'text' => is_string($default) && ! ($kind === 'text' && $format === 'rich'),
|
|
99
|
+
'int' => is_int($default),
|
|
100
|
+
'float' => is_int($default) || is_float($default),
|
|
101
|
+
'boolean' => is_bool($default),
|
|
102
|
+
'enum' => is_string($default) && in_array($default, $options, true),
|
|
103
|
+
default => false,
|
|
104
|
+
};
|
|
105
|
+
if (! $fits) {
|
|
106
|
+
throw new InvalidArgumentException("The default of \"{$name}\" has to be a value the field could hold".($kind === 'enum' ? ': one of '.implode(', ', $options).'.' : (in_array($kind, ['string', 'text', 'int', 'float', 'boolean'], true) ? '.' : ", and a {$kind} field can't have one.")));
|
|
107
|
+
}
|
|
108
|
+
if ($unique) {
|
|
109
|
+
throw new InvalidArgumentException("\"{$name}\" is unique, so only one record could ever have its default.");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if ($from !== null) {
|
|
113
|
+
if ($kind !== 'string' || $format !== 'slug') {
|
|
114
|
+
throw new InvalidArgumentException("\"{$name}\" can't be made from another field: that is for a slug ({$name}:slug({$from})).");
|
|
115
|
+
}
|
|
116
|
+
if (! preg_match('/^[a-z][A-Za-z0-9]*$/', $from) || $from === $name) {
|
|
117
|
+
throw new InvalidArgumentException("\"{$name}\" is made from a field of the same resource, named as it is there: {$name}:slug(title).");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if ($private && $kind !== 'file') {
|
|
121
|
+
throw new InvalidArgumentException("\"{$name}\" can't be private: that is for file fields. Who may see a record's other fields is its policy's to say.");
|
|
122
|
+
}
|
|
84
123
|
if ($kind === 'file') {
|
|
85
124
|
if ($accept === []) {
|
|
86
125
|
throw new InvalidArgumentException("File field \"{$name}\" needs what it accepts, e.g. {$name}:image or {$name}:file(pdf|image).");
|
|
@@ -103,15 +142,41 @@ final class Field
|
|
|
103
142
|
* Parse one `name:type` token. Suffix `?` = optional (nullable), `!` = unique; both may be combined.
|
|
104
143
|
*
|
|
105
144
|
* status:enum(draft|published)
|
|
145
|
+
* excerpt:string(500) a length other than the usual 255
|
|
146
|
+
* body:richtext formatted text, kept as HTML that has been cleaned
|
|
106
147
|
* category:belongsTo(Category) stored as categoryId
|
|
107
148
|
* image:image an optimised image; image:image(product) names a profile
|
|
108
149
|
* manual:file(pdf|document) any other upload
|
|
150
|
+
* cv:file(pdf|private) one only those who may view the record can fetch
|
|
151
|
+
* status:enum(pending|paid)=pending what it is when a create leaves it out
|
|
152
|
+
* slug:slug(title)! made from the title when left empty
|
|
109
153
|
*
|
|
110
154
|
* Grit's and Flare's spellings are understood too: see normalise().
|
|
111
155
|
*/
|
|
112
156
|
public static function parse(string $token): self
|
|
113
157
|
{
|
|
114
158
|
$written = trim($token);
|
|
159
|
+
// status:enum(pending|paid)=pending: what the field is when a create leaves it out.
|
|
160
|
+
$default = null;
|
|
161
|
+
if (($at = strpos($written, '=')) !== false) {
|
|
162
|
+
$default = trim(substr($written, $at + 1));
|
|
163
|
+
$written = rtrim(substr($written, 0, $at));
|
|
164
|
+
}
|
|
165
|
+
if ($default !== null) {
|
|
166
|
+
$field = self::parse($written);
|
|
167
|
+
$value = match ($field->kind) {
|
|
168
|
+
'int' => filter_var($default, FILTER_VALIDATE_INT) === false ? $default : (int) $default,
|
|
169
|
+
'float' => is_numeric($default) ? (float) $default : $default,
|
|
170
|
+
'boolean' => match (strtolower($default)) {
|
|
171
|
+
'true', '1', 'yes' => true,
|
|
172
|
+
'false', '0', 'no' => false,
|
|
173
|
+
default => $default,
|
|
174
|
+
},
|
|
175
|
+
default => $default,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return self::fromArray($field->name, ['default' => $value] + $field->toArray());
|
|
179
|
+
}
|
|
115
180
|
$token = self::normalise($written);
|
|
116
181
|
if (! preg_match('/^([A-Za-z][A-Za-z0-9_]*):([A-Za-z_]+)(?:\(([^)]*)\))?([?!]{0,2})$/', $token, $m)) {
|
|
117
182
|
throw new InvalidArgumentException("Invalid field \"{$written}\". Expected name:type, e.g. price:money, status:enum(draft|live)?, image:image or category:belongsTo(Category).");
|
|
@@ -135,14 +200,23 @@ final class Field
|
|
|
135
200
|
return new self($name, 'file', null, $required, $unique, accept: ['image'], profile: $list[0] ?? null);
|
|
136
201
|
}
|
|
137
202
|
if ($type === 'file') {
|
|
138
|
-
|
|
203
|
+
// cv:file(pdf|private): "private" among what it takes is how it is kept, not a kind of file.
|
|
204
|
+
$accept = array_values(array_diff($list, ['private']));
|
|
205
|
+
|
|
206
|
+
return new self($name, 'file', null, $required, $unique, accept: $accept ?: ['any'], private: count($accept) !== count($list));
|
|
139
207
|
}
|
|
140
208
|
if (! isset(self::ALIASES[$type])) {
|
|
141
209
|
throw new InvalidArgumentException("Unknown type \"{$type}\" on \"{$name}\". Use one of: ".implode(', ', array_keys(self::ALIASES)).', enum(a|b), belongsTo(Resource), image, file(pdf|…).');
|
|
142
210
|
}
|
|
143
211
|
[$kind, $format] = self::ALIASES[$type];
|
|
212
|
+
// slug:slug(title)!: made from the title when a create leaves it empty.
|
|
213
|
+
if ($format === 'slug' && $args !== '' && ! ctype_digit($args)) {
|
|
214
|
+
return new self($name, $kind, $format, $required, $unique, from: Naming::camel($args));
|
|
215
|
+
}
|
|
216
|
+
// excerpt:string(500): a length of its own.
|
|
217
|
+
$maxLength = in_array($kind, ['string', 'text'], true) && $format !== 'color' && ctype_digit($args) ? (int) $args : null;
|
|
144
218
|
|
|
145
|
-
return new self($name, $kind, $format, $required, $unique);
|
|
219
|
+
return new self($name, $kind, $format, $required, $unique, maxLength: $maxLength);
|
|
146
220
|
}
|
|
147
221
|
|
|
148
222
|
/**
|
|
@@ -183,6 +257,10 @@ final class Field
|
|
|
183
257
|
$data['target'] ?? null,
|
|
184
258
|
array_values($data['accept'] ?? []),
|
|
185
259
|
$data['profile'] ?? null,
|
|
260
|
+
isset($data['maxLength']) ? (int) $data['maxLength'] : null,
|
|
261
|
+
(bool) ($data['private'] ?? false),
|
|
262
|
+
$data['default'] ?? null,
|
|
263
|
+
$data['from'] ?? null,
|
|
186
264
|
);
|
|
187
265
|
}
|
|
188
266
|
|
|
@@ -196,6 +274,10 @@ final class Field
|
|
|
196
274
|
'target' => $this->target,
|
|
197
275
|
'accept' => $this->accept ?: null,
|
|
198
276
|
'profile' => $this->profile,
|
|
277
|
+
'maxLength' => $this->maxLength,
|
|
278
|
+
'private' => $this->private ?: null,
|
|
279
|
+
'default' => $this->default,
|
|
280
|
+
'from' => $this->from,
|
|
199
281
|
'required' => $this->required ? null : false,
|
|
200
282
|
'unique' => $this->unique ?: null,
|
|
201
283
|
'label' => $this->label,
|
|
@@ -219,6 +301,12 @@ final class Field
|
|
|
219
301
|
return substr($this->name, 0, -2);
|
|
220
302
|
}
|
|
221
303
|
|
|
304
|
+
/** Whether this is text with formatting: written in an editor, kept as HTML, cleaned on the way in and out. */
|
|
305
|
+
public function isRich(): bool
|
|
306
|
+
{
|
|
307
|
+
return $this->kind === 'text' && $this->format === 'rich';
|
|
308
|
+
}
|
|
309
|
+
|
|
222
310
|
/** Whether this is a file field that takes images only, and so is optimised and shown as a picture. */
|
|
223
311
|
public function isImage(): bool
|
|
224
312
|
{
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Support;
|
|
4
|
+
|
|
5
|
+
use DOMDocument;
|
|
6
|
+
use DOMElement;
|
|
7
|
+
use DOMNode;
|
|
8
|
+
use DOMText;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Rich text, made safe to keep and to show.
|
|
12
|
+
*
|
|
13
|
+
* What a rich text field receives is whatever was sent: the dashboard's editor, a script,
|
|
14
|
+
* a CSV file, a visitor's form. None of it is trusted. The HTML is parsed, and written
|
|
15
|
+
* out again from what was understood, keeping only the tags and attributes on a list
|
|
16
|
+
* (config: nevela.rich_text). A tag that isn't on it goes and its words stay; a tag whose
|
|
17
|
+
* contents are not words, such as a script, goes with them.
|
|
18
|
+
*
|
|
19
|
+
* The same is done when a record is read, so that HTML which reached the table some other
|
|
20
|
+
* way (a column adopted from an older app, a row written by hand) is never shown as it is.
|
|
21
|
+
*/
|
|
22
|
+
final class Html
|
|
23
|
+
{
|
|
24
|
+
/** What the dashboard's editor can make. */
|
|
25
|
+
public const TAGS = ['p', 'br', 'strong', 'em', 's', 'a', 'h2', 'h3', 'ul', 'ol', 'li', 'blockquote'];
|
|
26
|
+
|
|
27
|
+
/** Attributes kept, by tag. A link keeps where it goes and nothing else. */
|
|
28
|
+
public const ATTRIBUTES = ['a' => ['href']];
|
|
29
|
+
|
|
30
|
+
/** Where a link may go. One with no scheme, such as /about, is the site's own and is kept. */
|
|
31
|
+
public const SCHEMES = ['http', 'https', 'mailto'];
|
|
32
|
+
|
|
33
|
+
/** Other spellings of the same thing, as browsers and word processors write them. */
|
|
34
|
+
private const SAME_AS = ['b' => 'strong', 'i' => 'em', 'strike' => 's', 'del' => 's'];
|
|
35
|
+
|
|
36
|
+
/** Removed with everything inside them: what they hold was never text. */
|
|
37
|
+
private const DROPPED = ['script', 'style', 'iframe', 'object', 'embed', 'noscript', 'template', 'svg', 'math', 'head', 'title', 'textarea', 'select', 'option', 'button', 'audio', 'video', 'canvas', 'applet', 'frame', 'frameset', 'link', 'meta', 'base'];
|
|
38
|
+
|
|
39
|
+
/** Tags that sit inside a line of text. Anything else stands on lines of its own. */
|
|
40
|
+
private const INLINE = ['a', 'strong', 'em', 's', 'b', 'i', 'u', 'strike', 'del', 'ins', 'span', 'code', 'sub', 'sup', 'small', 'mark', 'font', 'br', 'abbr', 'cite', 'q', 'label', 'time', 'img', 'kbd', 'var', 'samp', 'big', 'tt', 'wbr'];
|
|
41
|
+
|
|
42
|
+
private const EMPTY = ['br', 'hr', 'img'];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The HTML with only what the list allows, or an empty string when there is nothing
|
|
46
|
+
* in it to read: an editor that was opened and left sends `<p><br></p>`.
|
|
47
|
+
*
|
|
48
|
+
* @param array{tags?: list<string>, attributes?: array<string, list<string>>, schemes?: list<string>}|null $allow Null for config('nevela.rich_text')
|
|
49
|
+
*/
|
|
50
|
+
public static function clean(?string $html, ?array $allow = null): string
|
|
51
|
+
{
|
|
52
|
+
if ($html === null || trim($html) === '') {
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
$allow ??= function_exists('config') && function_exists('app') && app()->bound('config') ? (array) config('nevela.rich_text', []) : [];
|
|
56
|
+
$tags = array_map('strtolower', (array) ($allow['tags'] ?? self::TAGS));
|
|
57
|
+
$attributes = (array) ($allow['attributes'] ?? self::ATTRIBUTES);
|
|
58
|
+
$schemes = array_map('strtolower', (array) ($allow['schemes'] ?? self::SCHEMES));
|
|
59
|
+
|
|
60
|
+
$document = new DOMDocument;
|
|
61
|
+
$before = libxml_use_internal_errors(true);
|
|
62
|
+
// Characters outside ASCII go in as numbered entities: the parser would otherwise
|
|
63
|
+
// have to guess the encoding, and guesses Latin-1.
|
|
64
|
+
$document->loadHTML('<html><body>'.mb_encode_numericentity($html, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8').'</body></html>');
|
|
65
|
+
libxml_clear_errors();
|
|
66
|
+
libxml_use_internal_errors($before);
|
|
67
|
+
|
|
68
|
+
$body = $document->getElementsByTagName('body')->item(0);
|
|
69
|
+
if ($body === null) {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
$out = self::top($body, $tags, $attributes, $schemes);
|
|
73
|
+
$words = trim(html_entity_decode(strip_tags($out), ENT_QUOTES | ENT_HTML5, 'UTF-8'), " \t\n\r\0\x0B\u{A0}");
|
|
74
|
+
|
|
75
|
+
return $words === '' && ! str_contains($out, '<img') ? '' : $out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The top of the document, where everything is a paragraph, a heading, a list or a
|
|
80
|
+
* quote. Text and inline tags found loose there are gathered into paragraphs, so that
|
|
81
|
+
* plain text from a CSV file or a script is stored as what the editor would have made.
|
|
82
|
+
* A wrapper that isn't kept, such as a div, is looked into the same way.
|
|
83
|
+
*
|
|
84
|
+
* @param list<string> $tags
|
|
85
|
+
* @param array<string, list<string>> $attributes
|
|
86
|
+
* @param list<string> $schemes
|
|
87
|
+
*/
|
|
88
|
+
private static function top(DOMNode $parent, array $tags, array $attributes, array $schemes): string
|
|
89
|
+
{
|
|
90
|
+
$paragraph = fn (string $inline) => trim($inline) === '' ? '' : (in_array('p', $tags, true) ? '<p>'.trim($inline).'</p>' : $inline);
|
|
91
|
+
$out = '';
|
|
92
|
+
$loose = '';
|
|
93
|
+
foreach ($parent->childNodes as $node) {
|
|
94
|
+
$name = $node instanceof DOMElement ? strtolower($node->nodeName) : '';
|
|
95
|
+
$name = self::SAME_AS[$name] ?? $name;
|
|
96
|
+
if ($name === '' || in_array($name, self::INLINE, true)) {
|
|
97
|
+
$loose .= self::write($node, $tags, $attributes, $schemes);
|
|
98
|
+
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
$out .= $paragraph($loose);
|
|
102
|
+
$loose = '';
|
|
103
|
+
$out .= in_array($name, $tags, true) || in_array($name, self::DROPPED, true)
|
|
104
|
+
? self::write($node, $tags, $attributes, $schemes)
|
|
105
|
+
: self::top($node, $tags, $attributes, $schemes);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return $out.$paragraph($loose);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The words alone, on one line: what a table cell or a search result shows. */
|
|
112
|
+
public static function text(?string $html, int $limit = 0): string
|
|
113
|
+
{
|
|
114
|
+
$text = trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags((string) preg_replace('/<(br|\/p|\/h[1-6]|\/li|\/blockquote)\b[^>]*>/i', ' ', (string) $html)), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
|
|
115
|
+
|
|
116
|
+
return $limit > 0 && mb_strlen($text) > $limit ? rtrim(mb_substr($text, 0, $limit - 1)).'…' : $text;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param list<string> $tags
|
|
122
|
+
* @param array<string, list<string>> $attributes
|
|
123
|
+
* @param list<string> $schemes
|
|
124
|
+
*/
|
|
125
|
+
private static function write(DOMNode $node, array $tags, array $attributes, array $schemes): string
|
|
126
|
+
{
|
|
127
|
+
if ($node instanceof DOMText) {
|
|
128
|
+
return htmlspecialchars($node->nodeValue ?? '', ENT_NOQUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
|
|
129
|
+
}
|
|
130
|
+
if (! $node instanceof DOMElement) {
|
|
131
|
+
return ''; // a comment, a processing instruction
|
|
132
|
+
}
|
|
133
|
+
$name = strtolower($node->nodeName);
|
|
134
|
+
$name = self::SAME_AS[$name] ?? $name;
|
|
135
|
+
if (in_array($name, self::DROPPED, true)) {
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
$inner = '';
|
|
140
|
+
foreach ($node->childNodes as $child) {
|
|
141
|
+
$inner .= self::write($child, $tags, $attributes, $schemes);
|
|
142
|
+
}
|
|
143
|
+
if (! in_array($name, $tags, true)) {
|
|
144
|
+
// Not a tag to keep, but its words are. Where it stood on its own line, a space keeps them apart.
|
|
145
|
+
return in_array($name, self::INLINE, true) && $name !== 'br' ? $inner : $inner.' ';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
$kept = '';
|
|
149
|
+
foreach ((array) ($attributes[$name] ?? []) as $attribute) {
|
|
150
|
+
$value = $node->getAttribute($attribute);
|
|
151
|
+
if ($value === '' || (in_array($attribute, ['href', 'src'], true) && ! self::safe($value, $schemes))) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
$kept .= ' '.$attribute.'="'.htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8').'"';
|
|
155
|
+
}
|
|
156
|
+
// A link that goes nowhere is its words.
|
|
157
|
+
if ($name === 'a' && $kept === '') {
|
|
158
|
+
return $inner;
|
|
159
|
+
}
|
|
160
|
+
// A tag with nothing in it is nothing. Browsers leave these behind: Chrome puts a
|
|
161
|
+
// list inside the paragraph it was started in, which leaves an empty paragraph
|
|
162
|
+
// before it once the two are told apart. (A blank line is <p><br></p>, and is kept.)
|
|
163
|
+
if (! in_array($name, self::EMPTY, true) && trim($inner) === '') {
|
|
164
|
+
return '';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return in_array($name, self::EMPTY, true) ? "<{$name}{$kept}>" : "<{$name}{$kept}>{$inner}</{$name}>";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Whether an address is one a link may have. A browser ignores spaces and control
|
|
172
|
+
* characters inside a scheme, so `java\tscript:` is read here the way it would be there.
|
|
173
|
+
*
|
|
174
|
+
* @param list<string> $schemes
|
|
175
|
+
*/
|
|
176
|
+
private static function safe(string $address, array $schemes): bool
|
|
177
|
+
{
|
|
178
|
+
$address = (string) preg_replace('/[\x00-\x20\x7F]+/', '', $address);
|
|
179
|
+
if (! preg_match('/^([a-z][a-z0-9+.\-]*):/i', $address, $m)) {
|
|
180
|
+
return true; // no scheme: /about, #top, //example.com
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return in_array(strtolower($m[1]), $schemes, true);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Support;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
|
6
|
+
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
7
|
+
use Illuminate\Database\Eloquent\Model;
|
|
8
|
+
use Illuminate\Support\Str;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether a string from an address could be a record's id at all.
|
|
12
|
+
*
|
|
13
|
+
* SQLite and MySQL answer "where id = 'nobody'" with no rows. Postgres checks the type
|
|
14
|
+
* first: an id column is a number or a UUID, 'nobody' is neither, and the query is an
|
|
15
|
+
* error, which reaches the visitor as a 500 where a 404 was meant. Inside a transaction
|
|
16
|
+
* it also aborts everything else in it. So an id is looked at before the database is
|
|
17
|
+
* asked, and one of the wrong shape is nobody's.
|
|
18
|
+
*/
|
|
19
|
+
final class Key
|
|
20
|
+
{
|
|
21
|
+
/** A whole number that fits a bigint column. */
|
|
22
|
+
public static function isInt(mixed $id): bool
|
|
23
|
+
{
|
|
24
|
+
return is_scalar($id) && preg_match('/^[0-9]{1,18}$/', (string) $id) === 1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public static function isUuid(mixed $id): bool
|
|
28
|
+
{
|
|
29
|
+
return is_string($id) && Str::isUuid($id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether `$id` has the shape of this model's key: digits for a numbered key, and
|
|
34
|
+
* whatever a model with Laravel's UUID or ULID keys says is one of its own. A model
|
|
35
|
+
* with some other key of its own making is taken at its word.
|
|
36
|
+
*
|
|
37
|
+
* @param Model|class-string<Model> $model
|
|
38
|
+
*/
|
|
39
|
+
public static function fits(Model|string $model, mixed $id): bool
|
|
40
|
+
{
|
|
41
|
+
$model = is_string($model) ? new $model : $model;
|
|
42
|
+
if (! is_scalar($id)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (in_array($model->getKeyType(), ['int', 'integer'], true)) {
|
|
46
|
+
return self::isInt($id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
$traits = class_uses_recursive($model);
|
|
50
|
+
|
|
51
|
+
return match (true) {
|
|
52
|
+
isset($traits[HasUuids::class]) => Str::isUuid((string) $id),
|
|
53
|
+
isset($traits[HasUlids::class]) => Str::isUlid((string) $id),
|
|
54
|
+
default => true,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -25,7 +25,17 @@ final class ListQuery
|
|
|
25
25
|
|
|
26
26
|
public ?string $q = null;
|
|
27
27
|
|
|
28
|
-
/**
|
|
28
|
+
/** The three things a record with a publish date can be. */
|
|
29
|
+
public const PUBLISHED = ['published', 'draft', 'scheduled'];
|
|
30
|
+
|
|
31
|
+
/** The filter that isn't a field: whether a record's address has been proved ("confirm"). */
|
|
32
|
+
public const CONFIRMED = 'confirmed';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A filter on the field a resource is published by carries `state` in place of a value.
|
|
36
|
+
*
|
|
37
|
+
* @var list<array{field: string, column: string, value: string|int|float|bool|null, state?: string, confirmed?: bool}>
|
|
38
|
+
*/
|
|
29
39
|
public array $filters = [];
|
|
30
40
|
|
|
31
41
|
/** @param array<string, mixed> $params */
|
|
@@ -66,6 +76,24 @@ final class ListQuery
|
|
|
66
76
|
foreach ($filters as $key => $value) {
|
|
67
77
|
$param = "filter[{$key}]";
|
|
68
78
|
$field = $resource->fields[$key] ?? null;
|
|
79
|
+
// filter[publishedAt]=draft: not a date to match, but which of the three it is.
|
|
80
|
+
if ($field !== null && $resource->publish === $key) {
|
|
81
|
+
$state = is_string($value) ? strtolower($value) : '';
|
|
82
|
+
in_array($state, self::PUBLISHED, true)
|
|
83
|
+
? $query->filters[] = ['field' => $key, 'column' => $field->column(), 'value' => null, 'state' => $state]
|
|
84
|
+
: $query->issue($param, 'Must be one of: '.implode(', ', self::PUBLISHED).'.');
|
|
85
|
+
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// filter[confirmed]=true: whether the address was proved, for a resource whose form asks.
|
|
89
|
+
if ($field === null && $key === self::CONFIRMED && $resource->public?->confirm !== null) {
|
|
90
|
+
$proved = is_array($value) ? null : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
|
91
|
+
$proved === null
|
|
92
|
+
? $query->issue($param, 'Must be true or false.')
|
|
93
|
+
: $query->filters[] = ['field' => $key, 'column' => 'confirmed_at', 'value' => null, 'confirmed' => $proved];
|
|
94
|
+
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
69
97
|
if ($field === null || ! $field->filterable()) {
|
|
70
98
|
$query->issue($param, "Can't filter by \"{$key}\".");
|
|
71
99
|
|