@navette-js/forms 1.0.0-beta.2 → 1.0.0-beta.4
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/lib/builder.d.ts +45 -4
- package/lib/builder.js +99 -16
- package/lib/index.d.ts +722 -68
- package/lib/index.js +1705 -234
- package/lib/rules.d.ts +19 -0
- package/lib/rules.js +148 -0
- package/package.json +4 -4
- package/views/components/text-input.ejs +14 -5
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @navette-js/forms — form fields (Field, TextInput, Select, Checkbox…) and server-side validation.
|
|
2
|
-
import { Attributes, HtmlString, e, evaluate, icon, js, views, configured } from '@navette-js/support';
|
|
2
|
+
import { Attributes, HtmlString, cls, e, evaluate, icon, js, sanitizeHtml, views, configured } from '@navette-js/support';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { t } from '@navette-js/support/i18n';
|
|
5
5
|
import { Component, Icon as SchemaIcon, Schema, Text, childSchema, renderInlineItems, slug } from '@navette-js/schemas';
|
|
@@ -16,7 +16,7 @@ import Image from '@tiptap/extension-image';
|
|
|
16
16
|
import { TableKit } from '@tiptap/extension-table';
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
18
|
import nodePath from 'node:path';
|
|
19
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { constants as fsConstants, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
|
|
20
20
|
views.namespace('filament-forms', fileURLToPath(new URL('../views', import.meta.url)));
|
|
21
21
|
let colorManager = null;
|
|
22
22
|
export const useColorManager = (manager) => { colorManager = manager; };
|
|
@@ -28,7 +28,7 @@ const phpNumber = (n) => {
|
|
|
28
28
|
const [mantissa, exponent] = n.toPrecision(14).split('e');
|
|
29
29
|
return `${mantissa.replace(/\.?0+$/, '')}E${exponent}`;
|
|
30
30
|
};
|
|
31
|
-
|
|
31
|
+
import { isBlank as blank, stringRule, parseRegex } from "./rules.js";
|
|
32
32
|
const headline = (s) => s.replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase()).replace(/ (.)/g, (_, c) => ' ' + c.toLowerCase());
|
|
33
33
|
export class Field extends Component {
|
|
34
34
|
_label = null;
|
|
@@ -41,7 +41,16 @@ export class Field extends Component {
|
|
|
41
41
|
_dehydrated = null;
|
|
42
42
|
_hiddenLabel = false;
|
|
43
43
|
_helperText = null;
|
|
44
|
+
_liveMode = null;
|
|
45
|
+
_validationAttribute = null;
|
|
46
|
+
_validationMessages = {};
|
|
47
|
+
_markedAsRequired = null;
|
|
44
48
|
constructor(name) { super(); this._name = name; }
|
|
49
|
+
/** Sends the state to the server as soon as it changes (`onBlur`: when the field loses focus). `false` turns it off. */
|
|
50
|
+
live(condition = true) { this._liveMode = condition === false ? null : typeof condition === 'object' && condition.onBlur ? 'blur' : 'live'; return this; }
|
|
51
|
+
isLive() { return this._liveMode !== null; }
|
|
52
|
+
/** `wire:model` directive of the field, with its live modifiers. */
|
|
53
|
+
wireModel() { return `wire:model${this._liveMode ? '.live' : ''}${this._liveMode === 'blur' ? '.blur' : ''}`; }
|
|
45
54
|
hasStatePath() { return true; }
|
|
46
55
|
getName() { return this._name; }
|
|
47
56
|
getId() { return this.getKey(); }
|
|
@@ -110,7 +119,125 @@ export class Field extends Component {
|
|
|
110
119
|
getDefaultState() { return typeof this._default === 'function' ? this._default() : this._default; }
|
|
111
120
|
/** Shapes a record value as it enters the form (hydration). */
|
|
112
121
|
hydrateState(value) { return value; }
|
|
113
|
-
rule
|
|
122
|
+
/** Adds a rule: an object `{ name, passes, message }`, or a Laravel-style string (`'max:255'`, `'regex:/^[a-z]+$/'`). */
|
|
123
|
+
rule(rule, condition = true) {
|
|
124
|
+
if (typeof rule === 'string') {
|
|
125
|
+
const spec = rule;
|
|
126
|
+
// Built now, so that an unknown or malformed rule fails when the form is declared.
|
|
127
|
+
const probe = stringRule(spec, { numeric: false, name: this._name ?? '' });
|
|
128
|
+
if (!probe)
|
|
129
|
+
return this;
|
|
130
|
+
const build = () => stringRule(spec, { numeric: this.hasNumericValidation(), name: this._name ?? '' });
|
|
131
|
+
rule = { name: probe.name, implicit: probe.implicit, passes: (value, ctx) => build().passes(value, ctx), message: (attribute) => build().message(attribute) };
|
|
132
|
+
}
|
|
133
|
+
if (condition === false)
|
|
134
|
+
return this;
|
|
135
|
+
if (condition === true) {
|
|
136
|
+
this._rules.push(rule);
|
|
137
|
+
return this;
|
|
138
|
+
}
|
|
139
|
+
const inner = rule;
|
|
140
|
+
this._rules.push({ ...inner, passes: async (value, ctx) => !(await condition(ctx)) || inner.passes(value, ctx) });
|
|
141
|
+
return this;
|
|
142
|
+
}
|
|
143
|
+
/** Several rules at once: `rules(['required', 'max:255'])` or `rules('required|max:255')`. */
|
|
144
|
+
rules(rules, condition = true) {
|
|
145
|
+
for (const rule of typeof rules === 'string' ? rules.split('|').filter((item) => item.trim() !== '') : rules)
|
|
146
|
+
this.rule(rule, condition);
|
|
147
|
+
return this;
|
|
148
|
+
}
|
|
149
|
+
/** Value checked by the rules (a subclass may trim it, for instance). */
|
|
150
|
+
stateForValidation(state) { return state; }
|
|
151
|
+
/** Sizes (`min`, `max`, `size`, `between`) compare the number, not the text length. */
|
|
152
|
+
hasNumericValidation() { return false; }
|
|
153
|
+
/** Name of the field in validation messages (the lowercased label by default). */
|
|
154
|
+
validationAttribute(name) { this._validationAttribute = name; return this; }
|
|
155
|
+
/** Custom messages by rule name (`{ required: 'Tell us your name.', max: 'Too long.' }`); `:attribute` is replaced by the field name. */
|
|
156
|
+
validationMessages(messages) { this._validationMessages = { ...this._validationMessages, ...messages }; return this; }
|
|
157
|
+
_loadStateFromRelationships = null;
|
|
158
|
+
_saveRelationships = null;
|
|
159
|
+
/** Fills the field from the record's relationships (after the record's own values): `(record, ctx) => state`. */
|
|
160
|
+
loadStateFromRelationshipsUsing(callback) { this._loadStateFromRelationships = callback; return this; }
|
|
161
|
+
/** Saves the field into the record's relationships, once the record is created or updated. Such a field is not in the saved data. */
|
|
162
|
+
saveRelationshipsUsing(callback) { this._saveRelationships = callback; return this; }
|
|
163
|
+
/** Whether the field saves itself through `saveRelationshipsUsing()` (and is then left out of the saved data). */
|
|
164
|
+
async savesRelationships() { return this._saveRelationships !== null; }
|
|
165
|
+
async loadStateFromRelationships(record) { return this._loadStateFromRelationships ? { state: await this._loadStateFromRelationships(record, this.context()) } : null; }
|
|
166
|
+
async saveRelationships(record) { if (this._saveRelationships)
|
|
167
|
+
await this._saveRelationships({ record, state: await this.dehydrateState(this.context().state), ctx: this.context() }); }
|
|
168
|
+
/** Shows (or hides, with `false`) the required asterisk, regardless of `required()`. */
|
|
169
|
+
markAsRequired(condition = true) { this._markedAsRequired = condition; return this; }
|
|
170
|
+
async isMarkedAsRequired() { return this._markedAsRequired ?? (await this.isRequired()); }
|
|
171
|
+
stringRuleWith(name, parameters = []) { return this.rule(parameters.length ? `${name}:${parameters.map(String).join(',')}` : name); }
|
|
172
|
+
/** The value must match the pattern (a RegExp, or a `/pattern/flags` string). */
|
|
173
|
+
regex(pattern) { const re = typeof pattern === 'string' ? parseRegex(pattern) : pattern; return this.rule({ name: 'regex', passes: (v) => { re.lastIndex = 0; return re.test(String(v)); }, message: (a) => `The ${a} field format is invalid.` }); }
|
|
174
|
+
notRegex(pattern) { const re = typeof pattern === 'string' ? parseRegex(pattern) : pattern; return this.rule({ name: 'not_regex', passes: (v) => { re.lastIndex = 0; return !re.test(String(v)); }, message: (a) => `The ${a} field format is invalid.` }); }
|
|
175
|
+
alpha() { return this.stringRuleWith('alpha'); }
|
|
176
|
+
alphaDash() { return this.stringRuleWith('alpha_dash'); }
|
|
177
|
+
alphaNum() { return this.stringRuleWith('alpha_num'); }
|
|
178
|
+
ascii() { return this.stringRuleWith('ascii'); }
|
|
179
|
+
uuid() { return this.stringRuleWith('uuid'); }
|
|
180
|
+
ulid() { return this.stringRuleWith('ulid'); }
|
|
181
|
+
json() { return this.stringRuleWith('json'); }
|
|
182
|
+
ip() { return this.stringRuleWith('ip'); }
|
|
183
|
+
ipv4() { return this.stringRuleWith('ipv4'); }
|
|
184
|
+
ipv6() { return this.stringRuleWith('ipv6'); }
|
|
185
|
+
macAddress() { return this.stringRuleWith('mac_address'); }
|
|
186
|
+
hexColor() { return this.stringRuleWith('hex_color'); }
|
|
187
|
+
multipleOf(value) { return this.stringRuleWith('multiple_of', [value]); }
|
|
188
|
+
/** The value must be one of these (a list, or the values of an enum-like object). */
|
|
189
|
+
in(values) { const allowed = (Array.isArray(values) ? values : Object.values(values)).map(String); return this.rule({ name: 'in', passes: (v) => (Array.isArray(v) ? v : [v]).every((item) => allowed.includes(String(item))), message: (a) => `The selected ${a} is invalid.` }); }
|
|
190
|
+
notIn(values) { const denied = values.map(String); return this.rule({ name: 'not_in', passes: (v) => (Array.isArray(v) ? v : [v]).every((item) => !denied.includes(String(item))), message: (a) => `The selected ${a} is invalid.` }); }
|
|
191
|
+
/** `in()` with the values of an enum-like object. */
|
|
192
|
+
enum(values) { return this.in(values); }
|
|
193
|
+
startsWith(prefixes) { return this.stringRuleWith('starts_with', [prefixes].flat()); }
|
|
194
|
+
endsWith(suffixes) { return this.stringRuleWith('ends_with', [suffixes].flat()); }
|
|
195
|
+
doesntStartWith(prefixes) { return this.stringRuleWith('doesnt_start_with', [prefixes].flat()); }
|
|
196
|
+
doesntEndWith(suffixes) { return this.stringRuleWith('doesnt_end_with', [suffixes].flat()); }
|
|
197
|
+
/** The field must equal `<name>_confirmation` (or the given field) of the same form. */
|
|
198
|
+
confirmed(field = null) { return this.rule(field ? `confirmed:${field}` : 'confirmed'); }
|
|
199
|
+
same(field) { return this.stringRuleWith('same', [field]); }
|
|
200
|
+
different(field) { return this.stringRuleWith('different', [field]); }
|
|
201
|
+
/** Compares with a number or another field of the same form. */
|
|
202
|
+
gt(fieldOrValue) { return this.stringRuleWith('gt', [fieldOrValue]); }
|
|
203
|
+
gte(fieldOrValue) { return this.stringRuleWith('gte', [fieldOrValue]); }
|
|
204
|
+
lt(fieldOrValue) { return this.stringRuleWith('lt', [fieldOrValue]); }
|
|
205
|
+
lte(fieldOrValue) { return this.stringRuleWith('lte', [fieldOrValue]); }
|
|
206
|
+
/** No duplicates in a list value (tags, multiple select, checkbox list). */
|
|
207
|
+
distinct() { return this.stringRuleWith('distinct'); }
|
|
208
|
+
/** Required when another field of the form has one of these values (`requiredIf('type', 'company')`). */
|
|
209
|
+
requiredIf(field, values) { return this.stringRuleWith('required_if', [field, ...[values].flat()]); }
|
|
210
|
+
requiredUnless(field, values) { return this.stringRuleWith('required_unless', [field, ...[values].flat()]); }
|
|
211
|
+
requiredIfAccepted(field) { return this.stringRuleWith('required_if_accepted', [field]); }
|
|
212
|
+
requiredWith(fields) { return this.stringRuleWith('required_with', [fields].flat()); }
|
|
213
|
+
requiredWithAll(fields) { return this.stringRuleWith('required_with_all', [fields].flat()); }
|
|
214
|
+
requiredWithout(fields) { return this.stringRuleWith('required_without', [fields].flat()); }
|
|
215
|
+
requiredWithoutAll(fields) { return this.stringRuleWith('required_without_all', [fields].flat()); }
|
|
216
|
+
prohibited(condition = true) { return condition ? this.stringRuleWith('prohibited') : this; }
|
|
217
|
+
prohibitedIf(field, values) { return this.stringRuleWith('prohibited_if', [field, ...[values].flat()]); }
|
|
218
|
+
prohibitedUnless(field, values) { return this.stringRuleWith('prohibited_unless', [field, ...[values].flat()]); }
|
|
219
|
+
prohibits(fields) { return this.stringRuleWith('prohibits', [fields].flat()); }
|
|
220
|
+
/**
|
|
221
|
+
* The value must exist: in a column of the host's data adapter (`exists()`, `exists({ column })`), in another adapter
|
|
222
|
+
* (`exists({ adapter: new ObjectionAdapter(Brand), column: 'id' })`), or according to a function.
|
|
223
|
+
*/
|
|
224
|
+
exists(check = {}) {
|
|
225
|
+
return this.rule({
|
|
226
|
+
name: 'exists',
|
|
227
|
+
passes: async (value, ctx) => {
|
|
228
|
+
if (typeof check === 'function')
|
|
229
|
+
return check(value, ctx);
|
|
230
|
+
const adapter = check.adapter ?? ctx.livewire.getDataAdapter?.();
|
|
231
|
+
if (!adapter)
|
|
232
|
+
throw new Error('exists(): no data adapter (pass one with { adapter }).');
|
|
233
|
+
for (const item of Array.isArray(value) ? value : [value])
|
|
234
|
+
if (!(await adapter.exists(check.column ?? this._name, item, null)))
|
|
235
|
+
return false;
|
|
236
|
+
return true;
|
|
237
|
+
},
|
|
238
|
+
message: (a) => `The selected ${a} is invalid.`,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
114
241
|
/** `unique` rule: the value must not exist in the column of the same name; `ignoreRecord` excludes the record being edited. */
|
|
115
242
|
unique(options = {}) {
|
|
116
243
|
return this.rule({
|
|
@@ -144,13 +271,16 @@ export class Field extends Component {
|
|
|
144
271
|
if ((await this.isDisabled()) && this._dehydrated !== true)
|
|
145
272
|
return [];
|
|
146
273
|
// An empty string is treated as missing.
|
|
147
|
-
const
|
|
148
|
-
const
|
|
274
|
+
const raw = this.stateForValidation(this.context().state);
|
|
275
|
+
const value = raw === '' ? null : raw;
|
|
276
|
+
const attribute = this._validationAttribute ?? (await this.getLabel()).toLowerCase();
|
|
149
277
|
for (const rule of await this.getValidationRules()) {
|
|
150
|
-
if (rule.name !== 'required' && blank(value))
|
|
278
|
+
if (rule.name !== 'required' && !rule.implicit && blank(value))
|
|
151
279
|
continue;
|
|
152
|
-
if (!(await rule.passes(value, this.context())))
|
|
153
|
-
|
|
280
|
+
if (!(await rule.passes(value, this.context()))) {
|
|
281
|
+
const custom = this._validationMessages[rule.name];
|
|
282
|
+
return [custom === undefined ? rule.message(attribute) : typeof custom === 'function' ? custom(attribute) : custom.replace(/:attribute/g, attribute)];
|
|
283
|
+
}
|
|
154
284
|
}
|
|
155
285
|
return [];
|
|
156
286
|
}
|
|
@@ -170,7 +300,7 @@ export class Field extends Component {
|
|
|
170
300
|
if (this._helperText !== null)
|
|
171
301
|
html += `<div id="${e(this.getHelperTextId())}" class="fi-sc fi-inline fi-sc-has-gap fi-sc-dense"><div x-data="filamentSchemaComponent(${e(js({ key: null, exposedMethods: [], path: this.getStatePath(), containerPath: this.container.getStatePath() }))})"><div class="fi-sc-component"><span class="fi-sc-text">${e(this._helperText)}</span></div></div></div>`;
|
|
172
302
|
return views.render('filament-forms::components.field-wrapper', {
|
|
173
|
-
id: this.getId(), label: await this.getLabel(), required: (await this.
|
|
303
|
+
id: this.getId(), label: await this.getLabel(), required: (await this.isMarkedAsRequired()) && !(await this.isDisabled()),
|
|
174
304
|
labelPrefix: options.labelPrefix ?? '', hiddenLabel: this._hiddenLabel, after: options.after ?? '', afterLabel: this._hiddenLabel ? '' : await this.renderAfterLabel(), labelAsDiv: options.labelAsDiv === true, focusable: options.focusable === true, html, errorMessage: errors[0] ?? null, wrapperClass: options.wrapperClass ?? null,
|
|
175
305
|
});
|
|
176
306
|
}
|
|
@@ -189,6 +319,7 @@ export class TextInput extends Field {
|
|
|
189
319
|
_prefix = null;
|
|
190
320
|
_live = null;
|
|
191
321
|
static make(name) { return configured(new TextInput(name)); }
|
|
322
|
+
hasNumericValidation() { return this._numeric !== null; }
|
|
192
323
|
/** Decimal number: `type="number"`, `step="any"`. */
|
|
193
324
|
numeric() { this._numeric = 'decimal'; return this.rule({ name: 'numeric', passes: (v) => String(v).trim() !== '' && Number.isFinite(Number(v)), message: (a) => `The ${a} field must be a number.` }); }
|
|
194
325
|
/** Integer: `type="number"`, `step="1"`. */
|
|
@@ -198,21 +329,60 @@ export class TextInput extends Field {
|
|
|
198
329
|
step(step) { this._step = step; return this; }
|
|
199
330
|
minValue(value) { this._minValue = value; return this.rule({ name: 'min', passes: (v) => Number(v) >= value, message: (a) => `The ${a} field must be at least ${value}.` }); }
|
|
200
331
|
maxValue(value) { this._maxValue = value; return this.rule({ name: 'max', passes: (v) => Number(v) <= value, message: (a) => `The ${a} field must not be greater than ${value}.` }); }
|
|
201
|
-
/** Label shown before the input (e.g. `$`). */
|
|
202
|
-
prefix(label) { this._prefix = label; return this; }
|
|
332
|
+
/** Label shown before the input (e.g. `$`); `isInline`: inside the input instead of a separate box. */
|
|
333
|
+
prefix(label, isInline = false) { this._prefix = label; this._prefixInline = isInline; return this; }
|
|
203
334
|
_suffix = null;
|
|
204
335
|
/** Label shown after the input ("hours", "kg"…). */
|
|
205
|
-
suffix(label) { this._suffix = label; return this; }
|
|
336
|
+
suffix(label, isInline = false) { this._suffix = label; this._suffixInline = isInline; return this; }
|
|
337
|
+
_prefixInline = false;
|
|
338
|
+
_suffixInline = false;
|
|
339
|
+
_prefixIcon = null;
|
|
340
|
+
_suffixIcon = null;
|
|
341
|
+
_prefixIconColor = null;
|
|
342
|
+
_suffixIconColor = null;
|
|
343
|
+
_prefixActions = [];
|
|
344
|
+
_suffixActions = [];
|
|
345
|
+
prefixIcon(icon, isInline = false) { this._prefixIcon = icon; this._prefixInline = isInline; return this; }
|
|
346
|
+
suffixIcon(icon, isInline = false) { this._suffixIcon = icon; this._suffixInline = isInline; return this; }
|
|
347
|
+
prefixIconColor(color) { this._prefixIconColor = color; return this; }
|
|
348
|
+
suffixIconColor(color) { this._suffixIconColor = color; return this; }
|
|
349
|
+
/**
|
|
350
|
+
* Icon button before the input, running an action. The action gets the field's `state`, and `set(name, value)` to change a
|
|
351
|
+
* field of the same form.
|
|
352
|
+
*/
|
|
353
|
+
prefixAction(action, isInline = false) { this._prefixActions = [...this._prefixActions, action]; this._prefixInline = isInline; return this; }
|
|
354
|
+
prefixActions(actions) { this._prefixActions = actions; return this; }
|
|
355
|
+
suffixAction(action, isInline = false) { this._suffixActions = [...this._suffixActions, action]; this._suffixInline = isInline; return this; }
|
|
356
|
+
suffixActions(actions) { this._suffixActions = actions; return this; }
|
|
357
|
+
/** Affix actions, mounted through the schema component. */
|
|
358
|
+
getActions() { return [...this.getHintActions(), ...this._prefixActions, ...this._suffixActions]; }
|
|
359
|
+
/** Exact length: `minlength` and `maxlength`, and the matching rule. */
|
|
360
|
+
length(length) { this.minLength(length); this.maxLength(length); return this; }
|
|
361
|
+
_datalist = [];
|
|
362
|
+
/** Suggestions offered by the browser while typing (`<datalist>`). */
|
|
363
|
+
datalist(options) { this._datalist = options.map(String); return this; }
|
|
364
|
+
_readOnly = false;
|
|
365
|
+
/** The value is shown and sent, but cannot be changed (`readonly`). */
|
|
366
|
+
readOnly(condition = true) { this._readOnly = condition; return this; }
|
|
367
|
+
_copyable = null;
|
|
368
|
+
/** A button after the input copies its value (the browser only allows it on HTTPS or `localhost`). */
|
|
369
|
+
copyable(condition = true, copyMessage = null, copyMessageDuration = 2000) { this._copyable = condition ? { message: copyMessage, duration: copyMessageDuration } : null; return this; }
|
|
370
|
+
_trim = false;
|
|
371
|
+
/** Removes the spaces around the value before it is validated and saved. */
|
|
372
|
+
trim(condition = true) { this._trim = condition; return this; }
|
|
373
|
+
hydrateState(value) { return this._trim && typeof value === 'string' ? value.trim() : value; }
|
|
374
|
+
/** `trim()`: the submitted value is trimmed before it is validated and saved. */
|
|
375
|
+
stateForValidation(state) { return this._trim && typeof state === 'string' ? state.trim() : state; }
|
|
376
|
+
dehydrateState(state) { return this._trim && typeof state === 'string' ? state.trim() : state; }
|
|
377
|
+
_inputMode = null;
|
|
378
|
+
_autocapitalize = null;
|
|
379
|
+
/** Virtual keyboard: `none`, `text`, `decimal`, `numeric`, `tel`, `search`, `email` or `url`. */
|
|
380
|
+
inputMode(mode) { this._inputMode = mode; return this; }
|
|
381
|
+
/** `none`, `sentences`, `words` or `characters` (`true` / `false` for `on` / `off`). */
|
|
382
|
+
autocapitalize(mode = true) { this._autocapitalize = mode === true ? 'on' : mode === false ? 'off' : mode; return this; }
|
|
206
383
|
/** Sends the state to the server on every keystroke, or on blur with `onBlur`. */
|
|
207
384
|
live(options = {}) { this._live = options.onBlur ? 'blur' : 'live'; return this; }
|
|
208
385
|
type(type) { this._type = type; return this; }
|
|
209
|
-
email(condition = true) {
|
|
210
|
-
if (condition) {
|
|
211
|
-
this._type = 'email';
|
|
212
|
-
this.rule({ name: 'email', passes: (v) => /^[^\s@]+@[^\s@]+$/.test(String(v)), message: (a) => `The ${a} field must be a valid email address.` });
|
|
213
|
-
}
|
|
214
|
-
return this;
|
|
215
|
-
}
|
|
216
386
|
password(condition = true) { this._isPassword = condition; return this; }
|
|
217
387
|
revealable(condition = true) { this._isRevealable = condition; return this; }
|
|
218
388
|
/** `true` → "on", `false` → "off", a string as is. */
|
|
@@ -223,7 +393,16 @@ export class TextInput extends Field {
|
|
|
223
393
|
mask(pattern) { this._mask = pattern; return this; }
|
|
224
394
|
/** Phone number: digits, spaces and common punctuation. */
|
|
225
395
|
tel() { this._type = 'tel'; return this.rule({ name: 'tel', passes: (v) => /^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\.\/0-9]*$/.test(String(v)), message: (a) => `The ${a} field format is invalid.` }); }
|
|
226
|
-
email(
|
|
396
|
+
email(condition = true) {
|
|
397
|
+
if (!condition) {
|
|
398
|
+
if (this._type === 'email')
|
|
399
|
+
this._type = null;
|
|
400
|
+
this._rules = this._rules.filter((rule) => rule.name !== 'email');
|
|
401
|
+
return this;
|
|
402
|
+
}
|
|
403
|
+
this._type = 'email';
|
|
404
|
+
return this.rule({ name: 'email', passes: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v)), message: (a) => `The ${a} field must be a valid email address.` });
|
|
405
|
+
}
|
|
227
406
|
/** Web address: `url` input type, a valid http(s) value is required. */
|
|
228
407
|
url() { this._type = 'url'; return this.rule({ name: 'url', passes: (v) => { try {
|
|
229
408
|
return ['http:', 'https:'].includes(new URL(String(v)).protocol);
|
|
@@ -236,6 +415,18 @@ export class TextInput extends Field {
|
|
|
236
415
|
async toHtml() {
|
|
237
416
|
const isDisabled = await this.isDisabled();
|
|
238
417
|
const revealable = this._isPassword && this._isRevealable;
|
|
418
|
+
// Affixes: label, icon and actions (only visible actions, mounted through this component).
|
|
419
|
+
const ctx = this.context();
|
|
420
|
+
const key = this.getKey();
|
|
421
|
+
const visibleActions = async (actions) => { const html = []; for (const action of actions)
|
|
422
|
+
if (await action.isVisible(ctx))
|
|
423
|
+
html.push(await action.iconButton().size('sm').toHtml(ctx, { schemaComponent: key })); return html; };
|
|
424
|
+
const prefixActions = await visibleActions(this._prefixActions);
|
|
425
|
+
const copyLabel = t('filament-forms::components.text_input.actions.copy.label');
|
|
426
|
+
const copyMessage = this._copyable?.message ?? t('filament-forms::components.text_input.actions.copy.message');
|
|
427
|
+
const suffixActions = [...(await visibleActions(this._suffixActions)), ...(this._copyable ? [`<button title="${e(copyLabel)}" aria-label="${e(copyLabel)}" type="button" class="fi-ac-icon-btn-action fi-icon-btn fi-size-sm" x-on:click="${e(`window.navigator.clipboard.writeText($el.closest('.fi-input-wrp').querySelector('input').value)\n$tooltip(${js(copyMessage)}, { theme: $store.theme, timeout: ${this._copyable.duration} })`)}">${icon('m-clipboard-document-list')}</button>`] : [])];
|
|
428
|
+
const hasPrefix = this._prefix !== null || this._prefixIcon !== null || prefixActions.length > 0;
|
|
429
|
+
const hasSuffix = this._suffix !== null || this._suffixIcon !== null || suffixActions.length > 0;
|
|
239
430
|
const type = revealable ? 'password' : this._mask !== null ? 'text' : (this._type ?? (this._isPassword ? 'password' : this._numeric ? 'number' : 'text'));
|
|
240
431
|
const model = `wire:model${this._live ? '.live' : ''}${this._live === 'blur' ? '.blur' : ''}`;
|
|
241
432
|
const input = new Attributes()
|
|
@@ -243,16 +434,19 @@ export class TextInput extends Field {
|
|
|
243
434
|
'aria-invalid': this.hasError() ? 'true' : null,
|
|
244
435
|
'aria-describedby': this.getHelperTextId(),
|
|
245
436
|
autocomplete: this._autocomplete, autofocus: this._autofocus, disabled: isDisabled, id: this.getId(),
|
|
246
|
-
|
|
437
|
+
autocapitalize: this._autocapitalize,
|
|
438
|
+
inputmode: this._inputMode ?? (this._numeric === 'decimal' ? 'decimal' : this._numeric === 'integer' ? 'numeric' : null),
|
|
439
|
+
list: this._datalist.length && !isDisabled && !this._readOnly ? `${this.getId()}-list` : null,
|
|
440
|
+
readonly: this._readOnly,
|
|
247
441
|
max: phpNumber(this._maxValue), min: phpNumber(this._minValue),
|
|
248
442
|
maxlength: this._maxLength, minlength: this._minLength, placeholder: this._placeholder,
|
|
249
|
-
required: (await this.isRequired()) && !isDisabled, step: this._step ?? (this._numeric === 'decimal' ? 'any' : this._numeric === 'integer' ? 1 : null), type,
|
|
443
|
+
required: (await this.isRequired()) && !isDisabled && !this._readOnly, step: this._step ?? (this._numeric === 'decimal' ? 'any' : this._numeric === 'integer' ? 1 : null), type,
|
|
250
444
|
// Modifiers (`.live`, `.blur`) also apply to `wire:model.fill`.
|
|
251
445
|
[revealable ? model.replace('wire:model', 'wire:model.fill') : model]: this.getStatePath(),
|
|
252
446
|
'x-bind:type': revealable ? "isPasswordRevealed ? 'text' : 'password'" : null,
|
|
253
447
|
'x-mask': this._mask,
|
|
254
448
|
})
|
|
255
|
-
.class(['fi-input', { 'fi-revealable': revealable }]);
|
|
449
|
+
.class(['fi-input', { 'fi-input-has-inline-prefix': this._prefixInline && hasPrefix, 'fi-input-has-inline-suffix': this._suffixInline && hasSuffix, 'fi-revealable': revealable }]);
|
|
256
450
|
const wrapper = new Attributes()
|
|
257
451
|
.merge({
|
|
258
452
|
'x-data': revealable ? 'passwordRevealFormComponent()' : this._mask !== null ? '{}' : null,
|
|
@@ -263,49 +457,58 @@ export class TextInput extends Field {
|
|
|
263
457
|
.class(['fi-fo-text-input', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
264
458
|
return this.wrap(views.render('filament-forms::components.text-input', {
|
|
265
459
|
input: input.toHtml(), wrapper: wrapper.toHtml(), revealable, prefix: this._prefix, suffix: this._suffix,
|
|
460
|
+
prefixIcon: this._prefixIcon ? icon(this._prefixIcon, { attributes: new Attributes().class(this._prefixIconColor && this._prefixIconColor !== 'gray' ? ['fi-color', `fi-color-${this._prefixIconColor}`] : []) }) : '',
|
|
461
|
+
suffixIcon: this._suffixIcon ? icon(this._suffixIcon, { attributes: new Attributes().class(this._suffixIconColor && this._suffixIconColor !== 'gray' ? ['fi-color', `fi-color-${this._suffixIconColor}`] : []) }) : '',
|
|
462
|
+
prefixActions, suffixActions, hasPrefix, hasSuffix, prefixInline: this._prefixInline, suffixInline: this._suffixInline,
|
|
463
|
+
datalist: this._datalist.length && !isDisabled && !this._readOnly ? { id: `${this.getId()}-list`, options: this._datalist } : null,
|
|
266
464
|
showLabel: t('filament-forms::components.text_input.actions.show_password.label'),
|
|
267
465
|
hideLabel: t('filament-forms::components.text_input.actions.hide_password.label'),
|
|
268
466
|
}));
|
|
269
467
|
}
|
|
270
468
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
469
|
+
const flattenOptions = (groups) => groups.flatMap((item) => (Array.isArray(item) ? [item] : item.options));
|
|
470
|
+
function normalizeOptions(options) {
|
|
471
|
+
const entries = Array.isArray(options) ? options : Object.entries(options);
|
|
472
|
+
return entries.map(([key, value]) => {
|
|
473
|
+
if (value !== null && typeof value === 'object')
|
|
474
|
+
return { label: String(key), options: (Array.isArray(value) ? value : Object.entries(value)).map(([v, l]) => [String(v), String(l)]) };
|
|
475
|
+
return [String(key), String(value)];
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
/** Objection model of the form (the host's data adapter), for relationships. */
|
|
479
|
+
function modelOfForm(ctx, explicit) {
|
|
480
|
+
if (explicit)
|
|
481
|
+
return explicit;
|
|
482
|
+
const host = ctx.livewire;
|
|
483
|
+
const adapter = host.getDataAdapter?.() ?? host.getDataAdapterForTable?.() ?? host.resource?.adapter ?? null;
|
|
484
|
+
const model = adapter?.model;
|
|
485
|
+
if (typeof model !== 'function' || typeof model.getRelation !== 'function')
|
|
486
|
+
throw new Error('relationship(): the form has no Objection model; pass it with relationship(name, title, modify, { model }).');
|
|
487
|
+
return model;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Copy of a component and of the components it holds (layout children, item schemas), so that each repeated item gets its own
|
|
491
|
+
* instances: components keep a reference to their container.
|
|
492
|
+
*/
|
|
493
|
+
export function cloneComponentTree(component) {
|
|
494
|
+
const copy = Object.assign(Object.create(Object.getPrototypeOf(component)), component);
|
|
495
|
+
for (const [property, value] of Object.entries(copy)) {
|
|
496
|
+
if (value instanceof Component)
|
|
497
|
+
copy[property] = cloneComponentTree(value);
|
|
498
|
+
else if (Array.isArray(value) && value.length && value.every((item) => item instanceof Component))
|
|
499
|
+
copy[property] = value.map((item) => cloneComponentTree(item));
|
|
500
|
+
}
|
|
501
|
+
return copy;
|
|
502
|
+
}
|
|
503
|
+
/** Prefix or suffix of an input wrapper: actions, icon and label (escaped). */
|
|
504
|
+
function inputAffixHtml(side, options) {
|
|
505
|
+
if (options.label === null && !options.icon && !options.actions.length)
|
|
506
|
+
return '';
|
|
507
|
+
const label = options.label !== null ? `<span class="fi-input-wrp-label">${e(options.label)}</span>` : '';
|
|
508
|
+
const actions = options.actions.length ? `<div class="fi-input-wrp-actions">${options.actions.join('')}</div>` : '';
|
|
509
|
+
const focus = options.label !== null || options.icon ? ` x-on:click="if (! $event.target.closest('.fi-input-wrp-actions')) $dispatch('focus-input')"` : '';
|
|
510
|
+
const classes = cls([`fi-input-wrp-${side}`, side === 'prefix' ? 'fi-input-wrp-prefix-has-content' : null, { 'fi-inline': options.inline, [`fi-input-wrp-${side}-has-label`]: options.label !== null }]);
|
|
511
|
+
return `<div${focus} class="${classes}">${side === 'prefix' ? actions + options.icon + label : label + options.icon + actions}</div>`;
|
|
309
512
|
}
|
|
310
513
|
/**
|
|
311
514
|
* Dropdown. Native (`<select>`) by default; `searchable()` switches to the JavaScript select,
|
|
@@ -315,6 +518,15 @@ export class Select extends Field {
|
|
|
315
518
|
_options = [];
|
|
316
519
|
_placeholder = null;
|
|
317
520
|
_searchable = false;
|
|
521
|
+
_native = true;
|
|
522
|
+
_allowHtml = false;
|
|
523
|
+
_maxItems = null;
|
|
524
|
+
_minItems = null;
|
|
525
|
+
_optionsLimit = 50;
|
|
526
|
+
_searchDebounce = 1000;
|
|
527
|
+
_messages = {};
|
|
528
|
+
_relationship = null;
|
|
529
|
+
_affixes = { prefix: null, suffix: null, prefixIcon: null, suffixIcon: null, prefixIconColor: null, suffixIconColor: null, prefixActions: [], suffixActions: [], prefixInline: false, suffixInline: false };
|
|
318
530
|
_dynamic = null;
|
|
319
531
|
_searchOnServer = null;
|
|
320
532
|
_live = false;
|
|
@@ -335,6 +547,97 @@ export class Select extends Field {
|
|
|
335
547
|
* `searchOnServer: false`: search runs in the browser, among the options already sent (for a complete list).
|
|
336
548
|
*/
|
|
337
549
|
options(options, config = {}) { this._options = options; this._dynamic = config.dynamic ?? null; this._searchOnServer = config.searchOnServer ?? null; return this; }
|
|
550
|
+
/** "Yes" / "No" options, saved as `1` / `0`. */
|
|
551
|
+
boolean(trueLabel = null, falseLabel = null, placeholder = null) {
|
|
552
|
+
this._options = [['1', trueLabel ?? t('filament-forms::components.select.boolean.true')], ['0', falseLabel ?? t('filament-forms::components.select.boolean.false')]];
|
|
553
|
+
if (placeholder !== null)
|
|
554
|
+
this._placeholder = placeholder;
|
|
555
|
+
return this;
|
|
556
|
+
}
|
|
557
|
+
/** `false`: the JavaScript select, even without search. */
|
|
558
|
+
native(condition = true) { this._native = condition; return this; }
|
|
559
|
+
/** Option labels are HTML (sanitized on the server; JavaScript select only). */
|
|
560
|
+
allowHtml(condition = true) { this._allowHtml = condition; return this; }
|
|
561
|
+
/** Maximum (and minimum) number of selected items of a multiple select, checked on the server. */
|
|
562
|
+
maxItems(count) { this._maxItems = count; return this; }
|
|
563
|
+
minItems(count) { this._minItems = count; return this; }
|
|
564
|
+
/** Number of options shown at once in the JavaScript select (50 by default). */
|
|
565
|
+
optionsLimit(limit) { this._optionsLimit = Math.max(1, Math.floor(limit)); return this; }
|
|
566
|
+
searchDebounce(ms) { this._searchDebounce = Math.max(0, Math.floor(ms)); return this; }
|
|
567
|
+
loadingMessage(message) { this._messages.loading = message; return this; }
|
|
568
|
+
noSearchResultsMessage(message) { this._messages.noSearchResults = message; return this; }
|
|
569
|
+
searchPrompt(message) { this._messages.searchPrompt = message; return this; }
|
|
570
|
+
searchingMessage(message) { this._messages.searching = message; return this; }
|
|
571
|
+
noOptionsMessage(message) { this._messages.noOptions = message; return this; }
|
|
572
|
+
prefix(label, isInline = false) { this._affixes.prefix = label; this._affixes.prefixInline = isInline; return this; }
|
|
573
|
+
suffix(label, isInline = false) { this._affixes.suffix = label; this._affixes.suffixInline = isInline; return this; }
|
|
574
|
+
prefixIcon(name, isInline = false) { this._affixes.prefixIcon = name; this._affixes.prefixInline = isInline; return this; }
|
|
575
|
+
suffixIcon(name, isInline = false) { this._affixes.suffixIcon = name; this._affixes.suffixInline = isInline; return this; }
|
|
576
|
+
prefixIconColor(color) { this._affixes.prefixIconColor = color; return this; }
|
|
577
|
+
suffixIconColor(color) { this._affixes.suffixIconColor = color; return this; }
|
|
578
|
+
prefixAction(action, isInline = false) { this._affixes.prefixActions = [...this._affixes.prefixActions, action]; this._affixes.prefixInline = isInline; return this; }
|
|
579
|
+
suffixAction(action, isInline = false) { this._affixes.suffixActions = [...this._affixes.suffixActions, action]; this._affixes.suffixInline = isInline; return this; }
|
|
580
|
+
prefixActions(actions) { this._affixes.prefixActions = actions; return this; }
|
|
581
|
+
suffixActions(actions) { this._affixes.suffixActions = actions; return this; }
|
|
582
|
+
/**
|
|
583
|
+
* Options from a relationship of the form's model (Objection `relationMappings`): a belongs-to relationship (the field is the
|
|
584
|
+
* foreign key, `Select.make('author_id').relationship('author', 'name')`), or a many-to-many one with `multiple()` (the field is
|
|
585
|
+
* named after the relationship, and saves the links once the record is saved). `modifyQueryUsing(query, search)` scopes the
|
|
586
|
+
* options; picked keys outside that scope are rejected. With `searchable()`, the options are searched on the server.
|
|
587
|
+
*/
|
|
588
|
+
relationship(name, titleAttribute = null, modifyQueryUsing = null, options = {}) {
|
|
589
|
+
this._relationship = { name, title: titleAttribute, modify: modifyQueryUsing, model: options.model ?? null };
|
|
590
|
+
return this;
|
|
591
|
+
}
|
|
592
|
+
relationInfo() {
|
|
593
|
+
const ctx = this.context();
|
|
594
|
+
const model = modelOfForm(ctx, this._relationship.model);
|
|
595
|
+
const relation = model.getRelation(this._relationship.name);
|
|
596
|
+
const related = relation.relatedModelClass;
|
|
597
|
+
const id = `${related.tableName}.${related.idColumn}`;
|
|
598
|
+
const title = `${related.tableName}.${this._relationship.title ?? related.idColumn}`;
|
|
599
|
+
const isMany = /ManyToMany/.test(relation.constructor?.name ?? '') || typeof relation.joinTable === 'string';
|
|
600
|
+
return { model, relation, related, id, title, isMany };
|
|
601
|
+
}
|
|
602
|
+
async relationshipOptions(search, keys, limit = 1000) {
|
|
603
|
+
const { related, id, title } = this.relationInfo();
|
|
604
|
+
const query = related.query();
|
|
605
|
+
this._relationship.modify?.(query, search ?? undefined);
|
|
606
|
+
if (keys !== null) {
|
|
607
|
+
if (!keys.length)
|
|
608
|
+
return [];
|
|
609
|
+
query.whereIn(id, keys.slice(0, 1000));
|
|
610
|
+
}
|
|
611
|
+
if (search)
|
|
612
|
+
query.where(title, 'like', `%${search.slice(0, 255)}%`);
|
|
613
|
+
const rows = await query.clearSelect().select({ __key: id, __title: title }).orderBy(title).limit(limit);
|
|
614
|
+
return rows.map((row) => [String(row.__key), String(row.__title ?? row.__key)]);
|
|
615
|
+
}
|
|
616
|
+
isManyRelationship() { return this._relationship !== null && this.relationInfo().isMany; }
|
|
617
|
+
async savesRelationships() { return (await super.savesRelationships()) || this.isManyRelationship(); }
|
|
618
|
+
async loadStateFromRelationships(record) {
|
|
619
|
+
if (!this.isManyRelationship())
|
|
620
|
+
return super.loadStateFromRelationships(record);
|
|
621
|
+
const { model, related } = this.relationInfo();
|
|
622
|
+
const rows = await model.relatedQuery(this._relationship.name).for(record[model.idColumn]).clearSelect().select(`${related.tableName}.${related.idColumn} as __key`);
|
|
623
|
+
return { state: rows.map((row) => String(row.__key)) };
|
|
624
|
+
}
|
|
625
|
+
/** Many-to-many: links the picked records and unlinks the others (only keys allowed by the relationship's query). */
|
|
626
|
+
async saveRelationships(record) {
|
|
627
|
+
if (!this.isManyRelationship())
|
|
628
|
+
return super.saveRelationships(record);
|
|
629
|
+
const { model, related } = this.relationInfo();
|
|
630
|
+
const key = record[model.idColumn];
|
|
631
|
+
const desired = new Set((await this.relationshipOptions(null, this.selectedValues())).map(([value]) => value));
|
|
632
|
+
const current = (await this.loadStateFromRelationships(record)).state;
|
|
633
|
+
const removed = current.filter((value) => !desired.has(value));
|
|
634
|
+
if (removed.length)
|
|
635
|
+
await model.relatedQuery(this._relationship.name).for(key).unrelate().whereIn(`${related.tableName}.${related.idColumn}`, removed);
|
|
636
|
+
for (const value of desired)
|
|
637
|
+
if (!current.includes(value))
|
|
638
|
+
await model.relatedQuery(this._relationship.name).for(key).relate(related.idColumn === 'id' && /^\d+$/.test(value) ? Number(value) : value);
|
|
639
|
+
await super.saveRelationships(record);
|
|
640
|
+
}
|
|
338
641
|
/** Disables an option depending on the context (e.g. already picked in another row). */
|
|
339
642
|
disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
|
|
340
643
|
/** Sends the state to the server as soon as it changes. */
|
|
@@ -356,33 +659,52 @@ export class Select extends Field {
|
|
|
356
659
|
async labelsOf(values) {
|
|
357
660
|
if (!values.length)
|
|
358
661
|
return [];
|
|
359
|
-
const known = this._optionLabelsUsing ? await this._optionLabelsUsing(values, this.context()) : await this.resolveOptions();
|
|
662
|
+
const known = this._relationship ? await this.relationshipOptions(null, values) : this._optionLabelsUsing ? await this._optionLabelsUsing(values, this.context()) : await this.resolveOptions();
|
|
360
663
|
const map = new Map((Array.isArray(known) ? known : Object.entries(known)).map(([value, label]) => [String(value), String(label)]));
|
|
361
664
|
return values.filter((value) => map.has(value)).map((value) => [value, map.get(value)]);
|
|
362
665
|
}
|
|
363
|
-
|
|
364
|
-
|
|
666
|
+
_preload = false;
|
|
667
|
+
/** Relationship with `searchable()`: loads the options with the form, instead of on search only. Other options are always loaded. */
|
|
668
|
+
preload(condition = true) { this._preload = condition; return this; }
|
|
365
669
|
hydrateState(value) {
|
|
366
670
|
if (this._multiple)
|
|
367
671
|
return (Array.isArray(value) ? value : []).map(String);
|
|
368
672
|
return value === null || value === undefined || value === '' ? null : String(value);
|
|
369
673
|
}
|
|
370
674
|
dehydrateState(state) { return this._multiple ? this.selectedValues() : state; }
|
|
371
|
-
|
|
675
|
+
/** Options with their groups (labels sanitized when `allowHtml()`). */
|
|
676
|
+
async resolveGroupedOptions() {
|
|
677
|
+
if (this._relationship)
|
|
678
|
+
return this._searchable && !this._preload ? [] : this.relationshipOptions(null, null);
|
|
372
679
|
const options = typeof this._options === 'function' ? await this._options(this.context()) : this._options;
|
|
373
|
-
|
|
680
|
+
const groups = normalizeOptions(options);
|
|
681
|
+
if (!this._allowHtml)
|
|
682
|
+
return groups;
|
|
683
|
+
const clean = ([value, label]) => [value, sanitizeHtml(label)];
|
|
684
|
+
return groups.map((item) => (Array.isArray(item) ? clean(item) : { label: item.label, options: item.options.map(clean) }));
|
|
685
|
+
}
|
|
686
|
+
async resolveOptions() { return flattenOptions(await this.resolveGroupedOptions()); }
|
|
687
|
+
itemCountRules() {
|
|
688
|
+
const count = (v) => (Array.isArray(v) ? new Set(v.map(String)).size : 0);
|
|
689
|
+
return [
|
|
690
|
+
...(this._minItems !== null ? [{ name: 'min', passes: (v) => count(v) >= this._minItems, message: (a) => `The ${a} field must have at least ${this._minItems} items.` }] : []),
|
|
691
|
+
...(this._maxItems !== null ? [{ name: 'max', passes: (v) => count(v) <= this._maxItems, message: (a) => `The ${a} field must not have more than ${this._maxItems} items.` }] : []),
|
|
692
|
+
];
|
|
374
693
|
}
|
|
375
694
|
async getValidationRules() {
|
|
376
695
|
// With server search, a value is valid if the server can label it; otherwise it must be among the options.
|
|
377
696
|
if (this._multiple)
|
|
378
|
-
return [...(await super.getValidationRules()), { name: 'in', passes: async (v) => { const values = Array.isArray(v) ? v.map(String) : []; return (await this.labelsOf([...new Set(values)])).length === new Set(values).size; }, message: (a) => `The selected ${a} is invalid.` }];
|
|
379
|
-
const passes = async (v) => (this._searchResultsUsing ? (await this._optionLabelUsing?.(String(v), this.context())) != null : (await this.resolveOptions()).some(([value]) => value === String(v)));
|
|
697
|
+
return [...(await super.getValidationRules()), ...this.itemCountRules(), { name: 'in', passes: async (v) => { const values = Array.isArray(v) ? v.map(String) : []; return (await this.labelsOf([...new Set(values)])).length === new Set(values).size; }, message: (a) => `The selected ${a} is invalid.` }];
|
|
698
|
+
const passes = async (v) => (this._relationship ? (await this.relationshipOptions(null, [String(v)])).length === 1 : this._searchResultsUsing ? (await this._optionLabelUsing?.(String(v), this.context())) != null : (await this.resolveOptions()).some(([value]) => value === String(v)));
|
|
380
699
|
return [...(await super.getValidationRules()), { name: 'in', passes, message: (a) => `The selected ${a} is invalid.` }];
|
|
381
700
|
}
|
|
382
701
|
// Methods exposed to the select's JavaScript (called through callSchemaComponentMethod).
|
|
383
702
|
getExposedMethods() { return ['getOptionLabel', 'getSearchResultsForJs', 'getOptionsForJs', 'getOptionLabelsForJs']; }
|
|
384
703
|
static forJs(options) { return options.map(([value, label]) => ({ label, value, isDisabled: false })); }
|
|
385
|
-
|
|
704
|
+
groupsForJs(groups) {
|
|
705
|
+
return groups.map((item) => (Array.isArray(item) ? this.withDisabled(Select.forJs([item]))[0] : { label: item.label, options: this.withDisabled(Select.forJs(item.options)) }));
|
|
706
|
+
}
|
|
707
|
+
async getOptionsForJs() { return this.groupsForJs(await this.resolveGroupedOptions()); }
|
|
386
708
|
withDisabled(options) {
|
|
387
709
|
if (!this._disableOptionWhen)
|
|
388
710
|
return options;
|
|
@@ -390,17 +712,21 @@ export class Select extends Field {
|
|
|
390
712
|
return options.map((option) => ({ ...option, isDisabled: this._disableOptionWhen(option.value, ctx) }));
|
|
391
713
|
}
|
|
392
714
|
async getSearchResultsForJs(search = '') {
|
|
715
|
+
if (this._relationship)
|
|
716
|
+
return this.withDisabled(Select.forJs(await this.relationshipOptions(String(search ?? '').slice(0, 1000), null, this._optionsLimit)));
|
|
393
717
|
if (this._searchResultsUsing) {
|
|
394
718
|
const found = await this._searchResultsUsing(String(search ?? '').slice(0, 1000), this.context());
|
|
395
719
|
return Select.forJs((Array.isArray(found) ? found : Object.entries(found)).map(([value, label]) => [String(value), String(label)]));
|
|
396
720
|
}
|
|
397
721
|
const needle = String(search ?? '').toLowerCase();
|
|
398
|
-
return Select.forJs((await this.resolveOptions()).filter(([, label]) => label.toLowerCase().includes(needle)));
|
|
722
|
+
return this.withDisabled(Select.forJs((await this.resolveOptions()).filter(([, label]) => label.toLowerCase().includes(needle))));
|
|
399
723
|
}
|
|
400
724
|
async getOptionLabel() {
|
|
401
725
|
const state = this.context().state;
|
|
402
726
|
if (state === null || state === undefined || state === '')
|
|
403
727
|
return null;
|
|
728
|
+
if (this._relationship)
|
|
729
|
+
return (await this.relationshipOptions(null, [String(state)]))[0]?.[1] ?? null;
|
|
404
730
|
if (this._optionLabelUsing)
|
|
405
731
|
return this._optionLabelUsing(String(state), this.context());
|
|
406
732
|
return (await this.resolveOptions()).find(([value]) => value === String(state))?.[1] ?? null;
|
|
@@ -449,9 +775,16 @@ export class Select extends Field {
|
|
|
449
775
|
.modalSubmitActionLabel(tr('modal.actions.create.label'))
|
|
450
776
|
.schema(this._createOptionForm)
|
|
451
777
|
.action(async ({ data }) => {
|
|
452
|
-
|
|
778
|
+
let created;
|
|
779
|
+
if (this._createOptionUsing)
|
|
780
|
+
created = await this._createOptionUsing(data, this.context());
|
|
781
|
+
else if (this._relationship) {
|
|
782
|
+
const { related } = this.relationInfo();
|
|
783
|
+
const record = await related.query().insert(data);
|
|
784
|
+
created = record[related.idColumn];
|
|
785
|
+
}
|
|
786
|
+
else
|
|
453
787
|
throw new Error(`Select [${this.getStatePath()}]: createOptionUsing() is required with createOptionForm().`);
|
|
454
|
-
const created = await this._createOptionUsing(data, this.context());
|
|
455
788
|
if (created === null || created === undefined || created === '')
|
|
456
789
|
return;
|
|
457
790
|
this.setState(this._multiple ? [...new Set([...this.selectedValues(), String(created)])] : String(created));
|
|
@@ -481,36 +814,53 @@ export class Select extends Field {
|
|
|
481
814
|
return this._modifyEditOptionAction?.(action) ?? action;
|
|
482
815
|
}
|
|
483
816
|
/** Actions mounted on the field: hint, create option and edit option. */
|
|
484
|
-
getActions() { return [...this.getHintActions(), ...[this.getCreateOptionAction(), this.getEditOptionAction()].filter((action) => action !== null)]; }
|
|
485
|
-
|
|
817
|
+
getActions() { return [...this.getHintActions(), ...this._affixes.prefixActions, ...this._affixes.suffixActions, ...[this.getCreateOptionAction(), this.getEditOptionAction()].filter((action) => action !== null)]; }
|
|
818
|
+
affixIcon(name, color) { return name ? icon(name, { attributes: new Attributes().class(color && color !== 'gray' ? ['fi-color', `fi-color-${color}`] : []) }) : ''; }
|
|
819
|
+
async affixActionsHtml(actions) {
|
|
820
|
+
const ctx = this.context();
|
|
821
|
+
const html = [];
|
|
822
|
+
for (const action of actions)
|
|
823
|
+
if (await action.isVisible(ctx))
|
|
824
|
+
html.push(await action.iconButton().size('sm').toHtml(ctx, { schemaComponent: this.getKey() }));
|
|
825
|
+
return html;
|
|
826
|
+
}
|
|
827
|
+
async prefixHtml() {
|
|
828
|
+
const a = this._affixes;
|
|
829
|
+
return inputAffixHtml('prefix', { label: a.prefix, icon: this.affixIcon(a.prefixIcon, a.prefixIconColor), actions: await this.affixActionsHtml(a.prefixActions), inline: a.prefixInline });
|
|
830
|
+
}
|
|
831
|
+
/** Suffix: label, icon, actions, then the "+" and "edit" buttons. */
|
|
486
832
|
async suffixHtml() {
|
|
487
833
|
const ctx = this.context();
|
|
488
834
|
const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: this.getKey() };
|
|
489
|
-
const buttons =
|
|
835
|
+
const buttons = await this.affixActionsHtml(this._affixes.suffixActions);
|
|
490
836
|
for (const action of [this.getCreateOptionAction(), this.getEditOptionAction()]) {
|
|
491
837
|
if (action && (await action.isVisible(ctx)))
|
|
492
838
|
buttons.push((await action.toLinkHtml(ctx, context, '')).replace(' wire:key=""', ''));
|
|
493
839
|
}
|
|
494
|
-
|
|
840
|
+
const a = this._affixes;
|
|
841
|
+
return inputAffixHtml('suffix', { label: a.suffix, icon: this.affixIcon(a.suffixIcon, a.suffixIconColor), actions: buttons, inline: a.suffixInline });
|
|
495
842
|
}
|
|
496
843
|
async toHtml() {
|
|
497
844
|
const isDisabled = await this.isDisabled();
|
|
498
|
-
const
|
|
845
|
+
const grouped = await this.resolveGroupedOptions();
|
|
846
|
+
const resolved = flattenOptions(grouped);
|
|
499
847
|
const suffix = await this.suffixHtml();
|
|
500
|
-
|
|
848
|
+
const prefix = await this.prefixHtml();
|
|
849
|
+
if (this._searchable || this._multiple || !this._native || this._allowHtml) {
|
|
501
850
|
const isRequired = (await this.isRequired()) && !isDisabled;
|
|
502
851
|
const after = isRequired ? `<span id="${e(this.getId())}-required" class="fi-sr-only">${e(t('filament-forms::components.select.required_description'))}</span>` : '';
|
|
503
|
-
return this.wrap(await this.toJsHtml(
|
|
852
|
+
return this.wrap(await this.toJsHtml(grouped, isDisabled, suffix, prefix), { wrapperClass: 'fi-fo-select-wrp', after });
|
|
504
853
|
}
|
|
505
854
|
const select = new Attributes()
|
|
506
855
|
.merge({ 'aria-invalid': this.hasError() ? 'true' : null, disabled: isDisabled, id: this.getId(), required: (await this.isRequired()) && !isDisabled, 'wire:key': typeof this._options === 'function' ? `${this.getLivewireKey()}.${createHash('md5').update(JSON.stringify(resolved)).digest('hex')}` : null, [this._live ? 'wire:model.live' : 'wire:model']: this.getStatePath() })
|
|
507
856
|
.class(['fi-select-input']);
|
|
508
857
|
const wrapper = new Attributes().merge({ 'x-on:focus-input.stop': "$el.querySelector('select')?.focus()" }).class(['fi-fo-select', 'fi-fo-select-native', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
509
|
-
const
|
|
510
|
-
|
|
858
|
+
const option = ([value, label]) => `<option value="${e(value)}"${this._disableOptionWhen?.(value, this.context()) ? ' disabled' : ''}>${e(label)}</option>`;
|
|
859
|
+
const options = [this._selectablePlaceholder ? `<option value="">${isDisabled ? '' : e(this._placeholder ?? t('filament-forms::components.select.placeholder'))}</option>` : '', ...grouped.map((item) => (Array.isArray(item) ? option(item) : `<optgroup label="${e(item.label)}">${item.options.map(option).join('')}</optgroup>`))].join('');
|
|
860
|
+
return this.wrap(`<div ${wrapper.toHtml()}>${prefix}<div class="fi-input-wrp-content-ctn"><select ${select.toHtml()}>${options}</select></div>${suffix}</div>`, { wrapperClass: 'fi-fo-select-wrp' });
|
|
511
861
|
}
|
|
512
862
|
/** JavaScript select rendering. */
|
|
513
|
-
async toJsHtml(
|
|
863
|
+
async toJsHtml(grouped, isDisabled, suffix = '', prefix = '') {
|
|
514
864
|
const state = this.context().state;
|
|
515
865
|
const hasState = !this._multiple && state !== null && state !== undefined && state !== '';
|
|
516
866
|
const selected = this._multiple ? await this.labelsOf(this.selectedValues()) : [];
|
|
@@ -518,7 +868,7 @@ export class Select extends Field {
|
|
|
518
868
|
// Required field: a screen-reader instruction ("Select an option.") is linked to the select.
|
|
519
869
|
const describedBy = [isRequired ? `${this.getId()}-required` : null, this.hasError() ? `${this.getId()}-error` : null].filter(Boolean).join(' ') || null;
|
|
520
870
|
const accessibility = JSON.stringify({ 'aria-describedby': describedBy, 'aria-invalid': this.hasError() ? 'true' : null, 'aria-required': isRequired ? 'true' : null });
|
|
521
|
-
const forJs = this.
|
|
871
|
+
const forJs = this.groupsForJs(grouped);
|
|
522
872
|
// Dynamic options come from a function, so they may change between requests.
|
|
523
873
|
const hasDynamicOptions = this._dynamic ?? typeof this._options === 'function';
|
|
524
874
|
const tr = (key, replace = {}) => t(`filament-forms::components.select.${key}`, replace);
|
|
@@ -528,41 +878,41 @@ export class Select extends Field {
|
|
|
528
878
|
clearButtonLabel: ${js(tr('actions.clear.label'))},
|
|
529
879
|
errorMessage: ${js(tr('error_message'))},
|
|
530
880
|
hasDynamicOptions: ${hasDynamicOptions ? 'true' : 'false'},
|
|
531
|
-
hasDynamicSearchResults: ${this._searchResultsUsing || (this._searchOnServer ?? hasDynamicOptions) ? 'true' : 'false'},
|
|
532
|
-
hasInitialNoOptionsMessage: ${this._searchResultsUsing || this._hasNoInitialOptions ? 'false' : 'true'},
|
|
881
|
+
hasDynamicSearchResults: ${this._searchResultsUsing || (this._relationship && this._searchable) || (this._searchOnServer ?? hasDynamicOptions) ? 'true' : 'false'},
|
|
882
|
+
hasInitialNoOptionsMessage: ${this._searchResultsUsing || (this._relationship && this._searchable && !this._preload) || this._hasNoInitialOptions ? 'false' : 'true'},
|
|
533
883
|
id: ${js(this.getId())},
|
|
534
884
|
initialOptionLabel: ${js(hasState ? await this.getOptionLabel() : null)},
|
|
535
885
|
initialOptionLabels: ${selected.length ? js(selected.map(([value, label]) => ({ label, value, isDisabled: false }))) : '[]'},
|
|
536
886
|
initialState: ${this._multiple ? (selected.length ? js(selected.map(([value]) => value)) : '[]') : js(hasState ? String(state) : null)},
|
|
537
887
|
isAutofocused: ${this._autofocus ? 'true' : 'false'},
|
|
538
888
|
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
539
|
-
isHtmlAllowed: false,
|
|
889
|
+
isHtmlAllowed: ${this._allowHtml ? 'true' : 'false'},
|
|
540
890
|
isMultiple: ${this._multiple ? 'true' : 'false'},
|
|
541
891
|
isReorderable: false,
|
|
542
|
-
isSearchable: true,
|
|
892
|
+
isSearchable: ${this._searchable ? 'true' : 'false'},
|
|
543
893
|
key: ${js(this.getKey())},
|
|
544
894
|
livewireId: ${js(this.livewire.id)},
|
|
545
|
-
loadingMessage: ${js(tr('loading_message'))},
|
|
546
|
-
maxItems: null,
|
|
547
|
-
maxItemsMessage: ${js(tr('max_items_message', { count: '' }))},
|
|
548
|
-
noOptionsMessage: ${js(tr('no_options_message'))},
|
|
549
|
-
noSearchResultsMessage: ${js(tr('no_search_results_message'))},
|
|
895
|
+
loadingMessage: ${js(this._messages.loading ?? tr('loading_message'))},
|
|
896
|
+
maxItems: ${this._maxItems ?? 'null'},
|
|
897
|
+
maxItemsMessage: ${js(tr('max_items_message', { count: String(this._maxItems ?? '') }))},
|
|
898
|
+
noOptionsMessage: ${js(this._messages.noOptions ?? tr('no_options_message'))},
|
|
899
|
+
noSearchResultsMessage: ${js(this._messages.noSearchResults ?? tr('no_search_results_message'))},
|
|
550
900
|
options: ${forJs.length ? js(forJs) : '[]'},
|
|
551
|
-
optionsLimit:
|
|
901
|
+
optionsLimit: ${this._optionsLimit},
|
|
552
902
|
placeholder: ${js(this._placeholder ?? tr('placeholder'))},
|
|
553
903
|
position: null,
|
|
554
904
|
removeButtonLabel: ${js(tr('actions.remove_option.label'))},
|
|
555
|
-
searchDebounce:
|
|
556
|
-
searchingMessage: ${js(tr('searching_message'))},
|
|
905
|
+
searchDebounce: ${this._searchDebounce},
|
|
906
|
+
searchingMessage: ${js(this._messages.searching ?? tr('searching_message'))},
|
|
557
907
|
searchLabel: ${js(tr('search_label'))},
|
|
558
|
-
searchPrompt: ${js(tr('search_prompt'))},
|
|
908
|
+
searchPrompt: ${js(this._messages.searchPrompt ?? tr('search_prompt'))},
|
|
559
909
|
searchableOptionFields: ${js(['label'])},
|
|
560
910
|
state: $wire.$entangle(${js(this.getStatePath())}, ${this._live ? 'true' : 'false'}),
|
|
561
911
|
statePath: ${js(this.getStatePath())},
|
|
562
912
|
})`;
|
|
563
913
|
const wrapper = new Attributes().merge({ 'x-on:focus-input.stop': "$el.querySelector('.fi-select-input-btn')?.focus()" }).class(['fi-fo-select', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
564
914
|
const key = `${this.getLivewireKey()}.${createHash('md5').update(JSON.stringify(forJs)).digest('hex')}`;
|
|
565
|
-
return `<div ${wrapper.toHtml()}
|
|
915
|
+
return `<div ${wrapper.toHtml()}>${prefix}<div class="fi-input-wrp-content-ctn"><div class="fi-hidden" data-select-accessibility="${e(accessibility)}" x-data="{
|
|
566
916
|
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
567
917
|
init() {
|
|
568
918
|
const container = $el.nextElementSibling
|
|
@@ -587,28 +937,106 @@ export class TableColumn {
|
|
|
587
937
|
const ITEM_KEY = /^[A-Za-z0-9_-]{1,64}$/;
|
|
588
938
|
const isItemKey = (key) => ITEM_KEY.test(key) && key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
|
|
589
939
|
/**
|
|
590
|
-
* List of repeated items sharing the same fields
|
|
940
|
+
* List of repeated items sharing the same fields: stacked cards (default), a table (`table()`), or one field per item (`simple()`).
|
|
591
941
|
* The state is a map `item key → fields`; key order is display order.
|
|
592
|
-
* Add, delete and move are component actions, mounted with `schemaComponent`.
|
|
942
|
+
* Add, delete, clone and move are component actions, mounted with `schemaComponent`.
|
|
593
943
|
*/
|
|
594
944
|
export class Repeater extends Field {
|
|
595
945
|
_schema = () => [];
|
|
596
946
|
_columns = [];
|
|
597
947
|
_defaultItems = 0;
|
|
598
948
|
_maxItems = 500;
|
|
949
|
+
_minItems = null;
|
|
599
950
|
_itemLinks = [];
|
|
951
|
+
_simple = null;
|
|
952
|
+
_grid = null;
|
|
953
|
+
_collapsible = false;
|
|
954
|
+
_collapsed = false;
|
|
955
|
+
_persistCollapsed = false;
|
|
956
|
+
_cloneable = false;
|
|
957
|
+
_addable = true;
|
|
958
|
+
_deletable = true;
|
|
959
|
+
_reorderableWithButtons = false;
|
|
960
|
+
_itemLabel = null;
|
|
961
|
+
_itemNumbers = false;
|
|
962
|
+
_truncateItemLabel = true;
|
|
963
|
+
_headingTag = 'h4';
|
|
964
|
+
_addActionLabel = null;
|
|
965
|
+
_addActionAlignment = null;
|
|
966
|
+
_labelBetweenItems = null;
|
|
967
|
+
_itemHeaders = true;
|
|
968
|
+
_relationship = null;
|
|
600
969
|
static make(name) { return configured(new Repeater(name)); }
|
|
601
|
-
/** Item
|
|
602
|
-
schema(factory) { this._schema = factory; return this; }
|
|
970
|
+
/** Item components: a function called for each item. */
|
|
971
|
+
schema(factory) { this._schema = typeof factory === 'function' ? factory : () => factory.map(cloneComponentTree); return this; }
|
|
603
972
|
/** Table display, one column per field. */
|
|
604
973
|
table(columns) { this._columns = columns; return this; }
|
|
974
|
+
isTable() { return this._columns.length > 0; }
|
|
975
|
+
/**
|
|
976
|
+
* One field per item, without a card: the state is saved as a list of values (`['a@example.com', 'b@example.com']`).
|
|
977
|
+
* The field is cloned for each item.
|
|
978
|
+
*/
|
|
979
|
+
simple(field) { this._simple = field; return this; }
|
|
605
980
|
defaultItems(count) { this._defaultItems = count; return this; }
|
|
606
981
|
maxItems(count) { this._maxItems = count; return this; }
|
|
982
|
+
/** Minimum number of items, checked on the server. */
|
|
983
|
+
minItems(count) { this._minItems = count; return this; }
|
|
607
984
|
_reorderable = true;
|
|
608
985
|
/** `false`: items keep their order (e.g. a relationship without an order column). */
|
|
609
986
|
reorderable(condition = true) { this._reorderable = condition; return this; }
|
|
987
|
+
_dragAndDrop = true;
|
|
988
|
+
/** Move up / move down buttons in each item header, besides drag and drop. */
|
|
989
|
+
reorderableWithButtons(condition = true) { this._reorderableWithButtons = condition; return this; }
|
|
990
|
+
/** `false`: no drag and drop (buttons may still reorder, with `reorderableWithButtons()`). */
|
|
991
|
+
reorderableWithDragAndDrop(condition = true) { this._dragAndDrop = condition; return this; }
|
|
992
|
+
/** Reordering is off with `reorderable(false)`, and for a relationship without `orderColumn()` (the order could not be saved). */
|
|
993
|
+
isReorderable() { return this._reorderable && !(this._relationship !== null && this._relationship.orderColumn === null && this._orderColumnPending === null); }
|
|
994
|
+
canDrag() { return this.isReorderable() && this._dragAndDrop; }
|
|
995
|
+
canMove() { return this.isReorderable() && (this._dragAndDrop || this._reorderableWithButtons); }
|
|
610
996
|
/** Link shown on each row (an item action pointing to a URL). */
|
|
611
997
|
extraItemLink(resolver) { this._itemLinks.push(resolver); return this; }
|
|
998
|
+
/** Items laid out in a grid (`2`, or `{ md: 2, xl: 3 }`). */
|
|
999
|
+
grid(columns = 2) { this._grid = columns; return this; }
|
|
1000
|
+
/** Items can be collapsed from their header; `collapsed()` collapses them on load. */
|
|
1001
|
+
collapsible(condition = true) { this._collapsible = condition; return this; }
|
|
1002
|
+
collapsed(condition = true) { this._collapsed = condition; if (condition)
|
|
1003
|
+
this._collapsible = true; return this; }
|
|
1004
|
+
/** Keeps each item's collapsed state in the browser (local storage). */
|
|
1005
|
+
persistCollapsed(condition = true) { this._persistCollapsed = condition; return this; }
|
|
1006
|
+
cloneable(condition = true) { this._cloneable = condition; return this; }
|
|
1007
|
+
/** `false`: no "Add" button (and adding is refused on the server). */
|
|
1008
|
+
addable(condition = true) { this._addable = condition; return this; }
|
|
1009
|
+
/** `false`: no delete button (and deleting is refused on the server). */
|
|
1010
|
+
deletable(condition = true) { this._deletable = condition; return this; }
|
|
1011
|
+
/** Item header label: a string, or a function of the item's state (`({ state }) => state.name ?? null`). Escaped. */
|
|
1012
|
+
itemLabel(label) { this._itemLabel = label; return this; }
|
|
1013
|
+
/** Item number after the label. */
|
|
1014
|
+
itemNumbers(condition = true) { this._itemNumbers = condition; return this; }
|
|
1015
|
+
truncateItemLabel(condition = true) { this._truncateItemLabel = condition; return this; }
|
|
1016
|
+
/** `false`: items have no header (and so no buttons): only their fields. */
|
|
1017
|
+
itemHeaders(condition = true) { this._itemHeaders = condition; return this; }
|
|
1018
|
+
addActionLabel(label) { this._addActionLabel = label; return this; }
|
|
1019
|
+
addActionAlignment(alignment) { this._addActionAlignment = alignment; return this; }
|
|
1020
|
+
labelBetweenItems(label) { this._labelBetweenItems = label; return this; }
|
|
1021
|
+
/**
|
|
1022
|
+
* Items are the records of a has-many relationship of the form's model (Objection `relationMappings`): loaded when the form is
|
|
1023
|
+
* filled, then created, updated and deleted once the record is saved. `orderColumn()` saves the items' order.
|
|
1024
|
+
*/
|
|
1025
|
+
relationship(name = null, modifyQueryUsing = null, options = {}) {
|
|
1026
|
+
this._relationship = { name: name ?? this._name, modify: modifyQueryUsing, orderColumn: this._relationship?.orderColumn ?? null, model: options.model ?? null };
|
|
1027
|
+
return this;
|
|
1028
|
+
}
|
|
1029
|
+
orderColumn(column = 'sort') { if (this._relationship)
|
|
1030
|
+
this._relationship.orderColumn = column;
|
|
1031
|
+
else
|
|
1032
|
+
this._orderColumnPending = column; return this; }
|
|
1033
|
+
_orderColumnPending = null;
|
|
1034
|
+
itemComponents() {
|
|
1035
|
+
if (this._simple)
|
|
1036
|
+
return [cloneComponentTree(this._simple).hiddenLabel()];
|
|
1037
|
+
return this._schema();
|
|
1038
|
+
}
|
|
1039
|
+
itemFields() { return this.itemComponents().filter((component) => component instanceof Field); }
|
|
612
1040
|
/** Well-formed items only: keys come from the browser, so only simple keys are kept. */
|
|
613
1041
|
getItems() {
|
|
614
1042
|
const state = this.context().state;
|
|
@@ -617,28 +1045,118 @@ export class Repeater extends Field {
|
|
|
617
1045
|
return Object.fromEntries(Object.entries(state).filter(([key, item]) => isItemKey(key) && item !== null && typeof item === 'object' && !Array.isArray(item)).slice(0, this._maxItems));
|
|
618
1046
|
}
|
|
619
1047
|
setItems(items) { writePath(this.livewire, this.getStatePath().split('.'), items); }
|
|
620
|
-
itemDefaults() {
|
|
1048
|
+
itemDefaults() {
|
|
1049
|
+
const data = {};
|
|
1050
|
+
fillSchema(new Schema(this.container.livewire).statePath('').components(this.itemComponents()), data);
|
|
1051
|
+
return data;
|
|
1052
|
+
}
|
|
621
1053
|
getDefaultState() { return Object.fromEntries(Array.from({ length: this._defaultItems }, () => [randomUUID(), this.itemDefaults()])); }
|
|
622
|
-
/**
|
|
1054
|
+
/** A list (records → `record-<id>` items; simple values → one-field items) or a keyed map, limited to the declared fields. */
|
|
623
1055
|
hydrateState(value) {
|
|
1056
|
+
if (typeof value === 'string') {
|
|
1057
|
+
try {
|
|
1058
|
+
value = JSON.parse(value);
|
|
1059
|
+
}
|
|
1060
|
+
catch {
|
|
1061
|
+
value = null;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
624
1064
|
if (!Array.isArray(value))
|
|
625
1065
|
return value && typeof value === 'object' ? value : {};
|
|
626
|
-
const fields = this.
|
|
627
|
-
return Object.fromEntries(value.
|
|
1066
|
+
const fields = this.itemFields();
|
|
1067
|
+
return Object.fromEntries(value.slice(0, this._maxItems).map((row) => {
|
|
1068
|
+
if (this._simple)
|
|
1069
|
+
return [randomUUID(), { [this._simple.getName()]: this._simple.hydrateState(row) }];
|
|
1070
|
+
const key = row && typeof row === 'object' && row.id !== undefined && row.id !== null ? `record-${row.id}` : randomUUID();
|
|
1071
|
+
return [key, Object.fromEntries(fields.map((field) => [field.getName(), field.hydrateState(row?.[field.getName()] ?? field.getDefaultState())]))];
|
|
1072
|
+
}));
|
|
628
1073
|
}
|
|
629
1074
|
dehydrateState() { return Object.fromEntries(Object.keys(this.getItems()).map((key) => [key, {}])); }
|
|
1075
|
+
/** Simple repeater: saved as a list of values. */
|
|
1076
|
+
finalizeDehydratedState(state) {
|
|
1077
|
+
if (!this._simple || !state || typeof state !== 'object' || Array.isArray(state))
|
|
1078
|
+
return state;
|
|
1079
|
+
const name = this._simple.getName();
|
|
1080
|
+
return Object.values(state).map((item) => item?.[name] ?? null);
|
|
1081
|
+
}
|
|
630
1082
|
getChildSchemas() {
|
|
631
1083
|
const container = this.container;
|
|
632
|
-
return Object.keys(this.getItems()).map((key) => new Schema(container.livewire).key(`${this.getKey()}.${key}`).statePath(`${this.getStatePath()}.${key}`).operation(container.getOperation()).record(container.getRecord()).root(false).components(this.
|
|
1084
|
+
return Object.keys(this.getItems()).map((key) => new Schema(container.livewire).key(`${this.getKey()}.${key}`).statePath(`${this.getStatePath()}.${key}`).operation(container.getOperation()).record(container.getRecord()).root(false).parentComponent(this).columns(this._childColumns ?? 1).components(this.itemComponents()));
|
|
633
1085
|
}
|
|
1086
|
+
_childColumns = null;
|
|
1087
|
+
/** Grid columns inside each item. */
|
|
1088
|
+
columns(columns) { this._childColumns = columns; return this; }
|
|
634
1089
|
async getValidationRules() {
|
|
635
1090
|
const rules = [];
|
|
1091
|
+
const count = () => Object.keys(this.getItems()).length;
|
|
636
1092
|
if (await this.isRequired())
|
|
637
|
-
rules.push({ name: 'required', passes: () =>
|
|
1093
|
+
rules.push({ name: 'required', passes: () => count() > 0, message: (a) => `The ${a} field is required.` });
|
|
1094
|
+
if (this._minItems !== null)
|
|
1095
|
+
rules.push({ name: 'min', implicit: true, passes: () => count() >= this._minItems, message: (a) => `The ${a} field must have at least ${this._minItems} items.` });
|
|
1096
|
+
rules.push({ name: 'max', implicit: true, passes: () => count() <= this._maxItems, message: (a) => `The ${a} field must not have more than ${this._maxItems} items.` });
|
|
638
1097
|
return [...rules, ...this._rules];
|
|
639
1098
|
}
|
|
1099
|
+
// ---- relationship
|
|
1100
|
+
relationInfo() {
|
|
1101
|
+
const model = modelOfForm(this.context(), this._relationship.model);
|
|
1102
|
+
const relation = model.getRelation(this._relationship.name);
|
|
1103
|
+
return { model, relation, related: relation.relatedModelClass };
|
|
1104
|
+
}
|
|
1105
|
+
async savesRelationships() { return (await super.savesRelationships()) || this._relationship !== null; }
|
|
1106
|
+
relatedQuery(record) {
|
|
1107
|
+
const { model } = this.relationInfo();
|
|
1108
|
+
const query = model.relatedQuery(this._relationship.name).for(record[model.idColumn]);
|
|
1109
|
+
this._relationship.modify?.(query);
|
|
1110
|
+
return query;
|
|
1111
|
+
}
|
|
1112
|
+
async loadStateFromRelationships(record) {
|
|
1113
|
+
if (!this._relationship)
|
|
1114
|
+
return super.loadStateFromRelationships(record);
|
|
1115
|
+
if (this._orderColumnPending !== null) {
|
|
1116
|
+
this._relationship.orderColumn = this._orderColumnPending;
|
|
1117
|
+
this._orderColumnPending = null;
|
|
1118
|
+
}
|
|
1119
|
+
const { related } = this.relationInfo();
|
|
1120
|
+
const query = this.relatedQuery(record);
|
|
1121
|
+
if (this._relationship.orderColumn)
|
|
1122
|
+
query.orderBy(`${related.tableName}.${this._relationship.orderColumn}`);
|
|
1123
|
+
const rows = await query.limit(this._maxItems);
|
|
1124
|
+
return { state: rows.map((row) => ({ ...row, id: row[related.idColumn] })) };
|
|
1125
|
+
}
|
|
1126
|
+
/** Creates, updates and deletes the related records; only records of this owner (and query) are touched. */
|
|
1127
|
+
async saveRelationships(record) {
|
|
1128
|
+
if (!this._relationship)
|
|
1129
|
+
return super.saveRelationships(record);
|
|
1130
|
+
if (this._orderColumnPending !== null) {
|
|
1131
|
+
this._relationship.orderColumn = this._orderColumnPending;
|
|
1132
|
+
this._orderColumnPending = null;
|
|
1133
|
+
}
|
|
1134
|
+
const { related } = this.relationInfo();
|
|
1135
|
+
const existing = new Map((await this.relatedQuery(record)).map((row) => [String(row[related.idColumn]), row]));
|
|
1136
|
+
const kept = new Set();
|
|
1137
|
+
const keys = Object.keys(this.getItems());
|
|
1138
|
+
const schemas = this.getChildSchemas();
|
|
1139
|
+
for (const [index, key] of keys.entries()) {
|
|
1140
|
+
const data = await getSchemaState(schemas[index]);
|
|
1141
|
+
if (this._relationship.orderColumn)
|
|
1142
|
+
data[this._relationship.orderColumn] = index + 1;
|
|
1143
|
+
const id = key.startsWith('record-') ? key.slice('record-'.length) : null;
|
|
1144
|
+
if (id !== null && existing.has(id)) {
|
|
1145
|
+
kept.add(id);
|
|
1146
|
+
await related.query().findById(existing.get(id)[related.idColumn]).patch(data);
|
|
1147
|
+
}
|
|
1148
|
+
else {
|
|
1149
|
+
await this.relatedQuery(record).insert(data);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
const removed = [...existing.keys()].filter((id) => !kept.has(id));
|
|
1153
|
+
if (removed.length)
|
|
1154
|
+
await related.query().whereIn(related.idColumn, removed.map((id) => existing.get(id)[related.idColumn])).delete();
|
|
1155
|
+
}
|
|
640
1156
|
getActions() {
|
|
641
1157
|
const move = (offset) => ({ arguments: args }) => {
|
|
1158
|
+
if (!this.canMove())
|
|
1159
|
+
return;
|
|
642
1160
|
const items = this.getItems();
|
|
643
1161
|
const keys = Object.keys(items);
|
|
644
1162
|
const from = keys.indexOf(String(args.item));
|
|
@@ -648,16 +1166,26 @@ export class Repeater extends Field {
|
|
|
648
1166
|
[keys[from], keys[to]] = [keys[to], keys[from]];
|
|
649
1167
|
this.setItems(Object.fromEntries(keys.map((key) => [key, items[key]])));
|
|
650
1168
|
};
|
|
1169
|
+
const insertAfter = (items, after, key, item) => Object.fromEntries(Object.entries(items).flatMap(([k, v]) => (k === after ? [[k, v], [key, item]] : [[k, v]])));
|
|
651
1170
|
return [
|
|
652
1171
|
...this.getHintActions(),
|
|
653
|
-
Action.make('add').action(() => { const items = this.getItems(); if (Object.keys(items).length >= this._maxItems)
|
|
1172
|
+
Action.make('add').action(() => { const items = this.getItems(); if (!this._addable || Object.keys(items).length >= this._maxItems)
|
|
654
1173
|
return; items[randomUUID()] = this.itemDefaults(); this.setItems(items); }),
|
|
655
|
-
Action.make('delete').action(({ arguments: args }) => { const items = this.getItems(); const key = String(args.item); if (!Object.hasOwn(items, key))
|
|
1174
|
+
Action.make('delete').action(({ arguments: args }) => { const items = this.getItems(); const key = String(args.item); if (!this._deletable || !Object.hasOwn(items, key))
|
|
656
1175
|
return; delete items[key]; this.setItems(items); }),
|
|
1176
|
+
Action.make('clone').action(({ arguments: args }) => {
|
|
1177
|
+
const items = this.getItems();
|
|
1178
|
+
const key = String(args.item);
|
|
1179
|
+
if (!this._cloneable || !Object.hasOwn(items, key) || Object.keys(items).length >= this._maxItems)
|
|
1180
|
+
return;
|
|
1181
|
+
this.setItems(insertAfter(items, key, randomUUID(), JSON.parse(JSON.stringify(items[key]))));
|
|
1182
|
+
}),
|
|
657
1183
|
Action.make('moveUp').action(move(-1)),
|
|
658
1184
|
Action.make('moveDown').action(move(1)),
|
|
659
1185
|
// Drag and drop: the browser sends the new order, only accepted if it is a permutation of the existing keys.
|
|
660
1186
|
Action.make('reorder').action(({ arguments: args }) => {
|
|
1187
|
+
if (!this.canDrag())
|
|
1188
|
+
return;
|
|
661
1189
|
const items = this.getItems();
|
|
662
1190
|
const keys = Object.keys(items);
|
|
663
1191
|
const order = Array.isArray(args.items) ? args.items.map(String) : [];
|
|
@@ -667,9 +1195,85 @@ export class Repeater extends Field {
|
|
|
667
1195
|
}),
|
|
668
1196
|
];
|
|
669
1197
|
}
|
|
1198
|
+
async itemLabelOf(key, index, item) {
|
|
1199
|
+
const label = typeof this._itemLabel === 'function' ? await this._itemLabel({ state: item, key, index }) : this._itemLabel;
|
|
1200
|
+
return label === null || label === undefined || label === '' ? null : String(label);
|
|
1201
|
+
}
|
|
670
1202
|
async toHtml() {
|
|
671
1203
|
if (!colorManager)
|
|
672
1204
|
throw new Error('No color manager: call useColorManager().');
|
|
1205
|
+
if (this.isTable())
|
|
1206
|
+
return this.toTableHtml();
|
|
1207
|
+
const id = this.getId();
|
|
1208
|
+
const key = this.getKey();
|
|
1209
|
+
const items = this.getItems();
|
|
1210
|
+
const keys = Object.keys(items);
|
|
1211
|
+
const schemas = this.getChildSchemas();
|
|
1212
|
+
const tr = (name, replace = {}) => t(`filament-forms::components.repeater.${name}`, replace);
|
|
1213
|
+
const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: key };
|
|
1214
|
+
const expression = (action, args) => `mountAction(${js(action)}, ${args ? js(args) : '{}'}, ${js(context)})`;
|
|
1215
|
+
const descriptionId = (itemKey) => `${id}-item-${createHash('md5').update(itemKey).digest('hex')}`;
|
|
1216
|
+
const danger = colorManager.getComponentClasses('icon-button', 'danger');
|
|
1217
|
+
const gray = colorManager.getComponentClasses('icon-button', 'gray');
|
|
1218
|
+
const button = (options) => {
|
|
1219
|
+
const loading = options.expression ? `${icon(options.icon, { attributes: new Attributes({ 'wire:loading.remove.delay.default': '1', 'wire:target': options.expression }) })}${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': options.expression }))}` : icon(options.icon);
|
|
1220
|
+
return `<button title="${e(options.label)}"${options.disabled ? ' aria-disabled="true"' : ''} aria-label="${e(options.label)}"${options.disabled ? ' disabled="disabled"' : ''} type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${cls(['fi-ac-icon-btn-action', 'fi-icon-btn', { 'fi-disabled': options.disabled === true }, 'fi-size-sm', ...(options.colors ?? [])])}"${options.extra ?? ''}${options.expression ? ` wire:click="${e(options.expression)}"` : ''}>${loading}</button>`;
|
|
1221
|
+
};
|
|
1222
|
+
const rows = [];
|
|
1223
|
+
for (const [index, itemKey] of keys.entries()) {
|
|
1224
|
+
const isFirst = index === 0, isLast = index === keys.length - 1;
|
|
1225
|
+
const moveButton = (action, label, iconName, direction, disabled) => {
|
|
1226
|
+
const expr = expression(action, { item: itemKey });
|
|
1227
|
+
return `<li x-on:click.stop${this._reorderableWithButtons ? '' : ' class="fi-sr-only"'}>${button({ label, icon: iconName, expression: expr, disabled, extra: `${this._reorderableWithButtons ? '' : ' tabindex="-1"'} data-reorder-hidden="data-reorder-hidden" data-reorder-direction="${direction}" data-reorder-action="${e(expr)}" aria-describedby="${e(descriptionId(itemKey))}"` })}</li>`;
|
|
1228
|
+
};
|
|
1229
|
+
const content = await schemas[index].toHtml();
|
|
1230
|
+
if (this._simple) {
|
|
1231
|
+
const actions = [
|
|
1232
|
+
...(this.canDrag() && keys.length > 1 ? [`<li x-on:click.stop>${button({ label: tr('actions.reorder.label'), icon: 'm-arrows-up-down', expression: null, extra: ' x-sortable-handle="x-sortable-handle"' })}</li>`] : []),
|
|
1233
|
+
...(this.canMove() && keys.length > 1 ? [moveButton('moveUp', tr('actions.move_up.label'), 'm-arrow-up', 'up', isFirst), moveButton('moveDown', tr('actions.move_down.label'), 'm-arrow-down', 'down', isLast)] : []),
|
|
1234
|
+
...(this._cloneable ? [`<li x-on:click.stop>${button({ label: tr('actions.clone.label'), icon: 'm-square-2-stack', expression: expression('clone', { item: itemKey }), colors: gray })}</li>`] : []),
|
|
1235
|
+
...(this._deletable ? [`<li x-on:click.stop>${button({ label: tr('actions.delete.label'), icon: 'm-trash', expression: expression('delete', { item: itemKey }), colors: danger })}</li>`] : []),
|
|
1236
|
+
];
|
|
1237
|
+
rows.push(`<li wire:key="${e(this.getLivewireKey() ?? '')}.${e(itemKey)}.item" x-sortable-item="${e(itemKey)}" class="fi-fo-simple-repeater-item"><div class="fi-fo-simple-repeater-item-content">${content}</div>${actions.length ? `<ul class="fi-fo-simple-repeater-item-actions">${actions.join('')}</ul>` : ''}</li>`);
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
const label = await this.itemLabelOf(itemKey, index, items[itemKey]);
|
|
1241
|
+
const start = [
|
|
1242
|
+
...(this.canDrag() && keys.length > 1 ? [`<li x-on:click.stop>${button({ label: tr('actions.reorder.label'), icon: 'm-arrows-up-down', expression: null, extra: ' x-sortable-handle="x-sortable-handle"' })}</li>`] : []),
|
|
1243
|
+
...(this.canMove() && keys.length > 1 ? [moveButton('moveUp', tr('actions.move_up.label'), 'm-arrow-up', 'up', isFirst), moveButton('moveDown', tr('actions.move_down.label'), 'm-arrow-down', 'down', isLast)] : []),
|
|
1244
|
+
];
|
|
1245
|
+
const links = this._itemLinks.map((resolve) => resolve({ item: items[itemKey], key: itemKey })).filter((link) => link !== null && /^(\/(?!\/)|https?:\/\/)/.test(link.url))
|
|
1246
|
+
.map((link) => `<li x-on:click.stop><a href="${e(link.url)}" target="_blank" rel="noopener" aria-label="${e(link.label)}" title="${e(link.label)}" class="${cls(['fi-ac-icon-btn-action', 'fi-icon-btn', 'fi-size-sm', ...gray])}">${icon(link.icon)}</a></li>`);
|
|
1247
|
+
const end = [
|
|
1248
|
+
...links,
|
|
1249
|
+
...(this._cloneable ? [`<li x-on:click.stop>${button({ label: tr('actions.clone.label'), icon: 'm-square-2-stack', expression: expression('clone', { item: itemKey }), colors: gray })}</li>`] : []),
|
|
1250
|
+
...(this._deletable ? [`<li x-on:click.stop>${button({ label: tr('actions.delete.label'), icon: 'm-trash', expression: expression('delete', { item: itemKey }), colors: danger })}</li>`] : []),
|
|
1251
|
+
...(this._collapsible ? [`<li class="fi-fo-repeater-item-header-collapsible-actions" x-on:click.stop="toggleCollapsed"><div class="fi-fo-repeater-item-header-collapse-action">${button({ label: tr('actions.collapse.label'), icon: 'm-chevron-up', expression: null, colors: gray })}</div><div class="fi-fo-repeater-item-header-expand-action">${button({ label: tr('actions.expand.label'), icon: 'm-chevron-down', expression: null, colors: gray })}</div></li>`] : []),
|
|
1252
|
+
];
|
|
1253
|
+
const hasHeader = this._itemHeaders && (start.length > 0 || label !== null || end.length > 0);
|
|
1254
|
+
const header = hasHeader ? `<div${this._collapsible ? ' x-on:click.stop="toggleCollapsed"' : ''} class="fi-fo-repeater-item-header">${start.length ? `<ul class="fi-fo-repeater-item-header-start-actions">${start.join('')}</ul>` : ''}${label !== null ? `<${this._headingTag} class="${cls(['fi-fo-repeater-item-header-label', { 'fi-truncated': this._truncateItemLabel }])}">${e(label)}${this._itemNumbers ? ` ${index + 1}` : ''}</${this._headingTag}>` : ''}${end.length ? `<ul class="fi-fo-repeater-item-header-end-actions">${end.join('')}</ul>` : ''}</div>` : '';
|
|
1255
|
+
const itemConfig = { isCollapsed: this._collapsed, collapseKey: this._persistCollapsed ? `filament:repeater:collapsed:${key}-${itemKey}` : null };
|
|
1256
|
+
rows.push(`<li wire:ignore.self wire:key="${e(this.getLivewireKey() ?? '')}.${e(itemKey)}.item" x-data="item(${e(js(itemConfig))})" x-on:repeater-expand.window="expandFromEvent" x-on:repeater-collapse.window="collapseFromEvent" x-on:expand="expand" x-sortable-item="${e(itemKey)}" class="${cls(['fi-fo-repeater-item', { 'fi-fo-repeater-item-has-header': hasHeader }])}" x-bind:class="itemClasses">${header}<div x-show="! isCollapsed" class="fi-fo-repeater-item-content">${content}</div></li>`);
|
|
1257
|
+
if (!isLast && this._labelBetweenItems)
|
|
1258
|
+
rows.push(`<li class="fi-fo-repeater-label-between-items-ctn"><div class="fi-fo-repeater-label-between-items-divider-before"></div><span class="fi-fo-repeater-label-between-items">${e(this._labelBetweenItems)}</span><div class="fi-fo-repeater-label-between-items-divider-after"></div></li>`);
|
|
1259
|
+
}
|
|
1260
|
+
const prefix = this._simple ? 'fi-fo-simple-repeater' : 'fi-fo-repeater';
|
|
1261
|
+
const gridAttributes = this._grid !== null ? new Attributes().grid(this._grid).toHtml() : '';
|
|
1262
|
+
const gridClass = /class="([^"]*)"/.exec(gridAttributes)?.[1] ?? '';
|
|
1263
|
+
const gridStyle = /style="([^"]*)"/.exec(gridAttributes)?.[1] ?? null;
|
|
1264
|
+
const list = keys.length ? `<ul x-sortable${gridStyle ? ` style="${gridStyle}"` : ''} data-sortable-animation-duration="300" x-on:end.stop="reorder" x-ref="reorderItems" class="${cls([gridClass, `${prefix}-items`])}">${rows.join('')}</ul>` : '';
|
|
1265
|
+
const label = await this.getLabel();
|
|
1266
|
+
const addExpr = expression('add', null);
|
|
1267
|
+
const add = this._addable && keys.length < this._maxItems ? `<div class="${cls([`${prefix}-add`, this._addActionAlignment ? `fi-align-${this._addActionAlignment}` : null])}"><button type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="fi-ac-btn-action fi-btn fi-size-sm" wire:click="${e(addExpr)}">${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': addExpr }), 'sm')}${e(this._addActionLabel ?? tr('actions.add.label', { label: label.toLowerCase() }))}</button></div>` : '';
|
|
1268
|
+
const collapseAll = this._collapsible && !this._simple ? `<div class="${cls(['fi-fo-repeater-actions', { 'fi-hidden': keys.length < 2 }])}"><span x-on:click="collapseAll"><button type="button" class="${cls(['fi-ac-link-action', 'fi-link', 'fi-size-sm', ...colorManager.getComponentClasses('link', 'gray')])}"><span class="fi-link-label">${e(tr('actions.collapse_all.label'))}</span></button></span><span x-on:click="expandAll"><button type="button" class="${cls(['fi-ac-link-action', 'fi-link', 'fi-size-sm', ...colorManager.getComponentClasses('link', 'gray')])}"><span class="fi-link-label">${e(tr('actions.expand_all.label'))}</span></button></span></div>` : '';
|
|
1269
|
+
const itemLabel = tr('reordering.item');
|
|
1270
|
+
const descriptions = keys.map((itemKey, index) => `<span class="fi-sr-only" data-reorder-description="${e(itemKey)}" data-reorder-label="${e(itemLabel)}" id="${e(descriptionId(itemKey))}">${e(tr('reordering.position', { label: itemLabel, position: String(index + 1), count: String(keys.length) }))}</span>`).join('');
|
|
1271
|
+
const config = { statePath: this.getStatePath(), schemaComponent: key, moveUpAction: 'moveUp', moveDownAction: 'moveDown', reorderAction: 'reorder', message: tr('reordering.moved'), hasCustomReorderAction: false };
|
|
1272
|
+
const html = `<div aria-labelledby="${e(id)}-label" id="${e(id)}" role="group" data-reorder-owner="data-reorder-owner" data-reorder-items="${e(JSON.stringify(keys))}" x-load="x-load" x-load-src="${e(asset('repeater'))}" x-data="repeaterFormComponent(${e(js(config))})" class="${cls([prefix, { 'fi-collapsible': this._collapsible && !this._simple }])}"><span wire:ignore x-ref="reorderStatus" class="fi-sr-only" role="status" aria-live="polite" aria-atomic="true"></span>${collapseAll}${list}${add}${descriptions}</div>`;
|
|
1273
|
+
return this.wrap(html, { labelAsDiv: true });
|
|
1274
|
+
}
|
|
1275
|
+
async toTableHtml() {
|
|
1276
|
+
const colors = colorManager;
|
|
673
1277
|
const id = this.getId();
|
|
674
1278
|
const key = this.getKey();
|
|
675
1279
|
const livewireKey = this.getLivewireKey() ?? '';
|
|
@@ -686,7 +1290,7 @@ export class Repeater extends Field {
|
|
|
686
1290
|
const descriptionId = (itemKey) => `${id}-item-${createHash('md5').update(itemKey).digest('hex')}`;
|
|
687
1291
|
const rows = [];
|
|
688
1292
|
// No reorder column while there is only one row.
|
|
689
|
-
const hasReorderColumn = this.
|
|
1293
|
+
const hasReorderColumn = this.canDrag() && keys.length > 1;
|
|
690
1294
|
for (const [index, itemKey] of keys.entries()) {
|
|
691
1295
|
const moveButton = (action, label, iconName, direction, disabled) => {
|
|
692
1296
|
const expr = expression(action, { item: itemKey });
|
|
@@ -695,68 +1299,291 @@ export class Repeater extends Field {
|
|
|
695
1299
|
const cells = [];
|
|
696
1300
|
for (const field of schemas[index].getComponents())
|
|
697
1301
|
cells.push(`<td>${await field.toSchemaHtml(await field.isVisible())}</td>`);
|
|
698
|
-
|
|
1302
|
+
// Only local or http(s) URLs: a link built from item data cannot become a `javascript:` URL.
|
|
1303
|
+
const links = this._itemLinks.map((resolve) => resolve({ item: items[itemKey], key: itemKey })).filter((link) => link !== null && /^(\/(?!\/)|https?:\/\/)/.test(link.url))
|
|
699
1304
|
.map((link) => `<div x-on:click.stop><a href="${e(link.url)}" target="_blank" x-tooltip="{
|
|
700
1305
|
content: ${e(js(link.label))},
|
|
701
1306
|
theme: $store.theme,
|
|
702
1307
|
allowHTML: false,
|
|
703
1308
|
}" aria-label="${e(link.label)}" class="${button} fi-size-sm">${icon(link.icon)}</a></div>`);
|
|
704
1309
|
const deleteExpr = expression('delete', { item: itemKey });
|
|
705
|
-
rows.push(`<tr wire:key="${e(livewireKey)}.${e(itemKey)}.item" x-sortable-item="${e(itemKey)}">${hasReorderColumn ? `<td><div class="fi-fo-table-repeater-actions"><div x-on:click.stop><button title="${e(tr('actions.reorder.label'))}" aria-label="${e(tr('actions.reorder.label'))}" type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${button} fi-size-sm" x-sortable-handle="x-sortable-handle">${icon('m-arrows-up-down')}</button></div>${moveButton('moveUp', tr('actions.move_up.label'), 'm-arrow-up', 'up', index === 0)}${moveButton('moveDown', tr('actions.move_down.label'), 'm-arrow-down', 'down', index === keys.length - 1)}</div></td>` : ''}${cells.join('')}<td><div class="fi-fo-table-repeater-actions">${links.join('')}
|
|
1310
|
+
rows.push(`<tr wire:key="${e(livewireKey)}.${e(itemKey)}.item" x-sortable-item="${e(itemKey)}">${hasReorderColumn ? `<td><div class="fi-fo-table-repeater-actions"><div x-on:click.stop><button title="${e(tr('actions.reorder.label'))}" aria-label="${e(tr('actions.reorder.label'))}" type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${button} fi-size-sm" x-sortable-handle="x-sortable-handle">${icon('m-arrows-up-down')}</button></div>${moveButton('moveUp', tr('actions.move_up.label'), 'm-arrow-up', 'up', index === 0)}${moveButton('moveDown', tr('actions.move_down.label'), 'm-arrow-down', 'down', index === keys.length - 1)}</div></td>` : ''}${cells.join('')}<td><div class="fi-fo-table-repeater-actions">${links.join('')}${this._deletable ? `<div x-on:click.stop><button title="${e(tr('actions.delete.label'))}" aria-label="${e(tr('actions.delete.label'))}" type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${[button, 'fi-size-sm', ...colorManager.getComponentClasses('icon-button', 'danger')].join(' ')}" wire:click="${e(deleteExpr)}">${actionIcon('m-trash', deleteExpr)}${loading(deleteExpr)}</button></div>` : ''}</div></td></tr>`);
|
|
706
1311
|
}
|
|
707
1312
|
const head = this._columns.map((column) => `<th scope="col"${column.getWidth() ? ` style="width: ${e(column.getWidth())};"` : ''}>${e(column.label)}</th>`).join('');
|
|
708
1313
|
const addExpr = expression('add', null);
|
|
709
1314
|
const label = await this.getLabel();
|
|
710
1315
|
const config = { statePath: this.getStatePath(), schemaComponent: key, moveUpAction: 'moveUp', moveDownAction: 'moveDown', reorderAction: 'reorder', message: tr('reordering.moved'), hasCustomReorderAction: false };
|
|
711
1316
|
const descriptions = keys.map((itemKey, index) => `<span class="fi-sr-only" data-reorder-description="${e(itemKey)}" data-reorder-label="${e(itemLabel)}" id="${e(descriptionId(itemKey))}">${e(tr('reordering.position', { label: itemLabel, position: String(index + 1), count: String(keys.length) }))}</span>`).join('');
|
|
712
|
-
const table = `<div aria-labelledby="${e(id)}-label" id="${e(id)}" role="group" data-reorder-owner="data-reorder-owner" data-reorder-items="${e(JSON.stringify(keys))}" x-load="x-load" x-load-src="${e(asset('repeater'))}" x-data="repeaterFormComponent(${e(js(config))})" class="fi-fo-table-repeater"><span wire:ignore x-ref="reorderStatus" class="fi-sr-only" role="status" aria-live="polite" aria-atomic="true"></span><table><thead><tr>${hasReorderColumn ? `<th scope="col" class="fi-fo-table-repeater-empty-header-cell"><span class="fi-sr-only">${e(tr('columns.reorder.label'))}</span></th>` : ''}${head}<th scope="col" class="fi-fo-table-repeater-empty-header-cell"><span class="fi-sr-only">${e(tr('columns.actions.label'))}</span></th></tr></thead><tbody x-sortable data-sortable-animation-duration="300" x-on:end.stop="reorder" x-ref="reorderItems">${rows.join('')}</tbody></table>${keys.length < this._maxItems ? `<div class="fi-fo-table-repeater-add"><button type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="fi-ac-btn-action fi-btn fi-size-sm" wire:click="${e(addExpr)}">${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': addExpr }), 'sm')}${e(tr('actions.add.label', { label: label.toLowerCase() }))}</button></div>` : ''}${descriptions}</div>`;
|
|
1317
|
+
const table = `<div aria-labelledby="${e(id)}-label" id="${e(id)}" role="group" data-reorder-owner="data-reorder-owner" data-reorder-items="${e(JSON.stringify(keys))}" x-load="x-load" x-load-src="${e(asset('repeater'))}" x-data="repeaterFormComponent(${e(js(config))})" class="fi-fo-table-repeater"><span wire:ignore x-ref="reorderStatus" class="fi-sr-only" role="status" aria-live="polite" aria-atomic="true"></span><table><thead><tr>${hasReorderColumn ? `<th scope="col" class="fi-fo-table-repeater-empty-header-cell"><span class="fi-sr-only">${e(tr('columns.reorder.label'))}</span></th>` : ''}${head}<th scope="col" class="fi-fo-table-repeater-empty-header-cell"><span class="fi-sr-only">${e(tr('columns.actions.label'))}</span></th></tr></thead><tbody x-sortable data-sortable-animation-duration="300" x-on:end.stop="reorder" x-ref="reorderItems">${rows.join('')}</tbody></table>${this._addable && keys.length < this._maxItems ? `<div class="fi-fo-table-repeater-add"><button type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="fi-ac-btn-action fi-btn fi-size-sm" wire:click="${e(addExpr)}">${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': addExpr }), 'sm')}${e(this._addActionLabel ?? tr('actions.add.label', { label: label.toLowerCase() }))}</button></div>` : ''}${descriptions}</div>`;
|
|
713
1318
|
return this.wrap(table, { labelAsDiv: true });
|
|
714
1319
|
}
|
|
715
1320
|
}
|
|
716
|
-
/**
|
|
717
|
-
export
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
required: (await this.isRequired()) && !isDisabled, step: this._seconds ? 1 : null, type: 'time', 'wire:model': this.getStatePath(),
|
|
728
|
-
})
|
|
729
|
-
.class(['fi-input']);
|
|
730
|
-
const wrapper = new Attributes()
|
|
731
|
-
.merge({ 'x-on:focus-input.stop': "$el.querySelector('input:not([type=hidden])')?.focus()" })
|
|
732
|
-
.class(['fi-fo-date-time-picker', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
733
|
-
return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><input ${input.toHtml()} /></div></div>`);
|
|
1321
|
+
/** PHP date format (`M j, Y H:i`) to day.js (`MMM D, YYYY HH:mm`), as Filament's `convert_date_format()`. Escaped characters (`\at`) become literals. */
|
|
1322
|
+
export function phpDateFormatToDayjs(format) {
|
|
1323
|
+
const tokens = { d: 'DD', D: 'ddd', j: 'D', l: 'dddd', N: 'd', w: 'd', S: '', F: 'MMMM', m: 'MM', M: 'MMM', n: 'M', Y: 'YYYY', y: 'YY', a: 'a', A: 'A', g: 'h', G: 'H', h: 'hh', H: 'HH', i: 'mm', s: 'ss', v: 'SSS', e: 'z', T: 'z', P: 'Z', O: 'ZZ', U: 'X' };
|
|
1324
|
+
let out = '';
|
|
1325
|
+
for (let index = 0; index < format.length; index++) {
|
|
1326
|
+
const char = format[index];
|
|
1327
|
+
if (char === '\\' && index + 1 < format.length) {
|
|
1328
|
+
out += `[${format[++index]}]`;
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
out += Object.hasOwn(tokens, char) ? tokens[char] : /[a-zA-Z]/.test(char) ? `[${char}]` : char;
|
|
734
1332
|
}
|
|
1333
|
+
return out;
|
|
1334
|
+
}
|
|
1335
|
+
const pad = (n, size = 2) => String(n).padStart(size, '0');
|
|
1336
|
+
/** `YYYY-MM-DD HH:MM:SS` of a wall-clock time in a time zone, from a UTC instant. */
|
|
1337
|
+
function formatInZone(date, timeZone) {
|
|
1338
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }).formatToParts(date).map((part) => [part.type, part.value]));
|
|
1339
|
+
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
|
|
1340
|
+
}
|
|
1341
|
+
/** UTC instant of a wall-clock `YYYY-MM-DD HH:MM:SS` in a time zone. */
|
|
1342
|
+
function zonedToUtc(value, timeZone) {
|
|
1343
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/.exec(value);
|
|
1344
|
+
if (!match)
|
|
1345
|
+
return null;
|
|
1346
|
+
const asUtc = Date.UTC(+match[1], +match[2] - 1, +match[3], +match[4], +match[5], +(match[6] ?? 0));
|
|
1347
|
+
// Offset of the zone at that moment (twice, to settle around DST changes).
|
|
1348
|
+
let guess = asUtc;
|
|
1349
|
+
for (let i = 0; i < 2; i++) {
|
|
1350
|
+
const shown = Date.parse(formatInZone(new Date(guess), timeZone).replace(' ', 'T') + 'Z');
|
|
1351
|
+
guess = asUtc - (shown - guess);
|
|
1352
|
+
}
|
|
1353
|
+
return new Date(guess);
|
|
1354
|
+
}
|
|
1355
|
+
/** Normalizes a date-like value to `YYYY-MM-DD HH:MM:SS` (or null). */
|
|
1356
|
+
function normalizeDateTime(value) {
|
|
1357
|
+
if (value instanceof Date)
|
|
1358
|
+
return Number.isNaN(value.getTime()) ? null : `${value.getUTCFullYear()}-${pad(value.getUTCMonth() + 1)}-${pad(value.getUTCDate())} ${pad(value.getUTCHours())}:${pad(value.getUTCMinutes())}:${pad(value.getUTCSeconds())}`;
|
|
1359
|
+
if (typeof value !== 'string' || value === '')
|
|
1360
|
+
return null;
|
|
1361
|
+
const match = /^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value);
|
|
1362
|
+
if (match)
|
|
1363
|
+
return `${match[1]} ${match[2] ?? '00'}:${match[3] ?? '00'}:${match[4] ?? '00'}`;
|
|
1364
|
+
const time = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value);
|
|
1365
|
+
return time ? `1970-01-01 ${time[1]}:${time[2]}:${time[3] ?? '00'}` : null;
|
|
735
1366
|
}
|
|
736
|
-
/**
|
|
737
|
-
|
|
1367
|
+
/**
|
|
1368
|
+
* Base of the date, time and date-time pickers: native browser input by default, or Filament's JavaScript picker with
|
|
1369
|
+
* `native(false)` (display format, disabled dates, first day of the week, locale). Limits and disabled dates are checked on the server.
|
|
1370
|
+
*/
|
|
1371
|
+
class TemporalField extends Field {
|
|
738
1372
|
_seconds = true;
|
|
739
|
-
|
|
1373
|
+
_native = true;
|
|
1374
|
+
_maxDate = null;
|
|
1375
|
+
_minDate = null;
|
|
1376
|
+
_displayFormat = null;
|
|
1377
|
+
_format = null;
|
|
1378
|
+
_firstDayOfWeek = 1;
|
|
1379
|
+
_disabledDates = [];
|
|
1380
|
+
_closeOnDateSelection = false;
|
|
1381
|
+
_locale = null;
|
|
1382
|
+
_timezone = null;
|
|
1383
|
+
_hoursStep = 1;
|
|
1384
|
+
_minutesStep = 1;
|
|
1385
|
+
_secondsStep = 1;
|
|
1386
|
+
_defaultFocusedDate = null;
|
|
1387
|
+
_temporalPlaceholder = null;
|
|
740
1388
|
seconds(condition = true) { this._seconds = condition; return this; }
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1389
|
+
hasSeconds() { return this.hasTime() && this._seconds; }
|
|
1390
|
+
/** `false`: Filament's JavaScript picker instead of the browser's native input. */
|
|
1391
|
+
native(condition = true) { this._native = condition; return this; }
|
|
1392
|
+
isNative() { return this._native; }
|
|
1393
|
+
/** Display format of the JavaScript picker, in PHP's notation (`'d/m/Y'`, `'M j, Y H:i'`). */
|
|
1394
|
+
displayFormat(format) { this._displayFormat = format; return this; }
|
|
1395
|
+
/** Format of the saved value, in PHP's notation (`'Y-m-d'` by default for dates, `'Y-m-d H:i:s'` with a time). */
|
|
1396
|
+
format(format) { this._format = format; return this; }
|
|
1397
|
+
/** First day of the week in the JavaScript picker: 0 (Sunday) to 6. */
|
|
1398
|
+
firstDayOfWeek(day) { this._firstDayOfWeek = Math.min(6, Math.max(0, Math.floor(day))); return this; }
|
|
1399
|
+
weekStartsOnMonday() { return this.firstDayOfWeek(1); }
|
|
1400
|
+
weekStartsOnSunday() { return this.firstDayOfWeek(0); }
|
|
1401
|
+
/** Dates that cannot be picked (`YYYY-MM-DD`), also rejected on the server. */
|
|
1402
|
+
disabledDates(dates) { this._disabledDates = dates; return this; }
|
|
1403
|
+
closeOnDateSelection(condition = true) { this._closeOnDateSelection = condition; return this; }
|
|
1404
|
+
/** Locale of the JavaScript picker's month and day names (`'fr'`). */
|
|
1405
|
+
locale(locale) { this._locale = locale; return this; }
|
|
1406
|
+
/** Shows and edits the value in this time zone, while it is saved in UTC (date-time pickers). */
|
|
1407
|
+
timezone(timezone) { if (timezone !== null)
|
|
1408
|
+
new Intl.DateTimeFormat('en', { timeZone: timezone }); this._timezone = timezone; return this; }
|
|
1409
|
+
hoursStep(step) { this._hoursStep = step; return this; }
|
|
1410
|
+
minutesStep(step) { this._minutesStep = step; return this; }
|
|
1411
|
+
secondsStep(step) { this._secondsStep = step; return this; }
|
|
1412
|
+
/** Date focused when the JavaScript picker opens on an empty field. */
|
|
1413
|
+
defaultFocusedDate(date) { this._defaultFocusedDate = date; return this; }
|
|
1414
|
+
placeholder(text) { this._temporalPlaceholder = text; return this; }
|
|
1415
|
+
static resolveLimit(date) {
|
|
1416
|
+
const day = (offset) => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10);
|
|
1417
|
+
return date === 'today' ? day(0) : date === 'tomorrow' ? day(1) : date === 'yesterday' ? day(-1) : date;
|
|
1418
|
+
}
|
|
1419
|
+
async limit(date) { const value = await evaluate(date, this.context()); return value ? TemporalField.resolveLimit(String(value)) : null; }
|
|
1420
|
+
/** Latest accepted date: `'today'`, a date, or a context function (`({ get }) => get('ends_at')`). */
|
|
1421
|
+
maxDate(date) {
|
|
1422
|
+
this._maxDate = date;
|
|
1423
|
+
return this.rule({ name: 'before_or_equal', passes: async (v) => { const max = await this.limit(date); return max === null || this.comparable(v) <= this.comparable(max); }, message: (a) => `The ${a} field must be a date before or equal to ${typeof date === 'string' ? date : 'the maximum date'}.` });
|
|
1424
|
+
}
|
|
1425
|
+
minDate(date) {
|
|
1426
|
+
this._minDate = date;
|
|
1427
|
+
return this.rule({ name: 'after_or_equal', passes: async (v) => { const min = await this.limit(date); return min === null || this.comparable(v) >= this.comparable(min); }, message: (a) => `The ${a} field must be a date after or equal to ${typeof date === 'string' ? date : 'the minimum date'}.` });
|
|
746
1428
|
}
|
|
1429
|
+
/** Comparable string of a value: the date only for date pickers, the time only for time pickers. */
|
|
1430
|
+
comparable(value) {
|
|
1431
|
+
const normalized = normalizeDateTime(this.toUtc(value)) ?? String(value);
|
|
1432
|
+
if (!this.hasTime())
|
|
1433
|
+
return normalized.slice(0, 10);
|
|
1434
|
+
if (!this.hasDate())
|
|
1435
|
+
return normalized.slice(11, this._seconds ? 19 : 16);
|
|
1436
|
+
return normalized;
|
|
1437
|
+
}
|
|
1438
|
+
async getValidationRules() {
|
|
1439
|
+
const rules = await super.getValidationRules();
|
|
1440
|
+
if (!this.hasDate())
|
|
1441
|
+
return rules;
|
|
1442
|
+
return [...rules,
|
|
1443
|
+
{ name: 'date', passes: (v) => normalizeDateTime(v) !== null && !Number.isNaN(Date.parse(normalizeDateTime(v).replace(' ', 'T') + 'Z')), message: (a) => `The ${a} field must be a valid date.` },
|
|
1444
|
+
{ name: 'not_in', passes: async (v) => { const disabled = (await evaluate(this._disabledDates, this.context())) ?? []; return !disabled.includes(String(normalizeDateTime(this.toUtc(v)) ?? '').slice(0, 10)); }, message: (a) => `The selected ${a} is invalid.` },];
|
|
1445
|
+
}
|
|
1446
|
+
/** Record value to form state, shown in the field's time zone. */
|
|
1447
|
+
hydrateState(value) {
|
|
1448
|
+
const normalized = normalizeDateTime(value);
|
|
1449
|
+
if (normalized === null)
|
|
1450
|
+
return value === undefined ? null : value === '' ? null : typeof value === 'string' ? value : null;
|
|
1451
|
+
let local = normalized;
|
|
1452
|
+
if (this._timezone && this.hasDate() && this.hasTime())
|
|
1453
|
+
local = formatInZone(new Date(normalized.replace(' ', 'T') + 'Z'), this._timezone);
|
|
1454
|
+
return this.stateOf(local);
|
|
1455
|
+
}
|
|
1456
|
+
/** Form state as the input expects it. */
|
|
1457
|
+
stateOf(local) {
|
|
1458
|
+
if (!this._native)
|
|
1459
|
+
return this.hasDate() ? local : `${new Date().toISOString().slice(0, 10)} ${local.slice(11)}`;
|
|
1460
|
+
if (!this.hasTime())
|
|
1461
|
+
return local.slice(0, 10);
|
|
1462
|
+
if (!this.hasDate())
|
|
1463
|
+
return local.slice(11, this._seconds ? 19 : 16);
|
|
1464
|
+
return local.slice(0, this._seconds ? 19 : 16);
|
|
1465
|
+
}
|
|
1466
|
+
/** Wall-clock value of the field's time zone to UTC. */
|
|
1467
|
+
toUtc(value) {
|
|
1468
|
+
if (!this._timezone || !this.hasDate() || !this.hasTime() || typeof value !== 'string')
|
|
1469
|
+
return value;
|
|
1470
|
+
const instant = zonedToUtc(value, this._timezone);
|
|
1471
|
+
return instant ? normalizeDateTime(instant) : value;
|
|
1472
|
+
}
|
|
1473
|
+
/** Saved value: in UTC, in `format()` (or the default format). */
|
|
1474
|
+
dehydrateState(state) {
|
|
1475
|
+
if (state === '' || state === null || state === undefined)
|
|
1476
|
+
return null;
|
|
1477
|
+
const normalized = normalizeDateTime(this.toUtc(typeof state === 'string' ? state.replace('T', ' ') : state));
|
|
1478
|
+
if (normalized === null)
|
|
1479
|
+
return state;
|
|
1480
|
+
if (this._format)
|
|
1481
|
+
return formatPhpDate(normalized, this._format);
|
|
1482
|
+
if (!this.hasTime())
|
|
1483
|
+
return normalized.slice(0, 10);
|
|
1484
|
+
if (!this.hasDate())
|
|
1485
|
+
return normalized.slice(11, this._seconds ? 19 : 16);
|
|
1486
|
+
return normalized;
|
|
1487
|
+
}
|
|
1488
|
+
defaultDisplayFormat() {
|
|
1489
|
+
if (!this.hasTime())
|
|
1490
|
+
return 'M j, Y';
|
|
1491
|
+
const time = this._seconds ? 'H:i:s' : 'H:i';
|
|
1492
|
+
return this.hasDate() ? `M j, Y ${time}` : time;
|
|
1493
|
+
}
|
|
1494
|
+
nativeType() { return !this.hasTime() ? 'date' : !this.hasDate() ? 'time' : 'datetime-local'; }
|
|
747
1495
|
async toHtml() {
|
|
748
1496
|
const isDisabled = await this.isDisabled();
|
|
749
|
-
const
|
|
750
|
-
.merge({
|
|
751
|
-
'aria-invalid': this.hasError() ? 'true' : null, disabled: isDisabled, id: this.getId(),
|
|
752
|
-
required: (await this.isRequired()) && !isDisabled, step: this._seconds ? 1 : null, type: 'datetime-local', 'wire:model': this.getStatePath(),
|
|
753
|
-
})
|
|
754
|
-
.class(['fi-input']);
|
|
1497
|
+
const min = await this.limit(this._minDate), max = await this.limit(this._maxDate);
|
|
755
1498
|
const wrapper = new Attributes()
|
|
756
1499
|
.merge({ 'x-on:focus-input.stop': "$el.querySelector('input:not([type=hidden])')?.focus()" })
|
|
757
1500
|
.class(['fi-fo-date-time-picker', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
758
|
-
|
|
1501
|
+
const model = this.wireModel();
|
|
1502
|
+
if (this._native) {
|
|
1503
|
+
const limit = (value) => (value === null ? null : !this.hasTime() ? value.slice(0, 10) : !this.hasDate() ? (value.length > 8 ? value.slice(11) : value) : value.replace(' ', 'T'));
|
|
1504
|
+
const input = new Attributes()
|
|
1505
|
+
.merge({
|
|
1506
|
+
'aria-invalid': this.hasError() ? 'true' : null, disabled: isDisabled, id: this.getId(),
|
|
1507
|
+
max: limit(max), min: limit(min), placeholder: this._temporalPlaceholder,
|
|
1508
|
+
required: (await this.isRequired()) && !isDisabled, step: this.hasTime() && this._seconds ? 1 : null, type: this.nativeType(), [model]: this.getStatePath(),
|
|
1509
|
+
})
|
|
1510
|
+
.class(['fi-input']);
|
|
1511
|
+
return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><input ${input.toHtml()} /></div></div>`);
|
|
1512
|
+
}
|
|
1513
|
+
const id = this.getId();
|
|
1514
|
+
const hasDate = this.hasDate(), hasTime = this.hasTime(), hasSeconds = this.hasSeconds();
|
|
1515
|
+
const disabledDates = (await evaluate(this._disabledDates, this.context())) ?? [];
|
|
1516
|
+
const jsLimit = (value) => (value === null ? '' : normalizeDateTime(value.length <= 8 ? `1970-01-01 ${value}` : value) ?? value);
|
|
1517
|
+
const livewireKey = this.getLivewireKey() ?? '';
|
|
1518
|
+
const label = await this.getLabel();
|
|
1519
|
+
const tr = (key) => t(`filament-forms::components.date_time_picker.${key}`);
|
|
1520
|
+
const describedBy = [this.getHelperTextId(), this.hasError() ? `${id}-error` : null].filter(Boolean).join(' ') || null;
|
|
1521
|
+
const trigger = new Attributes().merge({ 'aria-describedby': describedBy, 'aria-invalid': this.hasError() ? 'true' : null, 'aria-controls': `${id}-panel`, 'aria-expanded': 'false', 'aria-haspopup': 'dialog', disabled: isDisabled, id, placeholder: this._temporalPlaceholder, role: 'combobox', type: 'text' }).class(['fi-fo-date-time-picker-trigger', 'fi-fo-date-time-picker-display-text-input']);
|
|
1522
|
+
const config = `dateTimePickerFormComponent({
|
|
1523
|
+
defaultFocusedDate: ${js(this._defaultFocusedDate)},
|
|
1524
|
+
displayFormat: ${js(phpDateFormatToDayjs(this._displayFormat ?? this.defaultDisplayFormat()))},
|
|
1525
|
+
firstDayOfWeek: ${this._firstDayOfWeek},
|
|
1526
|
+
hasDate: ${hasDate ? 'true' : 'false'},
|
|
1527
|
+
isAutofocused: ${this._autofocus && !isDisabled ? 'true' : 'false'},
|
|
1528
|
+
locale: ${js(this._locale ?? 'en')},
|
|
1529
|
+
shouldCloseOnDateSelection: ${this._closeOnDateSelection ? 'true' : 'false'},
|
|
1530
|
+
state: $wire.$entangle(${js(this.getStatePath())}${this.isLive() ? ', true' : ''}),
|
|
1531
|
+
})`;
|
|
1532
|
+
const keyHash = createHash('md5').update(JSON.stringify([disabledDates, isDisabled, max, min, hasDate, hasTime, hasSeconds])).digest('hex');
|
|
1533
|
+
const events = hasDate
|
|
1534
|
+
? ` x-on:dropdown-escape="if (isOpen()) { closePanel(true); $event.preventDefault() }" x-on:focusout="handleFocusOut($event)" x-on:keydown.escape="if (isOpen()) { $event.preventDefault(); $event.stopPropagation(); closePanel(true) }" x-on:click.outside="isOpen() && closePanel()" x-on:livewire:navigating.window="closePanel()"`
|
|
1535
|
+
: ` x-on:dropdown-escape="if (isOpen()) { $refs.panel.close(); $refs.button.focus(); $event.preventDefault() }" x-on:focusout="if (isOpen() && ! $el.contains($event.relatedTarget)) $refs.panel.close()" x-on:keydown.esc="isOpen() && $event.stopPropagation()"`;
|
|
1536
|
+
const buttonEvents = isDisabled ? (!hasDate ? ' x-on:keydown.enter.prevent.stop="if (false) { isOpen() ? selectDate() : togglePanelVisibility() }"' : '') : [
|
|
1537
|
+
!hasDate ? ' x-on:keydown.enter.prevent.stop="if (true) { isOpen() ? selectDate() : togglePanelVisibility() }"' : '',
|
|
1538
|
+
' x-on:click="togglePanelVisibility()"',
|
|
1539
|
+
hasDate ? ' x-on:keydown="handleTriggerKeydown($event)" x-bind:aria-expanded="isPanelOpen.toString()"' : ' x-on:keydown.arrow-left.prevent.stop="focusPreviousDay()" x-on:keydown.arrow-right.prevent.stop="focusNextDay()" x-on:keydown.arrow-up.prevent.stop="focusPreviousWeek()" x-on:keydown.arrow-down.prevent.stop="focusNextWeek()"',
|
|
1540
|
+
' x-on:keydown.backspace.prevent.stop="clearState()" x-on:keydown.clear.prevent.stop="clearState()" x-on:keydown.delete.prevent.stop="clearState()"',
|
|
1541
|
+
].join('');
|
|
1542
|
+
const calendar = hasDate ? `<div class="fi-fo-date-time-picker-panel-header"><select aria-label="${e(tr('month_select.label'))}" x-model="focusedMonth" class="fi-fo-date-time-picker-month-select"><template x-for="(month, index) in months"><option x-bind:value="index" x-text="month"></option></template></select><input type="number" inputmode="numeric" aria-label="${e(tr('year_input.label'))}" x-model.debounce="focusedYear" class="fi-fo-date-time-picker-year-input" /></div><div aria-live="polite" aria-atomic="true" x-text="getCalendarLabel()" class="fi-fo-date-time-picker-calendar-description"></div><div x-ref="calendar" role="grid" tabindex="-1" x-bind:aria-label="getCalendarLabel()" x-on:keydown="handleCalendarKeydown($event)" class="fi-fo-date-time-picker-calendar"><div role="row" class="fi-fo-date-time-picker-calendar-header"><template x-for="(day, index) in dayLabels" x-bind:key="index"><div role="columnheader" x-bind:aria-label="fullDayLabels[index]" x-text="day" class="fi-fo-date-time-picker-calendar-header-day"></div></template></div><template x-for="(week, weekIndex) in weeksInFocusedMonth" x-bind:key="weekIndex"><div role="row" class="fi-fo-date-time-picker-calendar-week"><template x-for="(day, dayIndex) in week" x-bind:key="day ?? ('empty-' + dayIndex)"><div role="gridcell" x-text="day" x-bind:data-date="day ? focusedDate.date(day).format('YYYY-MM-DD') : null" x-bind:aria-label="day ? getDayLabel(day) : null" x-bind:aria-selected="day && dayIsSelected(day) ? 'true' : 'false'" x-bind:aria-current="day && dayIsToday(day) ? 'date' : null" x-bind:aria-disabled="day && dayIsDisabled(day) ? 'true' : 'false'" x-bind:tabindex="day ? (focusedDate.date() === day ? 0 : -1) : null" x-on:focus="day && setFocusedDay(day)" x-on:click="if (day) { $el.focus(); selectDate(day) }" x-bind:class="{ 'fi-fo-date-time-picker-calendar-day': day, 'fi-fo-date-time-picker-calendar-day-today': day && dayIsToday(day), 'fi-focused': day && focusedDate.date() === day, 'fi-selected': day && dayIsSelected(day), 'fi-disabled': day && dayIsDisabled(day) }"></div></template></div></template></div>` : '';
|
|
1543
|
+
const timeInput = (max, step, label, model) => `<input max="${max}" min="0" step="${step}" type="number" inputmode="numeric" aria-label="${e(label)}" x-on:blur="checkTimeInputValidity" x-on:invalid="timeInputInvalid" x-model.debounce="${model}" />`;
|
|
1544
|
+
const time = hasTime ? `<div class="fi-fo-date-time-picker-time-inputs">${timeInput(23, this._hoursStep, tr('hour_input.label'), 'hour')}<span class="fi-fo-date-time-picker-time-input-separator">:</span>${timeInput(59, this._minutesStep, tr('minute_input.label'), 'minute')}${hasSeconds ? `<span class="fi-fo-date-time-picker-time-input-separator">:</span>${timeInput(59, this._secondsStep, tr('second_input.label'), 'second')}` : ''}</div>` : '';
|
|
1545
|
+
const picker = `<div hidden data-field-accessibility${describedBy ? ` aria-describedby="${e(describedBy)}"` : ''}${this.hasError() ? ' aria-invalid="true"' : ''}></div><div x-load x-load-src="${e(asset('date-time-picker'))}" x-data="${e(config)}" wire:ignore wire:key="${e(livewireKey)}.${keyHash}"${events}><input x-ref="maxDate" type="hidden" value="${e(jsLimit(max))}" /><input x-ref="minDate" type="hidden" value="${e(jsLimit(min))}" /><input x-ref="disabledDates" type="hidden" value="${e(JSON.stringify(disabledDates))}" /><input x-ref="button"${buttonEvents} readonly wire:key="${e(livewireKey)}.display-text" x-model="displayText" ${trigger.toHtml()} /><div x-ref="panel" id="${e(id)}-panel" role="dialog" aria-label="${e(label)}" x-cloak${hasDate ? ' x-show="isPanelOpen"' : ' x-float.placement.bottom-start.offset.flip.shift="{ offset: 8 }"'} wire:ignore wire:key="${e(livewireKey)}.panel" class="fi-fo-date-time-picker-panel">${calendar}${time}</div></div>`;
|
|
1546
|
+
return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn">${picker}</div></div>`);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
/** Formats `YYYY-MM-DD HH:MM:SS` with a PHP date format (the usual tokens). */
|
|
1550
|
+
function formatPhpDate(value, format) {
|
|
1551
|
+
const [date, time] = value.split(' ');
|
|
1552
|
+
const [year, month, day] = date.split('-').map(Number);
|
|
1553
|
+
const [hour, minute, second] = (time ?? '00:00:00').split(':').map(Number);
|
|
1554
|
+
const tokens = {
|
|
1555
|
+
Y: () => String(year), y: () => pad(year % 100), m: () => pad(month), n: () => String(month), d: () => pad(day), j: () => String(day),
|
|
1556
|
+
H: () => pad(hour), G: () => String(hour), h: () => pad(hour % 12 || 12), g: () => String(hour % 12 || 12), i: () => pad(minute), s: () => pad(second),
|
|
1557
|
+
A: () => (hour < 12 ? 'AM' : 'PM'), a: () => (hour < 12 ? 'am' : 'pm'), U: () => String(Date.UTC(year, month - 1, day, hour, minute, second) / 1000),
|
|
1558
|
+
};
|
|
1559
|
+
let out = '';
|
|
1560
|
+
for (let index = 0; index < format.length; index++) {
|
|
1561
|
+
const char = format[index];
|
|
1562
|
+
if (char === '\\' && index + 1 < format.length) {
|
|
1563
|
+
out += format[++index];
|
|
1564
|
+
continue;
|
|
1565
|
+
}
|
|
1566
|
+
out += Object.hasOwn(tokens, char) ? tokens[char]() : char;
|
|
759
1567
|
}
|
|
1568
|
+
return out;
|
|
1569
|
+
}
|
|
1570
|
+
/** Date picker: native `<input type="date">` by default; the saved state is `YYYY-MM-DD`. */
|
|
1571
|
+
export class DatePicker extends TemporalField {
|
|
1572
|
+
static make(name) { return configured(new DatePicker(name)); }
|
|
1573
|
+
hasDate() { return true; }
|
|
1574
|
+
hasTime() { return false; }
|
|
1575
|
+
}
|
|
1576
|
+
/** Time picker: native `<input type="time">` by default; the state is `HH:MM:SS` (`HH:MM` with `seconds(false)`). */
|
|
1577
|
+
export class TimePicker extends TemporalField {
|
|
1578
|
+
static make(name) { return configured(new TimePicker(name)); }
|
|
1579
|
+
hasDate() { return false; }
|
|
1580
|
+
hasTime() { return true; }
|
|
1581
|
+
}
|
|
1582
|
+
/** Date-time picker: native `datetime-local` input by default; the saved state is `YYYY-MM-DD HH:MM:SS`. */
|
|
1583
|
+
export class DateTimePicker extends TemporalField {
|
|
1584
|
+
static make(name) { return configured(new DateTimePicker(name)); }
|
|
1585
|
+
hasDate() { return true; }
|
|
1586
|
+
hasTime() { return true; }
|
|
760
1587
|
}
|
|
761
1588
|
/** Single choice as radio buttons. */
|
|
762
1589
|
export class Radio extends Field {
|
|
@@ -771,8 +1598,16 @@ export class Radio extends Field {
|
|
|
771
1598
|
/** Options side by side. */
|
|
772
1599
|
inline(condition = true) { this._inline = condition; return this; }
|
|
773
1600
|
live(condition = true) { this._live = condition; return this; }
|
|
1601
|
+
_disableOptionWhen = null;
|
|
1602
|
+
/** Options that cannot be picked; they are refused on the server too. */
|
|
1603
|
+
disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
|
|
1604
|
+
isOptionDisabled(value, label) { return this._disableOptionWhen ? this._disableOptionWhen(value, label) : false; }
|
|
1605
|
+
/** "Yes" (`1`) and "No" (`0`) options. */
|
|
1606
|
+
boolean(trueLabel = null, falseLabel = null) {
|
|
1607
|
+
return this.options([['1', trueLabel ?? t('filament-forms::components.radio.boolean.true')], ['0', falseLabel ?? t('filament-forms::components.radio.boolean.false')]]);
|
|
1608
|
+
}
|
|
774
1609
|
async getValidationRules() {
|
|
775
|
-
return [...(await super.getValidationRules()), { name: 'in', passes: (v) => this._options.some(([value]) => value === String(v)), message: (a) => `The selected ${a} is invalid.` }];
|
|
1610
|
+
return [...(await super.getValidationRules()), { name: 'in', passes: (v) => this._options.some(([value, label]) => value === String(v) && !this.isOptionDisabled(value, label)), message: (a) => `The selected ${a} is invalid.` }];
|
|
776
1611
|
}
|
|
777
1612
|
async toHtml() {
|
|
778
1613
|
const id = this.getId();
|
|
@@ -783,7 +1618,7 @@ export class Radio extends Field {
|
|
|
783
1618
|
.class(['fi-fo-radio', this._inline ? 'fi-inline' : null]);
|
|
784
1619
|
const items = this._options.map(([value, label]) => {
|
|
785
1620
|
const input = new Attributes()
|
|
786
|
-
.merge({ type: 'radio', disabled: isDisabled, id: `${id}-${value}`, name: id, required: isRequired, value, [this._live ? 'wire:model.live' : 'wire:model']: this.getStatePath() })
|
|
1621
|
+
.merge({ type: 'radio', disabled: isDisabled || this.isOptionDisabled(value, label), id: `${id}-${value}`, name: id, required: isRequired, value, [this._live ? 'wire:model.live' : 'wire:model']: this.getStatePath() })
|
|
787
1622
|
.class(['fi-radio-input', this.hasError() ? 'fi-invalid' : 'fi-valid']);
|
|
788
1623
|
const description = this._descriptions[value] ? `<p class="fi-fo-radio-label-description">${e(this._descriptions[value])}</p>` : '';
|
|
789
1624
|
return `<label class="fi-fo-radio-label"><input ${input.toHtml()} /><div class="fi-fo-radio-label-text"><p>${e(label)}</p>${description}</div></label>`;
|
|
@@ -797,18 +1632,40 @@ export class Textarea extends Field {
|
|
|
797
1632
|
static make(name) { return configured(new Textarea(name)); }
|
|
798
1633
|
rows(rows) { this._rows = rows; return this; }
|
|
799
1634
|
maxLength(length) { this._maxLength = length; return this.rule({ name: 'max', passes: (v) => String(v).length <= length, message: (a) => `The ${a} field must not be greater than ${length} characters.` }); }
|
|
1635
|
+
_minLength = null;
|
|
1636
|
+
_placeholder = null;
|
|
1637
|
+
_readOnly = false;
|
|
1638
|
+
_autosize = false;
|
|
1639
|
+
_grammarly = true;
|
|
1640
|
+
_trim = false;
|
|
1641
|
+
minLength(length) { this._minLength = length; return this.rule({ name: 'min', passes: (v) => String(v).length >= length, message: (a) => `The ${a} field must be at least ${length} characters.` }); }
|
|
1642
|
+
/** Exact length. */
|
|
1643
|
+
length(length) { this.minLength(length); return this.maxLength(length); }
|
|
1644
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
1645
|
+
readOnly(condition = true) { this._readOnly = condition; return this; }
|
|
1646
|
+
/** The textarea grows with its content. */
|
|
1647
|
+
autosize(condition = true) { this._autosize = condition; return this; }
|
|
1648
|
+
/** Stops the Grammarly extension from analyzing the content. */
|
|
1649
|
+
disableGrammarly(condition = true) { this._grammarly = !condition; return this; }
|
|
1650
|
+
/** Removes the spaces around the value before it is validated and saved. */
|
|
1651
|
+
trim(condition = true) { this._trim = condition; return this; }
|
|
1652
|
+
hydrateState(value) { return this._trim && typeof value === 'string' ? value.trim() : value; }
|
|
1653
|
+
/** `trim()`: the submitted value is trimmed before it is validated and saved. */
|
|
1654
|
+
stateForValidation(state) { return this._trim && typeof state === 'string' ? state.trim() : state; }
|
|
1655
|
+
dehydrateState(state) { return this._trim && typeof state === 'string' ? state.trim() : state; }
|
|
800
1656
|
async toHtml() {
|
|
801
1657
|
const isDisabled = await this.isDisabled();
|
|
802
1658
|
// Initial height: 1.5rem per row plus padding.
|
|
803
1659
|
const height = (this._rows ?? 2) * 1.5 + 0.75;
|
|
804
1660
|
const textarea = new Attributes().merge({
|
|
805
|
-
'aria-invalid': this.hasError() ? 'true' : null,
|
|
806
|
-
|
|
1661
|
+
'aria-invalid': this.hasError() ? 'true' : null, 'data-gramm': this._grammarly ? null : 'false', 'data-gramm_editor': this._grammarly ? null : 'false', 'data-enable-grammarly': this._grammarly ? null : 'false',
|
|
1662
|
+
disabled: isDisabled, id: this.getId(), maxlength: this._maxLength, minlength: this._minLength, placeholder: this._placeholder, readonly: this._readOnly,
|
|
1663
|
+
required: (await this.isRequired()) && !isDisabled && !this._readOnly, rows: this._rows, [this.wireModel()]: this.getStatePath(),
|
|
807
1664
|
});
|
|
808
1665
|
const wrapper = new Attributes().class(['fi-fo-textarea', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
809
1666
|
return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><div wire:ignore.self${height !== null ? ` style="height: ${height}rem"` : ''}><textarea x-load x-load-src="${e(asset('textarea'))}" x-data="textareaFormComponent({
|
|
810
1667
|
initialHeight: ${height ?? 'null'},
|
|
811
|
-
shouldAutosize: false,
|
|
1668
|
+
shouldAutosize: ${this._autosize ? 'true' : 'false'},
|
|
812
1669
|
state: $wire.$entangle(${e(js(this.getStatePath()))}),
|
|
813
1670
|
})" x-model="state" ${textarea.toHtml()}></textarea></div></div></div>`, { wrapperClass: 'fi-fo-textarea-wrp' });
|
|
814
1671
|
}
|
|
@@ -824,9 +1681,28 @@ export class TagsInput extends Field {
|
|
|
824
1681
|
suggestions(suggestions) { this._suggestions = suggestions.map(String).slice(0, 1000); return this; }
|
|
825
1682
|
/** Keys that confirm the current tag, besides Enter (`['Tab', ',']`). */
|
|
826
1683
|
splitKeys(keys) { this._splitKeys = keys; return this; }
|
|
1684
|
+
_separator = null;
|
|
1685
|
+
_tagPrefix = null;
|
|
1686
|
+
_tagSuffix = null;
|
|
1687
|
+
_reorderable = false;
|
|
1688
|
+
_tagColor = null;
|
|
1689
|
+
_tagRules = [];
|
|
1690
|
+
/** Saves the tags as one string joined with this separator (`'a,b,c'`) instead of a list. */
|
|
1691
|
+
separator(separator = ',') { this._separator = separator; return this; }
|
|
1692
|
+
/** Text shown before / after each tag (not saved). */
|
|
1693
|
+
tagPrefix(prefix) { this._tagPrefix = prefix; return this; }
|
|
1694
|
+
tagSuffix(suffix) { this._tagSuffix = suffix; return this; }
|
|
1695
|
+
/** Tags can be reordered by dragging. */
|
|
1696
|
+
reorderable(condition = true) { this._reorderable = condition; return this; }
|
|
1697
|
+
/** Badge color of the tags (`primary` by default). */
|
|
1698
|
+
color(color) { this._tagColor = color; return this; }
|
|
1699
|
+
/** Rules applied to each tag (e.g. a maximum length); the message names the tag. */
|
|
1700
|
+
nestedRecursiveRules(rules) { this._tagRules = rules.map((rule) => (typeof rule === 'string' ? stringRule(rule, { numeric: false, name: this._name ?? '' }) : rule)).filter((rule) => rule !== null); return this; }
|
|
827
1701
|
getDefaultState() { return this._default ?? []; }
|
|
828
1702
|
hydrateState(value) {
|
|
829
|
-
if (typeof value === 'string')
|
|
1703
|
+
if (typeof value === 'string' && this._separator !== null)
|
|
1704
|
+
value = value === '' ? [] : value.split(this._separator);
|
|
1705
|
+
else if (typeof value === 'string') {
|
|
830
1706
|
try {
|
|
831
1707
|
value = JSON.parse(value);
|
|
832
1708
|
}
|
|
@@ -834,10 +1710,23 @@ export class TagsInput extends Field {
|
|
|
834
1710
|
value = value === '' ? [] : [value];
|
|
835
1711
|
}
|
|
836
1712
|
}
|
|
837
|
-
return Array.isArray(value) ? value.filter((tag) => typeof tag === 'string' || typeof tag === 'number').map(String) : [];
|
|
1713
|
+
return Array.isArray(value) ? value.filter((tag) => typeof tag === 'string' || typeof tag === 'number').map((tag) => String(tag).trim()).filter((tag) => tag !== '') : [];
|
|
1714
|
+
}
|
|
1715
|
+
/** Only non-empty strings are saved, whatever shape the browser sends (joined with the separator, if any). */
|
|
1716
|
+
dehydrateState(state) {
|
|
1717
|
+
const tags = (Array.isArray(state) ? state : []).filter((tag) => typeof tag === 'string' || typeof tag === 'number').map((tag) => String(tag).trim()).filter((tag) => tag !== '').slice(0, 1000);
|
|
1718
|
+
return this._separator !== null ? tags.join(this._separator) : tags;
|
|
1719
|
+
}
|
|
1720
|
+
async getValidationRules() {
|
|
1721
|
+
if (!this._tagRules.length)
|
|
1722
|
+
return super.getValidationRules();
|
|
1723
|
+
return [...(await super.getValidationRules()), ...this._tagRules.map((rule) => ({
|
|
1724
|
+
...rule,
|
|
1725
|
+
passes: async (value, ctx) => { for (const tag of Array.isArray(value) ? value : [])
|
|
1726
|
+
if (!(await rule.passes(tag, ctx)))
|
|
1727
|
+
return false; return true; },
|
|
1728
|
+
}))];
|
|
838
1729
|
}
|
|
839
|
-
/** Only non-empty strings are saved, whatever shape the browser sends. */
|
|
840
|
-
dehydrateState(state) { return (Array.isArray(state) ? state : []).filter((tag) => typeof tag === 'string' || typeof tag === 'number').map((tag) => String(tag).trim()).filter((tag) => tag !== '').slice(0, 1000); }
|
|
841
1730
|
async toHtml() {
|
|
842
1731
|
if (!colorManager)
|
|
843
1732
|
throw new Error('No color manager: call useColorManager().');
|
|
@@ -850,14 +1739,15 @@ export class TagsInput extends Field {
|
|
|
850
1739
|
const input = new Attributes()
|
|
851
1740
|
.merge({ autocomplete: 'off', disabled: isDisabled, id, list: `${id}-suggestions`, placeholder: this._placeholder ?? t('filament-forms::components.tags_input.placeholder'), type: 'text', 'x-bind': 'input' })
|
|
852
1741
|
.class(['fi-input']);
|
|
853
|
-
const badge = ['fi-badge', 'fi-size-md', ...colorManager.getComponentClasses('badge', 'primary')].join(' ');
|
|
1742
|
+
const badge = ['fi-badge', 'fi-size-md', ...colorManager.getComponentClasses('badge', this._tagColor ?? 'primary'), this._reorderable && !isDisabled ? 'fi-reorderable' : null].filter(Boolean).join(' ');
|
|
1743
|
+
const reorderable = this._reorderable && !isDisabled;
|
|
854
1744
|
const deleteButton = isDisabled ? '' : `<button type="button" x-on:click.stop="deleteTag(tag)" x-bind:aria-label="'${e(t('filament-forms::components.tags_input.actions.delete.label'))}: ' + tag" class="fi-badge-delete-btn"><span aria-hidden="true" class="fi-badge-delete-btn-icon"></span></button>`;
|
|
855
1745
|
return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><div x-load x-load-src="${e(asset('tags-input'))}" x-data="tagsInputFormComponent({
|
|
856
1746
|
state: $wire.$entangle(${e(js(this.getStatePath()))}, false),
|
|
857
1747
|
splitKeys: ${this._splitKeys.length ? e(js(this._splitKeys)) : '[]'},
|
|
858
1748
|
tagAddedMessage: ${e(js(t('filament-forms::components.tags_input.tag_added')))},
|
|
859
1749
|
tagRemovedMessage: ${e(js(t('filament-forms::components.tags_input.tag_removed')))},
|
|
860
|
-
})"><input ${input.toHtml()} /><div x-ref="liveRegion" aria-live="polite" class="fi-sr-only"></div><datalist id="${e(id)}-suggestions">${this._suggestions.map((s) => `<template x-bind:key="${e(js(s))}" x-if="! (state?.includes(${e(js(s))}) ?? true)"><option value="${e(s)}" /></template>`).join('')}</datalist><div wire:ignore wire:key="${e(this.getLivewireKey() ?? '')}.${createHash('md5').update(JSON.stringify(this.context().state ?? [])).digest('hex')}"><template x-cloak x-if="state?.length"><div class="fi-fo-tags-input-tags-ctn"><template x-for="(tag, index) in state" x-bind:key="\`\${tag}-\${index}\`"><span class="${badge}"><span class="fi-badge-label-ctn"><span class="fi-badge-label"
|
|
1750
|
+
})"><input ${input.toHtml()} /><div x-ref="liveRegion" aria-live="polite" class="fi-sr-only"></div><datalist id="${e(id)}-suggestions">${this._suggestions.map((s) => `<template x-bind:key="${e(js(s))}" x-if="! (state?.includes(${e(js(s))}) ?? true)"><option value="${e(s)}" /></template>`).join('')}</datalist><div wire:ignore wire:key="${e(this.getLivewireKey() ?? '')}.${createHash('md5').update(JSON.stringify(this.context().state ?? [])).digest('hex')}"><template x-cloak x-if="state?.length"><div${reorderable ? ' x-on:end.stop="reorderTags($event)" x-sortable data-sortable-animation-duration="300"' : ''} class="fi-fo-tags-input-tags-ctn"><template x-for="(tag, index) in state" x-bind:key="\`\${tag}-\${index}\`"><span${reorderable ? ' x-bind:x-sortable-item="index" x-sortable-handle' : ''} class="${badge}"><span class="fi-badge-label-ctn"><span class="fi-badge-label">${e(this._tagPrefix ?? '')}<span x-text="tag"></span>${e(this._tagSuffix ?? '')}</span></span>${deleteButton}</span></template></div></template></div></div></div></div>`, { wrapperClass: 'fi-fo-tags-input-wrp' });
|
|
861
1751
|
}
|
|
862
1752
|
}
|
|
863
1753
|
/** On/off switch. */
|
|
@@ -865,14 +1755,32 @@ export class Toggle extends Field {
|
|
|
865
1755
|
static make(name) { return configured(new Toggle(name)); }
|
|
866
1756
|
getDefaultState() { return this._default ?? false; }
|
|
867
1757
|
hydrateState(value) { return value === true || value === 1 || value === '1'; }
|
|
1758
|
+
_onIcon = null;
|
|
1759
|
+
_offIcon = null;
|
|
1760
|
+
_onColor = null;
|
|
1761
|
+
_offColor = null;
|
|
1762
|
+
_inline = true;
|
|
1763
|
+
/** Icons inside the switch's knob. */
|
|
1764
|
+
onIcon(icon) { this._onIcon = icon; return this; }
|
|
1765
|
+
offIcon(icon) { this._offIcon = icon; return this; }
|
|
1766
|
+
/** Colors when on (`primary` by default) and off (`gray`). */
|
|
1767
|
+
onColor(color) { this._onColor = color; return this; }
|
|
1768
|
+
offColor(color) { this._offColor = color; return this; }
|
|
1769
|
+
/** `false`: the label goes above the switch instead of beside it. */
|
|
1770
|
+
inline(condition = true) { this._inline = condition; return this; }
|
|
868
1771
|
async toHtml() {
|
|
869
1772
|
if (!colorManager)
|
|
870
1773
|
throw new Error('No color manager: call useColorManager().');
|
|
871
1774
|
const on = this.context().state === true;
|
|
872
|
-
const colors = colorManager.getComponentClasses('toggle', 'primary').join(' ');
|
|
1775
|
+
const colors = colorManager.getComponentClasses('toggle', this._onColor ?? 'primary').join(' ');
|
|
1776
|
+
const offColors = this._offColor && this._offColor !== 'gray' ? ` ${colorManager.getComponentClasses('toggle', this._offColor).join(' ')}` : '';
|
|
1777
|
+
const onIconHtml = this._onIcon ? icon(this._onIcon, { size: 'xs' }) : '';
|
|
1778
|
+
const offIconHtml = this._offIcon ? icon(this._offIcon, { size: 'xs' }) : '';
|
|
873
1779
|
const button = new Attributes().merge({ 'aria-describedby': this.getHelperTextId(), 'aria-checked': on ? 'true' : 'false', disabled: await this.isDisabled(), id: this.getId(), 'wire:target': this.getStatePath() }).class(['fi-fo-toggle', 'fi-toggle']);
|
|
874
|
-
const knob =
|
|
875
|
-
|
|
1780
|
+
const knob = `<div><div aria-hidden="true">${offIconHtml}</div><div aria-hidden="true">${onIconHtml}</div></div>`;
|
|
1781
|
+
const placeholderKnob = `<div><div aria-hidden="true"></div><div aria-hidden="true">${onIconHtml}</div></div>`;
|
|
1782
|
+
const control = `<button x-data="{ state: $wire.$entangle(${e(js(this.getStatePath()))}, ${this.isLive() ? 'true' : 'false'}) }" x-bind:aria-checked="state ? 'true' : 'false'" x-on:click="state = ! state" x-bind:class="state ? 'fi-toggle-on ${colors}' : 'fi-toggle-off${offColors}'"${on ? ' x-cloak' : ''} role="switch" type="button" ${button.toHtml()}>${knob}</button>${on ? `<div x-cloak="inline-flex" wire:ignore wire:key="${e(this.getLivewireKey() ?? '')}.placeholder" class="fi-toggle fi-toggle-on fi-hidden ${colors}">${placeholderKnob}</div>` : ''}`;
|
|
1783
|
+
return this._inline ? this.wrap('', { labelPrefix: control }) : this.wrap(control);
|
|
876
1784
|
}
|
|
877
1785
|
}
|
|
878
1786
|
/** Single choice shown as buttons. */
|
|
@@ -895,8 +1803,59 @@ export class ToggleButtons extends Field {
|
|
|
895
1803
|
inline(condition = true) { this._inline = condition; return this; }
|
|
896
1804
|
/** Buttons joined into a single group. */
|
|
897
1805
|
grouped(condition = true) { this._grouped = condition; return this; }
|
|
1806
|
+
_multiple = false;
|
|
1807
|
+
_tooltips = {};
|
|
1808
|
+
_size = null;
|
|
1809
|
+
_labelsHidden = false;
|
|
1810
|
+
_fullWidth = false;
|
|
1811
|
+
_columns = null;
|
|
1812
|
+
_disableOptionWhen = null;
|
|
1813
|
+
/** Several buttons can be selected: the state is a list of values. */
|
|
1814
|
+
multiple(condition = true) { this._multiple = condition; return this; }
|
|
1815
|
+
/** Tooltip per value. */
|
|
1816
|
+
tooltips(tooltips) { this._tooltips = tooltips; return this; }
|
|
1817
|
+
/** Button size: `xs`, `sm`, `md` (default), `lg` or `xl`. */
|
|
1818
|
+
size(size) { this._size = size; return this; }
|
|
1819
|
+
/** Icons only: the labels stay for screen readers. */
|
|
1820
|
+
hiddenButtonLabels(condition = true) { this._labelsHidden = condition; return this; }
|
|
1821
|
+
/** Buttons stretch to the width of the field. */
|
|
1822
|
+
fullWidth(condition = true) { this._fullWidth = condition; return this; }
|
|
1823
|
+
/** Number of columns the buttons are spread over (from `lg`), when not inline. */
|
|
1824
|
+
columns(columns) { this._columns = columns; return this; }
|
|
1825
|
+
/** Options that cannot be selected; they are refused on the server too. */
|
|
1826
|
+
disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
|
|
1827
|
+
isOptionDisabled(value, label) { return this._disableOptionWhen ? this._disableOptionWhen(value, label) : false; }
|
|
1828
|
+
/** "Yes" (`1`) and "No" (`0`) buttons, green and red, with check and cross icons. */
|
|
1829
|
+
boolean(trueLabel = null, falseLabel = null) {
|
|
1830
|
+
this.options([['1', trueLabel ?? t('filament-forms::components.toggle_buttons.boolean.true')], ['0', falseLabel ?? t('filament-forms::components.toggle_buttons.boolean.false')]]);
|
|
1831
|
+
this._colors = { 1: 'success', 0: 'danger', ...this._colors };
|
|
1832
|
+
this._icons = { 1: 'o-check', 0: 'o-x-mark', ...this._icons };
|
|
1833
|
+
return this;
|
|
1834
|
+
}
|
|
1835
|
+
getDefaultState() { return this._default ?? (this._multiple ? [] : null); }
|
|
1836
|
+
hydrateState(value) {
|
|
1837
|
+
if (!this._multiple)
|
|
1838
|
+
return value;
|
|
1839
|
+
if (typeof value === 'string') {
|
|
1840
|
+
try {
|
|
1841
|
+
value = JSON.parse(value);
|
|
1842
|
+
}
|
|
1843
|
+
catch {
|
|
1844
|
+
value = value === '' ? [] : [value];
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
return (Array.isArray(value) ? value : []).map(String);
|
|
1848
|
+
}
|
|
1849
|
+
/** Multiple: only offered values, in option order. */
|
|
1850
|
+
dehydrateState(state) {
|
|
1851
|
+
if (!this._multiple)
|
|
1852
|
+
return state;
|
|
1853
|
+
const chosen = new Set((Array.isArray(state) ? state : []).map(String));
|
|
1854
|
+
return this._options.map(([value]) => value).filter((value) => chosen.has(value));
|
|
1855
|
+
}
|
|
898
1856
|
async getValidationRules() {
|
|
899
|
-
|
|
1857
|
+
const allowed = (v) => this._options.some(([value, label]) => value === String(v) && !this.isOptionDisabled(value, label));
|
|
1858
|
+
return [...(await super.getValidationRules()), { name: 'in', passes: (v) => (this._multiple ? Array.isArray(v) && v.every(allowed) : allowed(v)), message: (a) => `The selected ${a} is invalid.` }];
|
|
900
1859
|
}
|
|
901
1860
|
async toHtml() {
|
|
902
1861
|
if (!colorManager)
|
|
@@ -907,16 +1866,22 @@ export class ToggleButtons extends Field {
|
|
|
907
1866
|
const describedBy = [isRequired ? `${id}-required` : null, this.hasError() ? `${id}-error` : null].filter(Boolean).join(' ') || null;
|
|
908
1867
|
const container = new Attributes()
|
|
909
1868
|
.merge({ 'aria-describedby': describedBy, 'aria-labelledby': `${id}-label`, 'aria-required': isRequired ? 'true' : null, role: 'radiogroup', 'wire:key': `${this.getLivewireKey()}.inputs` })
|
|
910
|
-
.class(['fi-fo-toggle-buttons', this._grouped ? 'fi-btn-group' : null, !this._grouped && this._inline ? 'fi-inline' : null]);
|
|
1869
|
+
.class(['fi-fo-toggle-buttons', this._grouped ? 'fi-btn-group' : null, !this._grouped && this._inline ? 'fi-inline' : null, { 'fi-width-full': this._fullWidth }]);
|
|
1870
|
+
if (this._multiple)
|
|
1871
|
+
container.merge({ role: 'group', 'aria-required': null });
|
|
1872
|
+
const grid = !this._grouped && !this._inline && this._columns && this._columns > 1 ? ` class="fi-grid fi-grid-direction-col lg:fi-grid-cols" style="--cols-lg: ${Math.floor(this._columns)}; --cols-default: 1;"` : '';
|
|
911
1873
|
const manager = colorManager;
|
|
912
|
-
const button = (value) => ['fi-btn', this._grouped ? 'fi-btn-group-btn' : null,
|
|
1874
|
+
const button = (value, disabled) => ['fi-btn', this._grouped ? 'fi-btn-group-btn' : null, `fi-size-${this._size ?? 'md'}`, disabled ? 'fi-disabled' : null, ...manager.getComponentClasses('button', this._colors[value] ?? 'primary')].filter(Boolean).join(' ');
|
|
913
1875
|
const items = this._options.map(([value, label]) => {
|
|
914
|
-
const
|
|
915
|
-
const
|
|
1876
|
+
const optionDisabled = this.isOptionDisabled(value, label);
|
|
1877
|
+
const input = new Attributes().merge({ id: `${id}-${value}`, name: this._multiple ? null : id, type: this._multiple ? 'checkbox' : 'radio', value, [this._live ? 'wire:model.live' : 'wire:model']: this.getStatePath(), disabled: isDisabled || optionDisabled, 'aria-label': this._labelsHidden ? label : null, required: isRequired && !this._multiple && !optionDisabled, 'aria-describedby': describedBy }).class(['fi-fo-toggle-buttons-input']);
|
|
1878
|
+
const tooltip = this._tooltips[value] ? ` x-tooltip="${e(`{ content: ${js(this._tooltips[value])}, theme: $store.theme, allowHTML: false }`)}"` : '';
|
|
1879
|
+
const iconSize = this._size === 'xs' || this._size === 'sm' ? 'sm' : undefined;
|
|
1880
|
+
const html = `<input ${input.toHtml()} /><label for="${e(`${id}-${value}`)}"${tooltip}${this._labelsHidden ? ` aria-label="${e(label)}"` : ''} class="${button(value, optionDisabled)}">${this._icons[value] ? icon(this._icons[value], iconSize ? { size: iconSize } : {}) : ''}${this._labelsHidden ? '' : e(label)}</label>`;
|
|
916
1881
|
return this._grouped ? html : `<div class="fi-fo-toggle-buttons-btn-ctn">${html}</div>`;
|
|
917
1882
|
});
|
|
918
1883
|
const after = isRequired ? `<span id="${e(id)}-required" class="fi-sr-only">${e(t('filament-forms::components.toggle_buttons.required_description'))}</span>` : '';
|
|
919
|
-
return this.wrap(`<div ${container.toHtml()}>${items.join('')}</div>`, { wrapperClass: 'fi-fo-toggle-buttons-wrp', labelAsDiv: true, focusable: true, after });
|
|
1884
|
+
return this.wrap(`<div ${container.toHtml()}>${grid ? `<div${grid}>${items.join('')}</div>` : items.join('')}</div>`, { wrapperClass: 'fi-fo-toggle-buttons-wrp', labelAsDiv: true, focusable: true, after });
|
|
920
1885
|
}
|
|
921
1886
|
}
|
|
922
1887
|
// Server-side TipTap schema: the nodes and marks the rich editor's default toolbar can produce.
|
|
@@ -944,13 +1909,87 @@ export function richDocumentToHtml(document) {
|
|
|
944
1909
|
}
|
|
945
1910
|
/**
|
|
946
1911
|
* Rich text editor: the browser works on a TipTap document, the database receives HTML.
|
|
947
|
-
* Not supported yet:
|
|
1912
|
+
* Not supported yet: custom blocks, merge tags, mentions, text colors.
|
|
948
1913
|
*/
|
|
1914
|
+
/**
|
|
1915
|
+
* Stores a file uploaded by an editor (`componentFileAttachments.<state path>[.<id>]`): type checked on the file's content, size,
|
|
1916
|
+
* then stored; the reference is consumed. Returns the public URL (local or http(s) only).
|
|
1917
|
+
*/
|
|
1918
|
+
async function storeEditorAttachment(field, id, options) {
|
|
1919
|
+
if (!options.storage)
|
|
1920
|
+
return null;
|
|
1921
|
+
const livewire = field.livewire;
|
|
1922
|
+
const segments = field.getStatePath().split('.');
|
|
1923
|
+
const holder = readPath(livewire.componentFileAttachments ?? {}, id === null ? segments.slice(0, -1) : segments);
|
|
1924
|
+
const name = id ?? segments[segments.length - 1];
|
|
1925
|
+
if (!holder || typeof holder !== 'object' || !Object.hasOwn(holder, name))
|
|
1926
|
+
return null;
|
|
1927
|
+
const uploads = livewire._runtime?.uploads;
|
|
1928
|
+
const file = uploads?.resolve(holder[name]);
|
|
1929
|
+
delete holder[name];
|
|
1930
|
+
if (!file || !options.types.includes(file.type) || file.size > options.maxSize * 1024)
|
|
1931
|
+
return null;
|
|
1932
|
+
const key = await options.storage.store(file, options.directory);
|
|
1933
|
+
uploads.remove(file.id);
|
|
1934
|
+
const info = await options.storage.info(key);
|
|
1935
|
+
return info && /^(\/(?!\/)|https?:\/\/)/.test(info.url) ? info.url : null;
|
|
1936
|
+
}
|
|
1937
|
+
/** Tools of the rich editor's toolbar, in their default groups. */
|
|
1938
|
+
export const RICH_EDITOR_TOOLS = [['bold', 'italic', 'underline', 'strike', 'subscript', 'superscript', 'link'], ['h2', 'h3'], ['alignStart', 'alignCenter', 'alignEnd'], ['blockquote', 'codeBlock', 'bulletList', 'orderedList'], ['table', 'attachFiles'], ['undo', 'redo']];
|
|
949
1939
|
export class RichEditor extends Field {
|
|
1940
|
+
_toolbar = null;
|
|
1941
|
+
_disabledTools = [];
|
|
1942
|
+
_json = false;
|
|
1943
|
+
_richPlaceholder = null;
|
|
1944
|
+
_attachmentsStorage = null;
|
|
1945
|
+
_attachmentsDirectory = null;
|
|
1946
|
+
_attachmentTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
1947
|
+
_attachmentMaxSize = 12288;
|
|
950
1948
|
static make(name) { return configured(new RichEditor(name)); }
|
|
951
|
-
|
|
1949
|
+
/** Toolbar tools, by group (`[['bold', 'italic', 'link'], ['h2', 'h3'], ['undo', 'redo']]`); see `RICH_EDITOR_TOOLS`. */
|
|
1950
|
+
toolbarButtons(groups) {
|
|
1951
|
+
const list = (groups.length && typeof groups[0] === 'string' ? [groups] : groups);
|
|
1952
|
+
const known = new Set(RICH_EDITOR_TOOLS.flat());
|
|
1953
|
+
const unknown = list.flat().filter((tool) => !known.has(tool));
|
|
1954
|
+
if (unknown.length)
|
|
1955
|
+
throw new Error(`RichEditor: unknown toolbar buttons ${unknown.join(', ')} (available: ${[...known].join(', ')}).`);
|
|
1956
|
+
this._toolbar = list;
|
|
1957
|
+
return this;
|
|
1958
|
+
}
|
|
1959
|
+
/** Removes buttons; with no argument, removes them all. */
|
|
1960
|
+
disableToolbarButtons(tools = []) { if (!tools.length)
|
|
1961
|
+
this._toolbar = []; this._disabledTools = [...this._disabledTools, ...tools]; return this; }
|
|
1962
|
+
disableAllToolbarButtons(condition = true) { if (condition)
|
|
1963
|
+
this._toolbar = []; return this; }
|
|
1964
|
+
/** Saves the content as the editor's JSON document instead of HTML. */
|
|
1965
|
+
json(condition = true) { this._json = condition; return this; }
|
|
1966
|
+
placeholder(text) { this._richPlaceholder = text; return this; }
|
|
1967
|
+
/** Where files attached in the editor are stored (`FileUpload.storage` by default); without storage, attaching is off. */
|
|
1968
|
+
fileAttachmentsStorage(storage) { this._attachmentsStorage = storage; return this; }
|
|
1969
|
+
fileAttachmentsDirectory(directory) { this._attachmentsDirectory = directory; return this; }
|
|
1970
|
+
/** Accepted types of attached files, checked on the file's content (images only: they are inserted as images). */
|
|
1971
|
+
fileAttachmentsAcceptedFileTypes(types) { this._attachmentTypes = types.filter((type) => type.startsWith('image/') && type !== 'image/svg+xml'); return this; }
|
|
1972
|
+
/** Maximum size of attached files, in kilobytes. */
|
|
1973
|
+
fileAttachmentsMaxSize(kilobytes) { this._attachmentMaxSize = kilobytes; return this; }
|
|
1974
|
+
attachmentsStorage() { return this._attachmentsStorage ?? FileUpload.storage; }
|
|
1975
|
+
tools() { return (this._toolbar ?? RICH_EDITOR_TOOLS).map((group) => group.filter((tool) => !this._disabledTools.includes(tool) && (tool !== 'attachFiles' || this.attachmentsStorage() !== null))).filter((group) => group.length); }
|
|
1976
|
+
hydrateState(value) {
|
|
1977
|
+
if (typeof value === 'string' && value.trim().startsWith('{')) {
|
|
1978
|
+
try {
|
|
1979
|
+
const parsed = JSON.parse(value);
|
|
1980
|
+
if (isRichDocument(parsed))
|
|
1981
|
+
return parsed;
|
|
1982
|
+
}
|
|
1983
|
+
catch { /* HTML */ }
|
|
1984
|
+
}
|
|
1985
|
+
return isRichDocument(value) ? value : richHtmlToDocument(typeof value === 'string' ? value : '');
|
|
1986
|
+
}
|
|
952
1987
|
getDefaultState() { return richHtmlToDocument(typeof this._default === 'string' ? this._default : ''); }
|
|
953
|
-
|
|
1988
|
+
/** HTML (sanitized by the editor's schema), or the JSON document with `json()` (normalized through the same schema). */
|
|
1989
|
+
dehydrateState(state) {
|
|
1990
|
+
const html = richDocumentToHtml(state);
|
|
1991
|
+
return this._json ? (html === null ? null : richHtmlToDocument(html)) : html;
|
|
1992
|
+
}
|
|
954
1993
|
async getValidationRules() {
|
|
955
1994
|
const rules = [];
|
|
956
1995
|
if (await this.isRequired())
|
|
@@ -961,8 +2000,18 @@ export class RichEditor extends Field {
|
|
|
961
2000
|
getMentionSearchResultsForJs() { return []; }
|
|
962
2001
|
getMentionLabelsForJs() { return []; }
|
|
963
2002
|
getCustomBlockPreviewsForJs() { return []; }
|
|
964
|
-
|
|
965
|
-
|
|
2003
|
+
/**
|
|
2004
|
+
* URL of a file the editor just uploaded: the file is checked (type from its content, size), stored, and its public URL returned.
|
|
2005
|
+
* It stays stored even if the form is not saved.
|
|
2006
|
+
*/
|
|
2007
|
+
async getUploadedFileAttachmentTemporaryUrl(args = {}) {
|
|
2008
|
+
const id = String(args?.attachment ?? '');
|
|
2009
|
+
const storage = this.attachmentsStorage();
|
|
2010
|
+
if (!storage || !/^[A-Za-z0-9_-]{1,64}$/.test(id) || !this.tools().flat().includes('attachFiles') || (await this.isDisabled()))
|
|
2011
|
+
return null;
|
|
2012
|
+
return storeEditorAttachment(this, id, { storage, directory: this._attachmentsDirectory, types: this._attachmentTypes, maxSize: this._attachmentMaxSize });
|
|
2013
|
+
}
|
|
2014
|
+
async saveUploadedFileAttachmentAndGetUrl(args = {}) { return this.getUploadedFileAttachmentTemporaryUrl(args); }
|
|
966
2015
|
/** Toolbar "link" action: a modal (URL, new tab), then commands sent back to the browser editor. */
|
|
967
2016
|
getActions() {
|
|
968
2017
|
const tr = (name) => t(`filament-forms::components.rich_editor.actions.link.${name}`);
|
|
@@ -990,17 +2039,57 @@ export class RichEditor extends Field {
|
|
|
990
2039
|
];
|
|
991
2040
|
}
|
|
992
2041
|
async toHtml() {
|
|
993
|
-
|
|
2042
|
+
let html = views.render('filament-forms::components.rich-editor', { livewireId: this.livewire.id, key: this.getKey(), statePath: this.getStatePath(), label: await this.getLabel(), js });
|
|
2043
|
+
// Toolbar: the snapshot's buttons, regrouped as configured.
|
|
2044
|
+
const toolbarStart = html.indexOf('data-rich-editor-toolbar="main"');
|
|
2045
|
+
if (toolbarStart >= 0) {
|
|
2046
|
+
const open = html.indexOf('>', toolbarStart) + 1;
|
|
2047
|
+
// The toolbar ends right before the "uploading file" message.
|
|
2048
|
+
const message = html.indexOf('x-show="isUploadingFile"', open);
|
|
2049
|
+
const close = message < 0 ? -1 : html.lastIndexOf('<div', message);
|
|
2050
|
+
if (close < 0)
|
|
2051
|
+
throw new Error('RichEditor: unexpected view markup (toolbar end not found).');
|
|
2052
|
+
const body = html.slice(open, close);
|
|
2053
|
+
const buttons = new Map();
|
|
2054
|
+
for (const match of body.matchAll(/<button data-rich-editor-tool="([A-Za-z0-9]+)"[\s\S]*?<\/button>/g))
|
|
2055
|
+
buttons.set(match[1], match[0]);
|
|
2056
|
+
const groups = this.tools().map((group) => `<div class="fi-fo-rich-editor-toolbar-group">${group.map((tool) => buttons.get(tool) ?? '').join('')}</div>`).join('');
|
|
2057
|
+
const endOfToolbar = body.lastIndexOf('</div>');
|
|
2058
|
+
html = html.slice(0, open) + groups + body.slice(endOfToolbar) + html.slice(close);
|
|
2059
|
+
}
|
|
2060
|
+
const attach = this.tools().flat().includes('attachFiles');
|
|
2061
|
+
const tr = (key, replace = {}) => t(`filament-forms::components.rich_editor.${key}`, replace);
|
|
2062
|
+
html = html.replace('canAttachFiles: true,', `canAttachFiles: ${attach ? 'true' : 'false'},`)
|
|
2063
|
+
.replace('maxFileSize: 12288,', `maxFileSize: ${this._attachmentMaxSize},`)
|
|
2064
|
+
.replace(/maxFileSizeValidationMessage: '[^']*',/, () => `maxFileSizeValidationMessage: ${js(`Uploaded files must not be greater than ${this._attachmentMaxSize} kilobytes.`)},`)
|
|
2065
|
+
.replace(/acceptedFileTypes: JSON\.parse\('[^']*'\),/, () => `acceptedFileTypes: ${js(this._attachmentTypes)},`)
|
|
2066
|
+
.replace(/acceptedFileTypesValidationMessage: '[^']*',/, () => `acceptedFileTypesValidationMessage: ${js(`Uploaded files must be of type: ${this._attachmentTypes.join(', ')}.`)},`)
|
|
2067
|
+
.replace('placeholder: null,', `placeholder: ${js(this._richPlaceholder)},`)
|
|
2068
|
+
.replace('isLiveOnBlur: false,', `isLiveOnBlur: ${this._liveMode === 'blur' ? 'true' : 'false'},`)
|
|
2069
|
+
.replace(/state: \$wire\.\$entangle\(('[^']*'), false\)/, (_m, path) => `state: $wire.$entangle(${path}, ${this._liveMode === 'live' ? 'true' : 'false'})`);
|
|
2070
|
+
void tr;
|
|
2071
|
+
return this.wrap(html, { labelAsDiv: true });
|
|
994
2072
|
}
|
|
995
2073
|
}
|
|
996
2074
|
/** Color picker (hex format). */
|
|
997
2075
|
export class ColorPicker extends Field {
|
|
998
2076
|
static make(name) { return configured(new ColorPicker(name)); }
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
2077
|
+
_format = 'hex';
|
|
2078
|
+
/** Color format of the state: `#ff0000` (default), `rgb(255, 0, 0)`, `rgba(255, 0, 0, 1)` or `hsl(0, 100%, 50%)`. */
|
|
2079
|
+
format(format) { this._format = format; return this; }
|
|
2080
|
+
hex() { return this.format('hex'); }
|
|
2081
|
+
rgb() { return this.format('rgb'); }
|
|
2082
|
+
rgba() { return this.format('rgba'); }
|
|
2083
|
+
hsl() { return this.format('hsl'); }
|
|
2084
|
+
async getValidationRules() {
|
|
2085
|
+
const patterns = {
|
|
2086
|
+
hex: /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/,
|
|
2087
|
+
rgb: /^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$/,
|
|
2088
|
+
rgba: /^rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0|1|0?\.\d+)\s*\)$/,
|
|
2089
|
+
hsl: /^hsl\(\s*\d{1,3}(\.\d+)?\s*,\s*\d{1,3}(\.\d+)?%\s*,\s*\d{1,3}(\.\d+)?%\s*\)$/,
|
|
2090
|
+
};
|
|
2091
|
+
const pattern = patterns[this._format];
|
|
2092
|
+
return [...(await super.getValidationRules()), { name: this._format === 'hex' ? 'hex_color' : 'regex', passes: (v) => pattern.test(String(v)), message: (a) => (this._format === 'hex' ? `The ${a} field must be a valid hexadecimal color.` : `The ${a} field format is invalid.`) }];
|
|
1004
2093
|
}
|
|
1005
2094
|
async toHtml() {
|
|
1006
2095
|
const isDisabled = await this.isDisabled();
|
|
@@ -1024,17 +2113,20 @@ export class ColorPicker extends Field {
|
|
|
1024
2113
|
state: $wire.$entangle(${e(js(this.getStatePath()))}),
|
|
1025
2114
|
})" x-on:dropdown-escape="if (isOpen()) { closePanelAndRestoreFocus(); $event.preventDefault() }" x-on:keydown.esc="isOpen() && $event.stopPropagation()" x-on:focusout="if (isOpen() && ! $el.contains($event.relatedTarget)) $refs.panel.close()" wire:key="${e(key)}.${hash}" class="fi-input-wrp-content"><input x-on:focus="$refs.panel.open($refs.input)" x-on:keydown.enter.prevent.stop="togglePanelVisibility()" x-ref="input" ${input.toHtml()} /><div aria-hidden="true" class="fi-fo-color-picker-preview my-auto me-3 size-5 shrink-0 rounded-full select-none" x-on:click="togglePanelVisibility()" x-bind:class="{
|
|
1026
2115
|
'fi-empty': ! state,
|
|
1027
|
-
}" x-bind:style="{ 'background-color': state }"></div><div wire:ignore.self wire:key="${e(key)}.panel" x-cloak x-float.placement.bottom-start.offset.flip.shift="{ offset: 8 }" x-ref="panel" id="${e(id)}-panel" role="dialog" aria-label="${e(t('filament-forms::components.color_picker.panel_label'))}" class="fi-fo-color-picker-panel"
|
|
2116
|
+
}" x-bind:style="{ 'background-color': state }"></div><div wire:ignore.self wire:key="${e(key)}.panel" x-cloak x-float.placement.bottom-start.offset.flip.shift="{ offset: 8 }" x-ref="panel" id="${e(id)}-panel" role="dialog" aria-label="${e(t('filament-forms::components.color_picker.panel_label'))}" class="fi-fo-color-picker-panel"><${({ hex: 'hex', rgb: 'rgb-string', rgba: 'rgba-string', hsl: 'hsl-string' })[this._format]}-color-picker x-ref="picker" color="${e(typeof state === 'string' ? state : '')}" /></div></div></div></div>`);
|
|
1028
2117
|
}
|
|
1029
2118
|
}
|
|
1030
2119
|
export class Checkbox extends Field {
|
|
2120
|
+
_inline = true;
|
|
1031
2121
|
static make(name) { return configured(new Checkbox(name)); }
|
|
1032
2122
|
getDefaultState() { return this._default ?? false; }
|
|
2123
|
+
/** `false`: the label goes above the checkbox instead of beside it. */
|
|
2124
|
+
inline(condition = true) { this._inline = condition; return this; }
|
|
1033
2125
|
async toHtml() {
|
|
1034
2126
|
const input = new Attributes()
|
|
1035
|
-
.merge({ type: 'checkbox', disabled: await this.isDisabled(), id: this.getId(), required: await this.isRequired(),
|
|
2127
|
+
.merge({ type: 'checkbox', disabled: await this.isDisabled(), id: this.getId(), required: await this.isRequired(), [this.wireModel()]: this.getStatePath() })
|
|
1036
2128
|
.class(['fi-checkbox-input', this.hasError() ? 'fi-invalid' : 'fi-valid']);
|
|
1037
|
-
return this.wrap('', { labelPrefix: `<input ${input.toHtml()} />` });
|
|
2129
|
+
return this._inline ? this.wrap('', { labelPrefix: `<input ${input.toHtml()} />` }) : this.wrap(`<input ${input.toHtml()} />`);
|
|
1038
2130
|
}
|
|
1039
2131
|
}
|
|
1040
2132
|
/** Checkboxes for a list of values (no search or "select all" yet). */
|
|
@@ -1045,6 +2137,23 @@ export class CheckboxList extends Field {
|
|
|
1045
2137
|
options(options) { this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]); return this; }
|
|
1046
2138
|
/** Number of columns the options are spread over (from `lg`). */
|
|
1047
2139
|
columns(columns) { this._optionColumns = columns; return this; }
|
|
2140
|
+
_descriptions = {};
|
|
2141
|
+
_searchable = false;
|
|
2142
|
+
_bulkToggleable = false;
|
|
2143
|
+
_disableOptionWhen = null;
|
|
2144
|
+
/** Helper text per value, under the option label. */
|
|
2145
|
+
descriptions(descriptions) { this._descriptions = descriptions; return this; }
|
|
2146
|
+
/** A search field filters the options in the browser. */
|
|
2147
|
+
searchable(condition = true) { this._searchable = condition; return this; }
|
|
2148
|
+
_searchPrompt = 'Search';
|
|
2149
|
+
_noSearchResultsMessage = 'No search results.';
|
|
2150
|
+
searchPrompt(prompt) { this._searchPrompt = prompt; return this; }
|
|
2151
|
+
noSearchResultsMessage(message) { this._noSearchResultsMessage = message; return this; }
|
|
2152
|
+
/** "Select all" / "Deselect all" links. */
|
|
2153
|
+
bulkToggleable(condition = true) { this._bulkToggleable = condition; return this; }
|
|
2154
|
+
/** Options that cannot be checked; they are refused on the server too. */
|
|
2155
|
+
disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
|
|
2156
|
+
isOptionDisabled(value, label) { return this._disableOptionWhen ? this._disableOptionWhen(value, label) : false; }
|
|
1048
2157
|
getDefaultState() { return this._default ?? []; }
|
|
1049
2158
|
hydrateState(value) {
|
|
1050
2159
|
let list = value;
|
|
@@ -1064,21 +2173,28 @@ export class CheckboxList extends Field {
|
|
|
1064
2173
|
return this._options.map(([value]) => value).filter((value) => chosen.has(value));
|
|
1065
2174
|
}
|
|
1066
2175
|
async getValidationRules() {
|
|
1067
|
-
|
|
2176
|
+
// A disabled option cannot be checked, even by a forged request.
|
|
2177
|
+
const known = new Set(this._options.filter(([value, label]) => !this.isOptionDisabled(value, label)).map(([value]) => value));
|
|
1068
2178
|
return [...(await super.getValidationRules()), { name: 'in', passes: (v) => Array.isArray(v) && v.every((value) => known.has(String(value))), message: (a) => `The selected ${a} is invalid.` }];
|
|
1069
2179
|
}
|
|
1070
2180
|
async toHtml() {
|
|
1071
2181
|
const isDisabled = await this.isDisabled();
|
|
1072
2182
|
const key = this.getLivewireKey() ?? '';
|
|
1073
2183
|
const options = this._options.map(([value, label]) => {
|
|
1074
|
-
const input = new Attributes().merge({ disabled: isDisabled, type: 'checkbox', value, '
|
|
1075
|
-
|
|
2184
|
+
const input = new Attributes().merge({ disabled: isDisabled || this.isOptionDisabled(value, label), type: 'checkbox', value, [this.wireModel()]: this.getStatePath(), 'x-on:change': this._bulkToggleable ? 'checkIfAllCheckboxesAreChecked()' : null }).class(['fi-checkbox-input', this.hasError() ? 'fi-invalid' : 'fi-valid']);
|
|
2185
|
+
const description = this._descriptions[value] ? `<p class="fi-fo-checkbox-list-option-description">${e(this._descriptions[value])}</p>` : '';
|
|
2186
|
+
return `<div wire:key="${e(`${key}.options.${value}`)}"${this._searchable ? ' x-show="matchesSearch($el)"' : ''} class="fi-fo-checkbox-list-option-ctn"><label class="fi-fo-checkbox-list-option"><input ${input.toHtml()} /><div class="fi-fo-checkbox-list-option-text"><span class="fi-fo-checkbox-list-option-label">${e(label)}</span>${description}</div></label></div>`;
|
|
1076
2187
|
});
|
|
1077
2188
|
const columns = this._optionColumns ?? 1;
|
|
1078
2189
|
const grid = columns > 1 ? `class="fi-grid fi-grid-direction-col lg:fi-grid-cols fi-fo-checkbox-list-options" style="--cols-lg: ${Math.floor(columns)}; --cols-default: 1;"` : 'class="fi-grid fi-grid-direction-col fi-fo-checkbox-list-options" style="--cols-default: 1;"';
|
|
2190
|
+
const searchPrompt = this._searchPrompt;
|
|
2191
|
+
const search = this._searchable ? `<div class="fi-input-wrp fi-fo-checkbox-list-search-input-wrp"><div class="fi-input-wrp-prefix fi-input-wrp-prefix-has-content fi-inline">${icon('m-magnifying-glass')}</div><div class="fi-input-wrp-content-ctn"><input aria-label="${e(searchPrompt)}" placeholder="${e(searchPrompt)}" type="search" x-model.debounce.1000="search" class="fi-input fi-input-has-inline-prefix" /></div></div>` : '';
|
|
2192
|
+
const link = (label) => `<button type="button" class="fi-ac-link-action fi-link fi-size-sm ${colorManager ? colorManager.getComponentClasses('link', 'primary').join(' ') : ''}"><span class="fi-link-label">${e(label)}</span></button>`;
|
|
2193
|
+
const bulk = this._bulkToggleable && !isDisabled && this._options.length ? `<div x-cloak class="fi-fo-checkbox-list-actions" wire:key="${e(key)}.actions"><span x-show="! areAllCheckboxesChecked" x-on:click="toggleAllCheckboxes()" wire:key="${e(key)}.actions.select-all">${link(t('filament-forms::components.checkbox_list.actions.select_all.label'))}</span><span x-show="areAllCheckboxesChecked" x-on:click="toggleAllCheckboxes()" wire:key="${e(key)}.actions.deselect-all">${link(t('filament-forms::components.checkbox_list.actions.deselect_all.label'))}</span></div>` : '';
|
|
2194
|
+
const noResults = this._searchable ? `<div x-cloak x-show="search && ! visibleCheckboxListOptions.length" role="status" aria-live="polite" class="fi-fo-checkbox-list-no-search-results-message">${e(this._noSearchResultsMessage)}</div>` : '';
|
|
1079
2195
|
return this.wrap(`<div role="group" x-load x-load-src="${e(asset('checkbox-list'))}" x-data="checkboxListFormComponent({
|
|
1080
2196
|
livewireId: ${e(js(this.livewire.id))},
|
|
1081
|
-
})" aria-labelledby="${e(this.getId())}-label" class="fi-fo-checkbox-list"
|
|
2197
|
+
})" aria-labelledby="${e(this.getId())}-label" class="fi-fo-checkbox-list">${search}${bulk}<div wire:key="${e(key)}.options"${this._searchable ? ' x-show="visibleCheckboxListOptions.length"' : ''} ${grid}>${options.join('')}</div>${noResults}</div>`, { labelAsDiv: true });
|
|
1082
2198
|
}
|
|
1083
2199
|
}
|
|
1084
2200
|
/** Free-form key/value pairs, saved as an object. */
|
|
@@ -1090,6 +2206,26 @@ export class KeyValue extends Field {
|
|
|
1090
2206
|
keyLabel(label) { this._keyLabel = label; return this; }
|
|
1091
2207
|
valueLabel(label) { this._valueLabel = label; return this; }
|
|
1092
2208
|
reorderable(condition = true) { this._reorderable = condition; return this; }
|
|
2209
|
+
_addable = true;
|
|
2210
|
+
_deletable = true;
|
|
2211
|
+
_editableKeys = true;
|
|
2212
|
+
_editableValues = true;
|
|
2213
|
+
_keyPlaceholder = null;
|
|
2214
|
+
_valuePlaceholder = null;
|
|
2215
|
+
_addLabel = null;
|
|
2216
|
+
_deleteLabel = null;
|
|
2217
|
+
/** `false`: no "Add row" button. */
|
|
2218
|
+
addable(condition = true) { this._addable = condition; return this; }
|
|
2219
|
+
/** `false`: rows cannot be deleted. */
|
|
2220
|
+
deletable(condition = true) { this._deletable = condition; return this; }
|
|
2221
|
+
/** `false`: keys cannot be changed. The server keeps the saved keys. */
|
|
2222
|
+
editableKeys(condition = true) { this._editableKeys = condition; return this; }
|
|
2223
|
+
/** `false`: values cannot be changed. */
|
|
2224
|
+
editableValues(condition = true) { this._editableValues = condition; return this; }
|
|
2225
|
+
keyPlaceholder(text) { this._keyPlaceholder = text; return this; }
|
|
2226
|
+
valuePlaceholder(text) { this._valuePlaceholder = text; return this; }
|
|
2227
|
+
addActionLabel(label) { this._addLabel = label; return this; }
|
|
2228
|
+
deleteActionLabel(label) { this._deleteLabel = label; return this; }
|
|
1093
2229
|
// In the form, the state is a list of `{ key, value }` pairs (what the browser component expects, and it keeps order);
|
|
1094
2230
|
// it becomes an object again when saved.
|
|
1095
2231
|
getDefaultState() { return KeyValue.toPairs(this._default); }
|
|
@@ -1105,10 +2241,29 @@ export class KeyValue extends Field {
|
|
|
1105
2241
|
}
|
|
1106
2242
|
return object && typeof object === 'object' && !Array.isArray(object) ? Object.entries(object).map(([key, v]) => ({ key, value: v })) : [];
|
|
1107
2243
|
}
|
|
1108
|
-
|
|
2244
|
+
_originalValue = null;
|
|
2245
|
+
hydrateState(value) { this._originalValue = value; return KeyValue.toPairs(value); }
|
|
1109
2246
|
/** Flat object of strings: empty keys, nested objects and dangerous keys are dropped; 200 pairs at most. */
|
|
1110
2247
|
dehydrateState(state) {
|
|
1111
2248
|
const out = {};
|
|
2249
|
+
// Restrictions set on the field are enforced on the server: a forged state cannot add, delete or edit what the UI forbids.
|
|
2250
|
+
// The saved value: the one hydrated by this instance, else the record's (fields are rebuilt on every request), else the default.
|
|
2251
|
+
const record = this.context().record;
|
|
2252
|
+
const saved = KeyValue.toPairs(this._originalValue ?? (record && typeof record === 'object' ? record[this._name] : null) ?? this._default);
|
|
2253
|
+
if (!this._addable || !this._deletable || !this._editableKeys || !this._editableValues) {
|
|
2254
|
+
const received = Array.isArray(state) ? state.filter((pair) => pair && typeof pair === 'object' && typeof pair.key === 'string') : [];
|
|
2255
|
+
const byKey = new Map(received.map((pair) => [String(pair.key).trim(), pair]));
|
|
2256
|
+
let rows = this._editableKeys ? received.map((pair) => ({ key: String(pair.key), value: pair.value })) : saved.map((pair, index) => ({ key: pair.key, value: received[index]?.value ?? pair.value }));
|
|
2257
|
+
if (!this._editableValues)
|
|
2258
|
+
rows = rows.map((row) => ({ ...row, value: saved.find((pair) => pair.key === row.key)?.value ?? null }));
|
|
2259
|
+
if (!this._addable)
|
|
2260
|
+
rows = rows.filter((row) => saved.some((pair) => pair.key === row.key));
|
|
2261
|
+
if (!this._deletable)
|
|
2262
|
+
for (const pair of saved)
|
|
2263
|
+
if (!rows.some((row) => row.key === pair.key))
|
|
2264
|
+
rows.push({ key: pair.key, value: byKey.get(pair.key)?.value ?? pair.value });
|
|
2265
|
+
state = rows;
|
|
2266
|
+
}
|
|
1112
2267
|
if (!Array.isArray(state))
|
|
1113
2268
|
return out;
|
|
1114
2269
|
for (const pair of state.slice(0, 200)) {
|
|
@@ -1134,7 +2289,7 @@ export class KeyValue extends Field {
|
|
|
1134
2289
|
return this.wrap(`<div aria-labelledby="${e(id)}-label" role="group" class="fi-fo-key-value fi-input-wrp"><div class="fi-input-wrp-content-ctn"><div x-load x-load-src="${e(asset('key-value'))}" x-id="['key-value-row']" x-data="keyValueFormComponent({
|
|
1135
2290
|
state: $wire.$entangle(${e(js(this.getStatePath()))}, false),
|
|
1136
2291
|
reordering: ${e(js(reordering))},
|
|
1137
|
-
})" wire:ignore wire:key="${e(key)}" class="fi-fo-key-value-table-ctn"><table aria-labelledby="${e(id)}-label" id="${e(id)}" class="fi-fo-key-value-table"><thead><tr>${this._reorderable ? `<th scope="col" x-show="rows.length" class="fi-has-action"><span class="fi-sr-only">${e(tr('columns.reorder.label'))}</span></th>` : ''}<th scope="col">${e(keyLabel)}</th><th scope="col">${e(valueLabel)}</th
|
|
2292
|
+
})" wire:ignore wire:key="${e(key)}" class="fi-fo-key-value-table-ctn"><table aria-labelledby="${e(id)}-label" id="${e(id)}" class="fi-fo-key-value-table"><thead><tr>${this._reorderable ? `<th scope="col" x-show="rows.length" class="fi-has-action"><span class="fi-sr-only">${e(tr('columns.reorder.label'))}</span></th>` : ''}<th scope="col">${e(keyLabel)}</th><th scope="col">${e(valueLabel)}</th>${this._deletable ? `<th scope="col" x-show="rows.length" class="fi-has-action"><span class="fi-sr-only">${e(tr('columns.actions.label'))}</span></th>` : ''}</tr></thead><tbody${this._reorderable ? ' x-on:end.stop="reorderRows($event)" x-sortable data-sortable-animation-duration="300"' : ''}><template x-bind:key="row.id" x-for="(row, index) in rows"><tr${this._reorderable ? ' x-bind:x-sortable-item="row.id"' : ''}>${reorderCell}<td><input aria-label="${e(keyLabel)}" placeholder="${e(this._keyPlaceholder ?? '')}" ${this._editableKeys ? '' : 'disabled '}type="text" x-model="row.key" x-on:input.debounce.500ms="updateState" class="fi-input" /></td><td><input aria-label="${e(valueLabel)}" placeholder="${e(this._valuePlaceholder ?? '')}" ${this._editableValues ? '' : 'disabled '}type="text" x-model="row.value" x-on:input.debounce.500ms="updateState" class="fi-input" /></td>${this._deletable ? `<td class="fi-has-action"><div x-on:click="deleteRow(index)"><button title="${e(this._deleteLabel ?? tr('actions.delete.label'))}" aria-label="${e(this._deleteLabel ?? tr('actions.delete.label'))}" ${iconButton} class="${dangerClasses}">${icon('m-trash')}</button></div></td>` : ''}</tr></template></tbody></table><span x-ref="reorderStatus" class="fi-sr-only" role="status" aria-live="polite" aria-atomic="true"></span>${this._addable ? `<div x-on:click="addRow" class="fi-fo-key-value-add-action-ctn"><button ${iconButton} class="fi-ac-link-action fi-link fi-size-md"><span class="fi-link-label">${e(this._addLabel ?? tr('actions.add.label'))}</span></button></div>` : ''}</div></div></div>`, { labelAsDiv: true, wrapperClass: 'fi-fo-key-value-wrp' });
|
|
1138
2293
|
}
|
|
1139
2294
|
}
|
|
1140
2295
|
/** Developer-written JavaScript expression (e.g. a Slider tooltip formatter), inserted as is: never put user data in it. */
|
|
@@ -1153,10 +2308,10 @@ function accessibilityOf(field, options = {}) {
|
|
|
1153
2308
|
}
|
|
1154
2309
|
/** Second `$entangle()` argument: true only for a plain `live()` field (no `onBlur`, no `debounce`). */
|
|
1155
2310
|
const entangleLive = (live) => (live !== null && !live.onBlur && live.debounce === null ? 'true' : 'false');
|
|
1156
|
-
const MARKDOWN_TOOLBAR = [['bold', 'italic', 'strike', 'link'], ['heading'], ['blockquote', 'codeBlock', 'bulletList', 'orderedList'], ['table'], ['undo', 'redo']];
|
|
2311
|
+
const MARKDOWN_TOOLBAR = [['bold', 'italic', 'strike', 'link'], ['heading'], ['blockquote', 'codeBlock', 'bulletList', 'orderedList'], ['table', 'attachFiles'], ['undo', 'redo']];
|
|
1157
2312
|
/**
|
|
1158
2313
|
* Markdown editor (EasyMDE). The state is raw Markdown text, to sanitize before any HTML display.
|
|
1159
|
-
*
|
|
2314
|
+
* Files can be attached (images, stored on upload) when a storage is set (`fileAttachmentsStorage()` or `FileUpload.storage`); when read-only (`disabled()`),
|
|
1160
2315
|
* the text is shown escaped, without Markdown conversion.
|
|
1161
2316
|
*/
|
|
1162
2317
|
export class MarkdownEditor extends Field {
|
|
@@ -1191,9 +2346,31 @@ export class MarkdownEditor extends Field {
|
|
|
1191
2346
|
async getValidationRules() {
|
|
1192
2347
|
return [...(await super.getValidationRules()), { name: 'string', passes: (v) => typeof v === 'string', message: (a) => `The ${a} field must be a string.` }];
|
|
1193
2348
|
}
|
|
2349
|
+
_attachmentsStorage = null;
|
|
2350
|
+
_attachmentsDirectory = null;
|
|
2351
|
+
_attachmentTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
2352
|
+
_attachmentMaxSize = 12288;
|
|
2353
|
+
/** Where attached files are stored (`FileUpload.storage` by default); without storage, the "Attach files" button is hidden. */
|
|
2354
|
+
fileAttachmentsStorage(storage) { this._attachmentsStorage = storage; return this; }
|
|
2355
|
+
fileAttachmentsDirectory(directory) { this._attachmentsDirectory = directory; return this; }
|
|
2356
|
+
/** Accepted types of attached files (images), checked on the file's content. */
|
|
2357
|
+
fileAttachmentsAcceptedFileTypes(types) { this._attachmentTypes = types.filter((type) => type.startsWith('image/') && type !== 'image/svg+xml'); return this; }
|
|
2358
|
+
/** Maximum size of attached files, in kilobytes. */
|
|
2359
|
+
fileAttachmentsMaxSize(kilobytes) { this._attachmentMaxSize = kilobytes; return this; }
|
|
2360
|
+
canAttachFiles() { return this.hasToolbarButton('attachFiles') && (this._attachmentsStorage ?? FileUpload.storage) !== null; }
|
|
2361
|
+
visibleToolbar() {
|
|
2362
|
+
if (this.canAttachFiles())
|
|
2363
|
+
return this._toolbarButtons;
|
|
2364
|
+
return this._toolbarButtons.map((group) => (Array.isArray(group) ? group.filter((name) => name !== 'attachFiles') : group)).filter((group) => (Array.isArray(group) ? group.length > 0 : group !== 'attachFiles'));
|
|
2365
|
+
}
|
|
1194
2366
|
getExposedMethods() { return ['getUploadedFileAttachmentTemporaryUrl', 'saveUploadedFileAttachmentAndGetUrl']; }
|
|
1195
2367
|
getUploadedFileAttachmentTemporaryUrl() { return null; }
|
|
1196
|
-
|
|
2368
|
+
/** Stores the file just uploaded to `componentFileAttachments.<state path>` and returns its URL. */
|
|
2369
|
+
async saveUploadedFileAttachmentAndGetUrl() {
|
|
2370
|
+
if (!this.canAttachFiles() || (await this.isDisabled()))
|
|
2371
|
+
return null;
|
|
2372
|
+
return storeEditorAttachment(this, null, { storage: this._attachmentsStorage ?? FileUpload.storage, directory: this._attachmentsDirectory, types: this._attachmentTypes, maxSize: this._attachmentMaxSize });
|
|
2373
|
+
}
|
|
1197
2374
|
async toHtml() {
|
|
1198
2375
|
const id = this.getId();
|
|
1199
2376
|
const accessibility = accessibilityOf(this);
|
|
@@ -1214,7 +2391,7 @@ export class MarkdownEditor extends Field {
|
|
|
1214
2391
|
tools: Object.fromEntries(['attach_files', 'blockquote', 'bold', 'bullet_list', 'code_block', 'heading', 'italic', 'link', 'ordered_list', 'redo', 'strike', 'table', 'undo'].map((tool) => [tool, tr(`tools.${tool}`)])),
|
|
1215
2392
|
};
|
|
1216
2393
|
const config = `markdownEditorFormComponent({
|
|
1217
|
-
canAttachFiles: false,
|
|
2394
|
+
canAttachFiles: ${this.canAttachFiles() ? 'true' : 'false'},
|
|
1218
2395
|
isLiveDebounced: ${this._live?.debounce !== null && this._live?.debounce !== undefined ? 'true' : 'false'},
|
|
1219
2396
|
isLiveOnBlur: ${this._live?.onBlur ? 'true' : 'false'},
|
|
1220
2397
|
label: ${js(await this.getLabel())},
|
|
@@ -1223,10 +2400,26 @@ export class MarkdownEditor extends Field {
|
|
|
1223
2400
|
minHeight: ${js(this._minHeight)},
|
|
1224
2401
|
placeholder: ${js(this._placeholder)},
|
|
1225
2402
|
state: $wire.$entangle(${js(this.getStatePath())}, ${entangleLive(this._live)}),
|
|
1226
|
-
toolbarButtons: ${js(this.
|
|
2403
|
+
toolbarButtons: ${js(this.visibleToolbar())},
|
|
1227
2404
|
translations: ${js(translations)},
|
|
1228
2405
|
uploadFileAttachmentUsing: async (file, onSuccess, onError) => {
|
|
2406
|
+
const acceptedTypes = ${js(this._attachmentTypes)}
|
|
2407
|
+
if (acceptedTypes && ! acceptedTypes.includes(file.type)) {
|
|
2408
|
+
return onError(${js(tr('file_attachments_accepted_file_types_message').replace(':values', this._attachmentTypes.join(', ')))})
|
|
2409
|
+
}
|
|
2410
|
+
const maxSize = ${js(this._attachmentMaxSize)}
|
|
2411
|
+
if (maxSize && file.size > +maxSize * 1024) {
|
|
2412
|
+
return onError(${js(tr('file_attachments_max_size_message').replace(':max', String(this._attachmentMaxSize)))})
|
|
2413
|
+
}
|
|
2414
|
+
$wire.upload(${js(`componentFileAttachments.${this.getStatePath()}`)}, file, () => {
|
|
2415
|
+
$saveUploadedFileAttachmentAndGetUrl()
|
|
2416
|
+
.then((url) => {
|
|
2417
|
+
if (! url) {
|
|
1229
2418
|
return onError()
|
|
2419
|
+
}
|
|
2420
|
+
onSuccess(url)
|
|
2421
|
+
})
|
|
2422
|
+
})
|
|
1230
2423
|
},
|
|
1231
2424
|
})`;
|
|
1232
2425
|
const wrapper = new Attributes().class(['fi-fo-markdown-editor', 'fi-input-wrp', { 'fi-invalid': this.hasError() }]);
|
|
@@ -1508,6 +2701,26 @@ export function fillSchemaFromRecord(schema, target, record) {
|
|
|
1508
2701
|
writePath(target, segments, c.hydrateState(readPath(record, segments) ?? c.getDefaultState()));
|
|
1509
2702
|
}
|
|
1510
2703
|
}
|
|
2704
|
+
/** Fills the fields that load their state from the record's relationships (`loadStateFromRelationshipsUsing()`, `Select::relationship()`…). */
|
|
2705
|
+
export async function loadSchemaRelationships(schema, target, record) {
|
|
2706
|
+
if (!record)
|
|
2707
|
+
return;
|
|
2708
|
+
for (const c of schema.getFlatComponents()) {
|
|
2709
|
+
if (!(c instanceof Field))
|
|
2710
|
+
continue;
|
|
2711
|
+
const loaded = await c.loadStateFromRelationships(record);
|
|
2712
|
+
if (loaded)
|
|
2713
|
+
writePath(target, relativePath(schema, c), c.hydrateState(loaded.state));
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
/** Saves the visible fields that write the record's relationships, once the record is created or updated. */
|
|
2717
|
+
export async function saveSchemaRelationships(schema, record) {
|
|
2718
|
+
if (!record)
|
|
2719
|
+
return;
|
|
2720
|
+
for (const c of await schema.getVisibleFlatComponents())
|
|
2721
|
+
if (c instanceof Field && (await c.isDehydrated() || c._dehydrated === false) && (await c.savesRelationships()))
|
|
2722
|
+
await c.saveRelationships(record);
|
|
2723
|
+
}
|
|
1511
2724
|
/** Validates every visible field of a schema; returns errors by state path. */
|
|
1512
2725
|
export async function validateSchema(schema) {
|
|
1513
2726
|
const errors = {};
|
|
@@ -1524,11 +2737,21 @@ export async function validateSchema(schema) {
|
|
|
1524
2737
|
export async function getSchemaState(schema) {
|
|
1525
2738
|
const data = {};
|
|
1526
2739
|
const fields = [];
|
|
2740
|
+
const skipped = [];
|
|
1527
2741
|
for (const c of await schema.getVisibleFlatComponents()) {
|
|
1528
2742
|
if (!(c instanceof Field) || !(await c.isDehydrated()))
|
|
1529
2743
|
continue;
|
|
2744
|
+
// Relationship fields (and the fields of their items) save themselves after the record (see saveSchemaRelationships), unless forced with `dehydrated()`.
|
|
2745
|
+
if (skipped.some((path) => c.getStatePath().startsWith(`${path}.`)))
|
|
2746
|
+
continue;
|
|
2747
|
+
if (c._dehydrated !== true && (await c.savesRelationships())) {
|
|
2748
|
+
skipped.push(c.getStatePath());
|
|
2749
|
+
continue;
|
|
2750
|
+
}
|
|
1530
2751
|
const state = await c.dehydrateState(c.context().state);
|
|
1531
2752
|
writePath(data, relativePath(schema, c), state === '' || state === undefined ? null : state);
|
|
2753
|
+
for (const [name, value] of Object.entries(c.getExtraDehydratedState?.() ?? {}))
|
|
2754
|
+
writePath(data, [...relativePath(schema, c).slice(0, -1), name], value);
|
|
1532
2755
|
fields.push(c);
|
|
1533
2756
|
}
|
|
1534
2757
|
// Deepest first: a container sees its children already shaped.
|
|
@@ -1716,6 +2939,60 @@ export class Wizard extends Component {
|
|
|
1716
2939
|
}
|
|
1717
2940
|
}
|
|
1718
2941
|
/** Local disk storage, served under a public URL. File names are random; the extension comes from the detected type. */
|
|
2942
|
+
/**
|
|
2943
|
+
* A file name chosen by the application or the user, made safe for storage: no directory part, only letters, digits, `_` and
|
|
2944
|
+
* `-`, and always the extension detected from the file's content (an uploaded `shell.php` declared as an image is stored
|
|
2945
|
+
* as `shell.png`).
|
|
2946
|
+
*/
|
|
2947
|
+
export function safeStorageName(name, extension) {
|
|
2948
|
+
const base = (name.split(/[\\/]/).pop() ?? '').replace(/\.[^.]*$/, '').normalize('NFKD').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 180) || randomUUID();
|
|
2949
|
+
return `${base}.${extension.replace(/[^A-Za-z0-9]/g, '') || 'bin'}`;
|
|
2950
|
+
}
|
|
2951
|
+
/** Width and height of a PNG, GIF, JPEG or WebP image, read from its header; `null` for other files. */
|
|
2952
|
+
export function readImageSize(path) {
|
|
2953
|
+
let buffer;
|
|
2954
|
+
try {
|
|
2955
|
+
const fd = openSync(path, 'r');
|
|
2956
|
+
buffer = Buffer.alloc(1024 * 1024);
|
|
2957
|
+
const read = readSync(fd, buffer, 0, buffer.length, 0);
|
|
2958
|
+
closeSync(fd);
|
|
2959
|
+
buffer = buffer.subarray(0, read);
|
|
2960
|
+
}
|
|
2961
|
+
catch {
|
|
2962
|
+
return null;
|
|
2963
|
+
}
|
|
2964
|
+
if (buffer.length >= 24 && buffer.readUInt32BE(0) === 0x89504e47)
|
|
2965
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
2966
|
+
if (buffer.length >= 10 && buffer.toString('ascii', 0, 3) === 'GIF')
|
|
2967
|
+
return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
|
|
2968
|
+
if (buffer.length >= 30 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') {
|
|
2969
|
+
const chunk = buffer.toString('ascii', 12, 16);
|
|
2970
|
+
if (chunk === 'VP8X')
|
|
2971
|
+
return { width: 1 + buffer.readUIntLE(24, 3), height: 1 + buffer.readUIntLE(27, 3) };
|
|
2972
|
+
if (chunk === 'VP8L') {
|
|
2973
|
+
const bits = buffer.readUInt32LE(21);
|
|
2974
|
+
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };
|
|
2975
|
+
}
|
|
2976
|
+
if (chunk === 'VP8 ')
|
|
2977
|
+
return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff };
|
|
2978
|
+
return null;
|
|
2979
|
+
}
|
|
2980
|
+
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
|
|
2981
|
+
let offset = 2;
|
|
2982
|
+
while (offset + 9 < buffer.length) {
|
|
2983
|
+
if (buffer[offset] !== 0xff) {
|
|
2984
|
+
offset++;
|
|
2985
|
+
continue;
|
|
2986
|
+
}
|
|
2987
|
+
const marker = buffer[offset + 1];
|
|
2988
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker))
|
|
2989
|
+
return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5) };
|
|
2990
|
+
offset += 2 + buffer.readUInt16BE(offset + 2);
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
return null;
|
|
2994
|
+
}
|
|
2995
|
+
const ratioOf = (ratio) => { const [width, height] = ratio.split(/[:/]/).map(Number); return width > 0 && height > 0 ? width / height : null; };
|
|
1719
2996
|
export class LocalFileStorage {
|
|
1720
2997
|
root;
|
|
1721
2998
|
baseUrl;
|
|
@@ -1725,13 +3002,24 @@ export class LocalFileStorage {
|
|
|
1725
3002
|
return null;
|
|
1726
3003
|
return nodePath.join(this.root, ...key.split('/'));
|
|
1727
3004
|
}
|
|
1728
|
-
async store(file, directory) {
|
|
1729
|
-
|
|
3005
|
+
async store(file, directory, name = null) {
|
|
3006
|
+
let key = [directory, name === null ? `${randomUUID()}.${file.extension}` : safeStorageName(name, file.extension)].filter(Boolean).join('/');
|
|
3007
|
+
// A chosen name never overwrites another file: a short random suffix is added.
|
|
3008
|
+
if (name !== null && this.resolve(key) && existsSync(this.resolve(key)))
|
|
3009
|
+
key = key.replace(/(\.[A-Za-z0-9]+)$/, `-${randomUUID().slice(0, 8)}$1`);
|
|
1730
3010
|
const target = this.resolve(key);
|
|
1731
3011
|
if (!target)
|
|
1732
3012
|
throw new Error('Invalid upload directory.');
|
|
1733
3013
|
mkdirSync(nodePath.dirname(target), { recursive: true });
|
|
1734
|
-
|
|
3014
|
+
try {
|
|
3015
|
+
copyFileSync(file.path, target, fsConstants.COPYFILE_EXCL);
|
|
3016
|
+
}
|
|
3017
|
+
catch (error) {
|
|
3018
|
+
// Another save took this name meanwhile: random suffix.
|
|
3019
|
+
if (error?.code !== 'EEXIST' || name === null)
|
|
3020
|
+
throw error;
|
|
3021
|
+
return this.store(file, directory, `${safeStorageName(name, file.extension).replace(/\.[^.]*$/, '')}-${randomUUID().slice(0, 8)}`);
|
|
3022
|
+
}
|
|
1735
3023
|
writeFileSync(`${target}.meta.json`, JSON.stringify({ name: file.name, type: file.type }));
|
|
1736
3024
|
return key;
|
|
1737
3025
|
}
|
|
@@ -1764,7 +3052,107 @@ export class FileUpload extends Field {
|
|
|
1764
3052
|
_storeFiles = true;
|
|
1765
3053
|
_storage = null;
|
|
1766
3054
|
_getUploadedFileUsing = null;
|
|
3055
|
+
_minSize = null;
|
|
3056
|
+
_ui = {};
|
|
3057
|
+
_avatar = false;
|
|
3058
|
+
_imageEditor = false;
|
|
3059
|
+
_circleCropper = false;
|
|
3060
|
+
_editorAspectRatios = [];
|
|
3061
|
+
_cropAspectRatio = null;
|
|
3062
|
+
_imageAspectRatios = null;
|
|
3063
|
+
_dimensions = null;
|
|
3064
|
+
_deletable = true;
|
|
3065
|
+
_preserveFilenames = false;
|
|
3066
|
+
_fileNameUsing = null;
|
|
3067
|
+
_fileNamesField = null;
|
|
3068
|
+
_storedNames = {};
|
|
3069
|
+
_alignment = null;
|
|
3070
|
+
_placeholder = null;
|
|
1767
3071
|
static make(name) { return configured(new FileUpload(name)); }
|
|
3072
|
+
/** Minimum file size, in kilobytes. */
|
|
3073
|
+
minSize(kilobytes) { this._minSize = kilobytes; return this; }
|
|
3074
|
+
/** Download button on each saved file. */
|
|
3075
|
+
downloadable(condition = true) { this._ui.isDownloadable = condition; return this; }
|
|
3076
|
+
/** Button opening each saved file in a new tab. */
|
|
3077
|
+
openable(condition = true) { this._ui.isOpenable = condition; return this; }
|
|
3078
|
+
/** `false`: no preview of images. */
|
|
3079
|
+
previewable(condition = true) { this._ui.isPreviewable = condition; return this; }
|
|
3080
|
+
/** `false`: files cannot be pasted from the clipboard. */
|
|
3081
|
+
pasteable(condition = true) { this._ui.isPasteable = condition; return this; }
|
|
3082
|
+
/** New files are added after the existing ones (instead of before). */
|
|
3083
|
+
appendFiles(condition = true) { this._ui.shouldAppendFiles = condition; return this; }
|
|
3084
|
+
/** `false`: saved files cannot be removed (the remove button is hidden, and removing is refused on the server). */
|
|
3085
|
+
deletable(condition = true) { this._deletable = condition; return this; }
|
|
3086
|
+
/** `'grid'` (with `multiple()`), `'compact'`, `'integrated'` or `'circle'`. */
|
|
3087
|
+
panelLayout(layout) { this._ui.panelLayout = layout; return this; }
|
|
3088
|
+
panelAspectRatio(ratio) { this._ui.panelAspectRatio = ratio; return this; }
|
|
3089
|
+
itemPanelAspectRatio(ratio) { this._ui.itemPanelAspectRatio = ratio; return this; }
|
|
3090
|
+
imagePreviewHeight(height) { this._ui.imagePreviewHeight = height; return this; }
|
|
3091
|
+
loadingIndicatorPosition(position) { this._ui.loadingIndicatorPosition = position; return this; }
|
|
3092
|
+
removeUploadedFileButtonPosition(position) { this._ui.removeUploadedFileButtonPosition = position; return this; }
|
|
3093
|
+
uploadButtonPosition(position) { this._ui.uploadButtonPosition = position; return this; }
|
|
3094
|
+
uploadProgressIndicatorPosition(position) { this._ui.uploadProgressIndicatorPosition = position; return this; }
|
|
3095
|
+
/** Message shown while a file uploads. */
|
|
3096
|
+
uploadingMessage(message) { this._ui.uploadingMessage = message; return this; }
|
|
3097
|
+
/** Number of files uploaded at the same time. */
|
|
3098
|
+
maxParallelUploads(count) { this._ui.maxParallelUploads = count; return this; }
|
|
3099
|
+
/** Extension → MIME type map for files the browser cannot type (`{ zip: 'application/zip' }`). */
|
|
3100
|
+
mimeTypeMap(map) { this._ui.mimeTypeMap = map; return this; }
|
|
3101
|
+
/** `false`: images keep their EXIF orientation. */
|
|
3102
|
+
orientImagesFromExif(condition = true) { this._ui.shouldOrientImageFromExif = condition; return this; }
|
|
3103
|
+
alignment(alignment) { this._alignment = alignment; return this; }
|
|
3104
|
+
/** Text of the drop area. */
|
|
3105
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
3106
|
+
/** Round avatar: one image, cropped to a square and shown as a circle. */
|
|
3107
|
+
avatar() { this._avatar = true; this.image(); this._ui.panelLayout ??= 'compact circle'; this._ui.imagePreviewHeight ??= '250'; this._cropAspectRatio ??= '1:1'; this._ui.automaticallyResizeImagesHeight ??= '500'; this._ui.automaticallyResizeImagesWidth ??= '500'; this._ui.loadingIndicatorPosition ??= 'center bottom'; this._ui.removeUploadedFileButtonPosition ??= 'center bottom'; this._ui.uploadButtonPosition ??= 'center bottom'; this._ui.uploadProgressIndicatorPosition ??= 'center bottom'; this._alignment ??= 'center'; return this; }
|
|
3108
|
+
/** Image editor (crop, rotate, flip, zoom) opened from each image, powered by Cropper.js in the browser. */
|
|
3109
|
+
imageEditor(condition = true) { this._imageEditor = condition; return this; }
|
|
3110
|
+
/** Aspect ratios offered by the image editor (`['16:9', '4:3', '1:1']`; `null` for a free crop). */
|
|
3111
|
+
imageEditorAspectRatioOptions(ratios) { this._editorAspectRatios = ratios; return this; }
|
|
3112
|
+
imageEditorAspectRatios(ratios) { return this.imageEditorAspectRatioOptions(ratios); }
|
|
3113
|
+
/** Cropper.js view mode (1, 2 or 3). */
|
|
3114
|
+
imageEditorMode(mode) { this._ui.imageEditorMode = mode; return this; }
|
|
3115
|
+
imageEditorEmptyFillColor(color) { this._ui.imageEditorEmptyFillColor = color; return this; }
|
|
3116
|
+
imageEditorViewportWidth(width) { this._ui.imageEditorViewportWidth = width === null ? null : String(width); return this; }
|
|
3117
|
+
imageEditorViewportHeight(height) { this._ui.imageEditorViewportHeight = height === null ? null : String(height); return this; }
|
|
3118
|
+
_autoOpenEditor = true;
|
|
3119
|
+
/** With `imageEditor()` and a single aspect ratio, opens the editor when an uploaded image has another ratio (on by default). */
|
|
3120
|
+
automaticallyOpenImageEditorForAspectRatio(condition = true) { this._autoOpenEditor = condition; return this; }
|
|
3121
|
+
autoOpenEditorRatio() {
|
|
3122
|
+
if (!this._imageEditor || !this._autoOpenEditor)
|
|
3123
|
+
return null;
|
|
3124
|
+
const ratio = this._cropAspectRatio ?? (this._imageAspectRatios?.length === 1 ? this._imageAspectRatios[0] : null);
|
|
3125
|
+
return ratio === null ? null : ratioOf(ratio);
|
|
3126
|
+
}
|
|
3127
|
+
/** Circle crop in the image editor (avatars). */
|
|
3128
|
+
circleCropper(condition = true) { this._circleCropper = condition; return this; }
|
|
3129
|
+
/** Crops images to this ratio in the browser before upload (`'16:9'`), without opening the editor. */
|
|
3130
|
+
automaticallyCropImagesToAspectRatio(ratio) { this._cropAspectRatio = ratio; return this; }
|
|
3131
|
+
imageCropAspectRatio(ratio) { return this.automaticallyCropImagesToAspectRatio(ratio); }
|
|
3132
|
+
/** Resizes images in the browser before upload (`'cover'`, `'contain'` or `'force'`). */
|
|
3133
|
+
automaticallyResizeImagesMode(mode) { this._ui.automaticallyResizeImagesMode = mode; return this; }
|
|
3134
|
+
automaticallyResizeImagesToWidth(width) { this._ui.automaticallyResizeImagesWidth = width === null ? null : String(width); return this; }
|
|
3135
|
+
automaticallyResizeImagesToHeight(height) { this._ui.automaticallyResizeImagesHeight = height === null ? null : String(height); return this; }
|
|
3136
|
+
automaticallyUpscaleImagesWhenResizing(condition = true) { this._ui.shouldAutomaticallyUpscaleImagesWhenResizing = condition; return this; }
|
|
3137
|
+
imageResizeMode(mode) { return this.automaticallyResizeImagesMode(mode); }
|
|
3138
|
+
imageResizeTargetWidth(width) { return this.automaticallyResizeImagesToWidth(width); }
|
|
3139
|
+
imageResizeTargetHeight(height) { return this.automaticallyResizeImagesToHeight(height); }
|
|
3140
|
+
/** Accepted aspect ratio(s) of uploaded images (`'16:9'` or a list), checked on the server. */
|
|
3141
|
+
imageAspectRatio(ratio) { this._imageAspectRatios = ratio === null ? null : [ratio].flat(); return this; }
|
|
3142
|
+
/** Width, height and ratio bounds of uploaded images (`{ minWidth: 800, maxHeight: 1080 }`), checked on the server. */
|
|
3143
|
+
imageDimensions(dimensions) { this._dimensions = dimensions; return this; }
|
|
3144
|
+
/** Stores files under their original (sanitized) names. See the security notes in the docs. */
|
|
3145
|
+
preserveFilenames(condition = true) { this._preserveFilenames = condition; return this; }
|
|
3146
|
+
/** Name of the stored file, from the temporary file; sanitized, and its extension is the detected one. */
|
|
3147
|
+
getUploadedFileNameForStorageUsing(callback) { this._fileNameUsing = callback; return this; }
|
|
3148
|
+
/** Saves the original file names (keyed by stored file) in another attribute of the record. */
|
|
3149
|
+
storeFileNamesIn(field) { this._fileNamesField = field; return this; }
|
|
3150
|
+
/** Extra saved values, next to the field (the original file names of `storeFileNamesIn()`). */
|
|
3151
|
+
getExtraDehydratedState() {
|
|
3152
|
+
if (this._fileNamesField === null)
|
|
3153
|
+
return {};
|
|
3154
|
+
return { [this._fileNamesField]: this._multiple ? this._storedNames : (Object.values(this._storedNames)[0] ?? null) };
|
|
3155
|
+
}
|
|
1768
3156
|
multiple(condition = true) { this._multiple = condition; return this; }
|
|
1769
3157
|
maxFiles(count) { this._maxFiles = count; return this; }
|
|
1770
3158
|
/** Maximum file size, in kilobytes. */
|
|
@@ -1799,6 +3187,23 @@ export class FileUpload extends Field {
|
|
|
1799
3187
|
return new Set(Object.values(this.hydrateState(record?.[this.getName()])));
|
|
1800
3188
|
}
|
|
1801
3189
|
isTemporary(value) { return value.startsWith('livewire-file:'); }
|
|
3190
|
+
/** Original names saved by `storeFileNamesIn()`, by stored key. */
|
|
3191
|
+
savedFileNames() {
|
|
3192
|
+
if (this._fileNamesField === null)
|
|
3193
|
+
return {};
|
|
3194
|
+
const record = this.container.getRecord();
|
|
3195
|
+
let names = record?.[this._fileNamesField];
|
|
3196
|
+
if (typeof names === 'string') {
|
|
3197
|
+
try {
|
|
3198
|
+
names = JSON.parse(names);
|
|
3199
|
+
}
|
|
3200
|
+
catch {
|
|
3201
|
+
const key = [...this.originalKeys()][0];
|
|
3202
|
+
return key ? { [key]: names } : {};
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
return names && typeof names === 'object' && !Array.isArray(names) ? Object.fromEntries(Object.entries(names).filter(([, name]) => typeof name === 'string')) : {};
|
|
3206
|
+
}
|
|
1802
3207
|
getExposedMethods() { return ['deleteUploadedFile', 'removeUploadedFile', 'reorderUploadedFiles', 'getUploadedFiles']; }
|
|
1803
3208
|
async getUploadedFiles() {
|
|
1804
3209
|
const original = this.originalKeys();
|
|
@@ -1809,6 +3214,9 @@ export class FileUpload extends Field {
|
|
|
1809
3214
|
continue;
|
|
1810
3215
|
}
|
|
1811
3216
|
const info = this._getUploadedFileUsing ? await this._getUploadedFileUsing(value, this.context()) : await this.getStorage()?.info(value);
|
|
3217
|
+
const savedName = this.savedFileNames()[value];
|
|
3218
|
+
if (info && savedName)
|
|
3219
|
+
info.name = savedName;
|
|
1812
3220
|
// Only local or http(s) URLs are sent to the browser.
|
|
1813
3221
|
files[fileKey] = info && /^(\/(?!\/)|https?:\/\/)/.test(info.url) ? info : null;
|
|
1814
3222
|
}
|
|
@@ -1818,6 +3226,9 @@ export class FileUpload extends Field {
|
|
|
1818
3226
|
/** Removes a file from the field. A stored file is only deleted by the application when the form is saved. */
|
|
1819
3227
|
removeUploadedFile(fileKey) {
|
|
1820
3228
|
const removed = this.entries().find(([key]) => key === fileKey);
|
|
3229
|
+
// `deletable(false)`: a saved file stays (a file just uploaded can still be removed).
|
|
3230
|
+
if (removed && !this._deletable && !this.isTemporary(removed[1]))
|
|
3231
|
+
return;
|
|
1821
3232
|
if (removed && this.isTemporary(removed[1]))
|
|
1822
3233
|
this.uploads?.remove(removed[1].slice('livewire-file:'.length));
|
|
1823
3234
|
this.writeState(this.entries().filter(([key]) => key !== fileKey));
|
|
@@ -1846,6 +3257,23 @@ export class FileUpload extends Field {
|
|
|
1846
3257
|
rules.push({ name: 'mimetypes', passes: () => temporary().every((file) => file !== null && accepts(file.type)), message: (a) => `The ${a} field must be a file of type: ${this._acceptedFileTypes.join(', ')}.` });
|
|
1847
3258
|
if (this._maxSize !== null)
|
|
1848
3259
|
rules.push({ name: 'max', passes: () => temporary().every((file) => file !== null && file.size <= this._maxSize * 1024), message: (a) => `The ${a} field must not be greater than ${this._maxSize} kilobytes.` });
|
|
3260
|
+
if (this._minSize !== null)
|
|
3261
|
+
rules.push({ name: 'min', passes: () => temporary().every((file) => file !== null && file.size >= this._minSize * 1024), message: (a) => `The ${a} field must be at least ${this._minSize} kilobytes.` });
|
|
3262
|
+
// Image rules only apply to new uploads, as in Laravel; a file whose size cannot be read fails them.
|
|
3263
|
+
if (this._dimensions) {
|
|
3264
|
+
const d = this._dimensions;
|
|
3265
|
+
const fits = (size) => size !== null
|
|
3266
|
+
&& (d.width === undefined || size.width === d.width) && (d.height === undefined || size.height === d.height)
|
|
3267
|
+
&& (d.minWidth === undefined || size.width >= d.minWidth) && (d.maxWidth === undefined || size.width <= d.maxWidth)
|
|
3268
|
+
&& (d.minHeight === undefined || size.height >= d.minHeight) && (d.maxHeight === undefined || size.height <= d.maxHeight)
|
|
3269
|
+
&& (d.ratio === undefined || Math.abs(size.width / size.height - d.ratio) < 0.01)
|
|
3270
|
+
&& (d.minRatio === undefined || size.width / size.height >= d.minRatio - 1e-9) && (d.maxRatio === undefined || size.width / size.height <= d.maxRatio + 1e-9);
|
|
3271
|
+
rules.push({ name: 'dimensions', passes: () => temporary().every((file) => file !== null && fits(readImageSize(file.path))), message: (a) => `The ${a} field has invalid image dimensions.` });
|
|
3272
|
+
}
|
|
3273
|
+
if (this._imageAspectRatios) {
|
|
3274
|
+
const ratios = this._imageAspectRatios.map(ratioOf).filter((ratio) => ratio !== null);
|
|
3275
|
+
rules.push({ name: 'dimensions', passes: () => temporary().every((file) => { const size = file && readImageSize(file.path); return !!size && size.height > 0 && ratios.some((ratio) => Math.abs(size.width / size.height - ratio) < 0.01); }), message: (a) => `The ${a} field has invalid image dimensions.` });
|
|
3276
|
+
}
|
|
1849
3277
|
return rules;
|
|
1850
3278
|
}
|
|
1851
3279
|
/** Validates the whole state, even when empty (a required field without files must fail). */
|
|
@@ -1861,6 +3289,8 @@ export class FileUpload extends Field {
|
|
|
1861
3289
|
/** On save: temporary files are stored, unrecognized existing keys are dropped. */
|
|
1862
3290
|
async dehydrateState() {
|
|
1863
3291
|
const original = this.originalKeys();
|
|
3292
|
+
const savedNames = this.savedFileNames();
|
|
3293
|
+
this._storedNames = {};
|
|
1864
3294
|
const out = [];
|
|
1865
3295
|
for (const [, value] of this.entries()) {
|
|
1866
3296
|
if (this.isTemporary(value)) {
|
|
@@ -1874,74 +3304,115 @@ export class FileUpload extends Field {
|
|
|
1874
3304
|
const storage = this.getStorage();
|
|
1875
3305
|
if (!storage)
|
|
1876
3306
|
throw new Error('FileUpload: no storage configured (FileUpload.storage).');
|
|
1877
|
-
|
|
3307
|
+
const name = this._fileNameUsing ? await this._fileNameUsing(file, this.context()) : this._preserveFilenames ? file.name : null;
|
|
3308
|
+
const stored = await storage.store(file, this._directory, name);
|
|
3309
|
+
this._storedNames[stored] = file.name;
|
|
3310
|
+
out.push(stored);
|
|
1878
3311
|
this.uploads?.remove(file.id);
|
|
1879
3312
|
}
|
|
1880
|
-
else if (original.has(value))
|
|
3313
|
+
else if (original.has(value)) {
|
|
1881
3314
|
out.push(this._storeFiles ? value : { key: value });
|
|
3315
|
+
if (savedNames[value])
|
|
3316
|
+
this._storedNames[value] = savedNames[value];
|
|
3317
|
+
}
|
|
1882
3318
|
}
|
|
3319
|
+
// `deletable(false)`: a saved file the browser dropped from the state is kept.
|
|
3320
|
+
if (!this._deletable)
|
|
3321
|
+
for (const key of original)
|
|
3322
|
+
if (!out.some((item) => item === key || (typeof item === 'object' && 'key' in item && item.key === key))) {
|
|
3323
|
+
out.push(this._storeFiles ? key : { key });
|
|
3324
|
+
if (savedNames[key])
|
|
3325
|
+
this._storedNames[key] = savedNames[key];
|
|
3326
|
+
}
|
|
1883
3327
|
return this._multiple ? out : (out[0] ?? null);
|
|
1884
3328
|
}
|
|
3329
|
+
/** Filament's image editor dialog (Cropper.js), rendered once per field. */
|
|
3330
|
+
imageEditorHtml() {
|
|
3331
|
+
const tr = (key, replace = {}) => t(`filament-forms::components.file_upload.editor.${key}`, replace);
|
|
3332
|
+
const explicit = this._imageEditor;
|
|
3333
|
+
const fields = [
|
|
3334
|
+
['x_position', 'xPositionInput', 'editor.setData({...editor.getData(true), x: +$el.value})'], ['y_position', 'yPositionInput', 'editor.setData({...editor.getData(true), y: +$el.value})'],
|
|
3335
|
+
['width', 'widthInput', 'editor.setData({...editor.getData(true), width: +$el.value})'], ['height', 'heightInput', 'editor.setData({...editor.getData(true), height: +$el.value})'],
|
|
3336
|
+
['rotation', 'rotationInput', 'editor.rotateTo(+$el.value)'],
|
|
3337
|
+
].map(([name, ref, save]) => `<label><div class="fi-input-wrp"><div class="fi-input-wrp-prefix fi-input-wrp-prefix-has-content fi-input-wrp-prefix-has-label"><span class="fi-input-wrp-label">${e(tr(`fields.${name}.label`))}</span></div><div class="fi-input-wrp-content-ctn"><input x-on:keyup.enter.prevent.stop="editor && ${e(save)}" x-on:blur="editor && ${e(save)}" x-ref="${ref}" x-on:keydown.enter.prevent type="text" class="fi-input" /></div><div class="fi-input-wrp-suffix fi-input-wrp-suffix-has-label"><span class="fi-input-wrp-label">${e(tr(`fields.${name}.unit`))}</span></div></div></label>`).join('');
|
|
3338
|
+
const groups = [
|
|
3339
|
+
[['drag_move', 'fi-o-arrows-move', "editor.setDragMode('move')"], ['drag_crop', 'fi-o-crop', "editor.setDragMode('crop')"], ['zoom_in', 'o-magnifying-glass-plus', 'editor.zoom(0.1)'], ['zoom_out', 'o-magnifying-glass-minus', 'editor.zoom(-0.1)'], ['zoom_100', 'o-arrows-pointing-out', 'editor.zoomTo(1)']],
|
|
3340
|
+
[['move_left', 'o-arrow-left-circle', 'editor.move(-10, 0)'], ['move_right', 'o-arrow-right-circle', 'editor.move(10, 0)'], ['move_up', 'o-arrow-up-circle', 'editor.move(0, -10)'], ['move_down', 'o-arrow-down-circle', 'editor.move(0, 10)']],
|
|
3341
|
+
[['rotate_left', 'o-arrow-uturn-left', 'editor.rotate(-90)'], ['rotate_right', 'o-arrow-uturn-right', 'editor.rotate(90)'], ['flip_horizontal', 'fi-o-flip-horizontal', 'editor.scaleX(-editor.getData().scaleX || -1)'], ['flip_vertical', 'fi-o-flip-vertical', 'editor.scaleY(-editor.getData().scaleY || -1)']],
|
|
3342
|
+
].map((group) => `<div class="fi-btn-group">${group.map(([name, iconName, click]) => { const label = tr(`actions.${name}.label`); return `<button aria-label="${e(label)}" type="button" x-on:click.prevent.stop="${e(click)}" x-tooltip="${e(`{ content: ${js(label)}, theme: $store.theme }`)}" class="fi-btn">${icon(iconName)}</button>`; }).join('')}</div>`).join('');
|
|
3343
|
+
const ratioOptions = [...this._editorAspectRatios, ...(this._cropAspectRatio !== null ? [this._cropAspectRatio] : [])].filter((ratio, index, all) => all.indexOf(ratio) === index)
|
|
3344
|
+
.map((ratio) => [ratio === null ? tr('aspect_ratios.no_fixed.label') : ratio.replace('/', ':'), ratio === null ? 'NaN' : (ratioOf(ratio) ?? 'NaN')]);
|
|
3345
|
+
const ratios = ratioOptions.length >= 2 ? `<div class="fi-fo-file-upload-editor-control-panel-group"><div class="fi-fo-file-upload-editor-control-panel-group-title">${e(tr('aspect_ratios.label'))}</div>${Array.from({ length: Math.ceil(ratioOptions.length / 5) }, (_, chunk) => `<div class="fi-btn-group">${ratioOptions.slice(chunk * 5, chunk * 5 + 5).map(([label, ratio]) => `<button type="button" x-on:click.prevent.stop="${e(`currentRatio = ${js(label)}; editor.setAspectRatio(${ratio === 'NaN' ? 'NaN' : ratio})`)}" x-tooltip="${e(`{ content: ${js(tr('actions.set_aspect_ratio.label', { ratio: label }))}, theme: $store.theme }`)}" x-bind:class="${e(`{ 'fi-active': currentRatio === ${js(label)} }`)}" class="fi-btn">${e(label)}</button>`).join('')}</div>`).join('')}</div>` : '';
|
|
3346
|
+
const success = cls(['fi-btn', ...(colorManager?.getComponentClasses('button', 'success') ?? [])]);
|
|
3347
|
+
const cancel = `<button type="button" x-on:click.prevent="pond.imageEditEditor.oncancel" class="fi-btn">${e(tr('actions.cancel.label'))}</button>`;
|
|
3348
|
+
const save = `<button type="button" x-on:click.prevent="saveEditor" class="${success}">${e(tr('actions.save.label'))}</button>`;
|
|
3349
|
+
const main = explicit ? `<div class="fi-fo-file-upload-editor-control-panel-main"><div class="fi-fo-file-upload-editor-control-panel-group">${fields}</div><div class="fi-fo-file-upload-editor-control-panel-group">${groups}</div>${ratios}<div class="fi-fo-file-upload-editor-control-panel-reset-action-ctn"><button type="button" x-on:click.prevent.stop="editor.reset()" class="${cls(['fi-link', 'fi-size-sm', 'fi-fo-file-upload-editor-control-panel-reset-action', ...(colorManager?.getComponentClasses('link', 'danger') ?? [])])}"><span class="fi-link-label">${e(tr('actions.reset.label'))}</span></button></div></div>` : '';
|
|
3350
|
+
return `<div aria-label="${e(tr('label'))}" aria-modal="true" role="dialog" x-show="isEditorOpen" x-cloak x-on:click.stop="" x-trap.noscroll="isEditorOpen" x-on:keydown.escape.prevent.stop="closeEditor" class="${cls(['fi-fo-file-upload-editor', { 'fi-fo-file-upload-editor-circle-cropper': this._circleCropper, 'fi-fo-file-upload-editor-crop-only': !explicit }])}"><div aria-hidden="true" class="fi-fo-file-upload-editor-overlay"></div><div class="fi-fo-file-upload-editor-window"><div class="fi-fo-file-upload-editor-image-ctn"><img alt="" x-ref="editor" class="fi-fo-file-upload-editor-image" /></div><div class="fi-fo-file-upload-editor-control-panel">${main}<div class="fi-fo-file-upload-editor-control-panel-footer">${explicit ? cancel + save : save + cancel}</div></div></div></div>`;
|
|
3351
|
+
}
|
|
1885
3352
|
async toHtml() {
|
|
1886
3353
|
const isDisabled = await this.isDisabled();
|
|
1887
3354
|
const id = this.getId();
|
|
1888
3355
|
const label = (await this.getLabel()).toLowerCase();
|
|
1889
3356
|
const tr = (key) => t(`filament-forms::components.file_upload.${key}`);
|
|
3357
|
+
const ui = (key, fallback) => (this._ui[key] === undefined ? fallback : this._ui[key]);
|
|
3358
|
+
const hasEditor = (this._imageEditor || this._cropAspectRatio !== null || this._circleCropper) && !isDisabled;
|
|
1890
3359
|
const config = `fileUploadFormComponent({
|
|
1891
3360
|
acceptedFileTypes: ${this._acceptedFileTypes ? js(this._acceptedFileTypes) : 'null'},
|
|
1892
|
-
automaticallyCropImagesAspectRatio:
|
|
1893
|
-
automaticallyOpenImageEditorForAspectRatio:
|
|
1894
|
-
automaticallyResizeImagesMode: null,
|
|
1895
|
-
automaticallyResizeImagesHeight: null,
|
|
1896
|
-
automaticallyResizeImagesWidth: null,
|
|
3361
|
+
automaticallyCropImagesAspectRatio: ${js(this._cropAspectRatio)},
|
|
3362
|
+
automaticallyOpenImageEditorForAspectRatio: ${js(this.autoOpenEditorRatio())},
|
|
3363
|
+
automaticallyResizeImagesMode: ${js(ui('automaticallyResizeImagesMode', null))},
|
|
3364
|
+
automaticallyResizeImagesHeight: ${js(ui('automaticallyResizeImagesHeight', null))},
|
|
3365
|
+
automaticallyResizeImagesWidth: ${js(ui('automaticallyResizeImagesWidth', null))},
|
|
1897
3366
|
canEditSvgs: true,
|
|
1898
3367
|
confirmSvgEditingMessage: ${js(tr('editor.svg.messages.confirmation'))},
|
|
1899
3368
|
disabledSvgEditingMessage: ${js(tr('editor.svg.messages.disabled'))},
|
|
1900
|
-
hasCircleCropper:
|
|
1901
|
-
hasImageEditor:
|
|
1902
|
-
imageEditorEmptyFillColor: null,
|
|
1903
|
-
imageEditorMode: 1,
|
|
1904
|
-
imageEditorViewportHeight: null,
|
|
1905
|
-
imageEditorViewportWidth: null,
|
|
1906
|
-
imagePreviewHeight: null,
|
|
1907
|
-
isAvatar:
|
|
1908
|
-
isDeletable:
|
|
3369
|
+
hasCircleCropper: ${js(this._circleCropper)},
|
|
3370
|
+
hasImageEditor: ${js(hasEditor)},
|
|
3371
|
+
imageEditorEmptyFillColor: ${js(ui('imageEditorEmptyFillColor', null))},
|
|
3372
|
+
imageEditorMode: ${js(ui('imageEditorMode', 1))},
|
|
3373
|
+
imageEditorViewportHeight: ${js(ui('imageEditorViewportHeight', null))},
|
|
3374
|
+
imageEditorViewportWidth: ${js(ui('imageEditorViewportWidth', null))},
|
|
3375
|
+
imagePreviewHeight: ${js(ui('imagePreviewHeight', null))},
|
|
3376
|
+
isAvatar: ${js(this._avatar)},
|
|
3377
|
+
isDeletable: ${js(this._deletable)},
|
|
1909
3378
|
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
1910
|
-
isDownloadable: false,
|
|
1911
|
-
isImageEditorExplicitlyEnabled:
|
|
3379
|
+
isDownloadable: ${js(ui('isDownloadable', false))},
|
|
3380
|
+
isImageEditorExplicitlyEnabled: ${js(this._imageEditor)},
|
|
1912
3381
|
isMultiple: ${this._multiple ? 'true' : 'false'},
|
|
1913
|
-
isOpenable: false,
|
|
1914
|
-
isPasteable: true,
|
|
1915
|
-
isPreviewable: true,
|
|
3382
|
+
isOpenable: ${js(ui('isOpenable', false))},
|
|
3383
|
+
isPasteable: ${js(ui('isPasteable', true))},
|
|
3384
|
+
isPreviewable: ${js(ui('isPreviewable', true))},
|
|
1916
3385
|
isReorderable: ${this._reorderable ? 'true' : 'false'},
|
|
1917
3386
|
isSvgEditingConfirmed: false,
|
|
1918
|
-
itemPanelAspectRatio: null,
|
|
1919
|
-
loadingIndicatorPosition: 'right',
|
|
3387
|
+
itemPanelAspectRatio: ${js(ui('itemPanelAspectRatio', null))},
|
|
3388
|
+
loadingIndicatorPosition: ${js(ui('loadingIndicatorPosition', 'right'))},
|
|
1920
3389
|
locale: 'en',
|
|
1921
3390
|
maxFiles: ${this._maxFiles ?? 'null'},
|
|
1922
3391
|
maxFilesValidationMessage: ${this._maxFiles !== null ? js(`The ${label} field must not have more than ${this._maxFiles} items.`) : 'null'},
|
|
1923
|
-
maxParallelUploads: null,
|
|
3392
|
+
maxParallelUploads: ${js(ui('maxParallelUploads', null))},
|
|
1924
3393
|
maxSize: ${this._maxSize !== null ? js(`${this._maxSize}KB`) : 'null'},
|
|
1925
|
-
mimeTypeMap: [],
|
|
1926
|
-
minSize: null,
|
|
1927
|
-
panelAspectRatio: null,
|
|
1928
|
-
panelLayout: 'compact',
|
|
1929
|
-
placeholder:
|
|
1930
|
-
removeUploadedFileButtonPosition: 'left',
|
|
1931
|
-
shouldAppendFiles: false,
|
|
1932
|
-
shouldAutomaticallyUpscaleImagesWhenResizing: true,
|
|
1933
|
-
shouldOrientImageFromExif: true,
|
|
1934
|
-
shouldTransformImage:
|
|
3394
|
+
mimeTypeMap: ${ui('mimeTypeMap', null) ? js(ui('mimeTypeMap', null)) : '[]'},
|
|
3395
|
+
minSize: ${this._minSize !== null ? js(`${this._minSize}KB`) : 'null'},
|
|
3396
|
+
panelAspectRatio: ${js(ui('panelAspectRatio', null))},
|
|
3397
|
+
panelLayout: ${js(ui('panelLayout', 'compact'))},
|
|
3398
|
+
placeholder: ${js(this._placeholder)},
|
|
3399
|
+
removeUploadedFileButtonPosition: ${js(ui('removeUploadedFileButtonPosition', 'left'))},
|
|
3400
|
+
shouldAppendFiles: ${js(ui('shouldAppendFiles', false))},
|
|
3401
|
+
shouldAutomaticallyUpscaleImagesWhenResizing: ${js(ui('shouldAutomaticallyUpscaleImagesWhenResizing', true))},
|
|
3402
|
+
shouldOrientImageFromExif: ${js(ui('shouldOrientImageFromExif', true))},
|
|
3403
|
+
shouldTransformImage: ${js(Boolean(this._cropAspectRatio || ui('automaticallyResizeImagesHeight', null) || ui('automaticallyResizeImagesWidth', null)))},
|
|
1935
3404
|
state: $wire.$entangle(${js(this.getStatePath())}, false),
|
|
1936
3405
|
statePath: ${js(this.getStatePath())},
|
|
1937
|
-
uploadButtonPosition: 'right',
|
|
1938
|
-
uploadingMessage: ${js(t('filament::components/button.messages.uploading_file'))},
|
|
3406
|
+
uploadButtonPosition: ${js(ui('uploadButtonPosition', 'right'))},
|
|
3407
|
+
uploadingMessage: ${js(ui('uploadingMessage', t('filament::components/button.messages.uploading_file')))},
|
|
1939
3408
|
downloadActionLabel: ${js(tr('actions.download.label'))},
|
|
1940
3409
|
openActionLabel: ${js(tr('actions.open.label'))},
|
|
1941
|
-
uploadProgressIndicatorPosition: 'right',
|
|
3410
|
+
uploadProgressIndicatorPosition: ${js(ui('uploadProgressIndicatorPosition', 'right'))},
|
|
1942
3411
|
})`;
|
|
1943
|
-
const key = `${this.getLivewireKey()}.${createHash('md5').update(JSON.stringify([this._acceptedFileTypes, this._maxFiles, this._multiple, isDisabled])).digest('hex')}`;
|
|
3412
|
+
const key = `${this.getLivewireKey()}.${createHash('md5').update(JSON.stringify([this._acceptedFileTypes, this._maxFiles, this._multiple, isDisabled, this._ui, this._imageEditor, this._cropAspectRatio])).digest('hex')}`;
|
|
1944
3413
|
const input = new Attributes().merge({ 'x-ref': 'input', 'aria-labelledby': `${id}-label`, disabled: isDisabled, multiple: this._multiple ? 'multiple' : null, type: 'file' });
|
|
1945
|
-
return this.wrap(`<div hidden data-field-accessibility data-preserve-filepond-description></div><div x-load x-load-src="${e(asset('file-upload'))}" x-data="${e(config)}" wire:ignore wire:key="${e(key)}" aria-labelledby="${e(id)}-label" id="${e(id)}" role="group" class="fi-fo-file-upload fi-align
|
|
3414
|
+
return this.wrap(`<div hidden data-field-accessibility data-preserve-filepond-description></div><div x-load x-load-src="${e(asset('file-upload'))}" x-data="${e(config)}" wire:ignore wire:key="${e(key)}" aria-labelledby="${e(id)}-label" id="${e(id)}" role="group" class="${cls(['fi-fo-file-upload', { 'fi-fo-file-upload-avatar': this._avatar }, `fi-align-${this._alignment ?? 'start'}`])}"><div class="fi-fo-file-upload-input-ctn"><input ${input.toHtml()} /></div><div x-show="error" x-text="error" x-cloak role="alert" class="fi-fo-file-upload-error-message"></div>${hasEditor ? this.imageEditorHtml() : ''}</div>`, { labelAsDiv: true });
|
|
1946
3415
|
}
|
|
1947
3416
|
}
|
|
3417
|
+
// Modal wizards of actions (`Action.steps()`): the wizard is not contained, the last step submits the action.
|
|
3418
|
+
Action.stepsRenderer = (steps, submit, cancel) => Wizard.make(steps).contained(false).submitAction(submit).cancelAction(cancel);
|