@navette-js/forms 1.0.0-beta.3 → 1.0.0-beta.5
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 +812 -71
- package/lib/index.js +1945 -253
- package/lib/rich-content.d.ts +138 -0
- package/lib/rich-content.js +350 -0
- package/lib/rich-editor-tool.d.ts +60 -0
- package/lib/rich-editor-tool.js +89 -0
- package/lib/rich-editor-tools.d.ts +2 -0
- package/lib/rich-editor-tools.js +394 -0
- 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,8 +1,8 @@
|
|
|
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
|
-
import { Component, Icon as SchemaIcon, Schema, Text, childSchema, renderInlineItems, slug } from '@navette-js/schemas';
|
|
5
|
+
import { Component, Icon as SchemaIcon, Schema, Text, childSchema, renderInlineItems, renderSlot, slug } from '@navette-js/schemas';
|
|
6
6
|
import { Action } from '@navette-js/actions';
|
|
7
7
|
import { loadingIndicator } from '@navette-js/support';
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
@@ -12,11 +12,16 @@ import TextAlign from '@tiptap/extension-text-align';
|
|
|
12
12
|
import Subscript from '@tiptap/extension-subscript';
|
|
13
13
|
import Superscript from '@tiptap/extension-superscript';
|
|
14
14
|
import Highlight from '@tiptap/extension-highlight';
|
|
15
|
-
import
|
|
15
|
+
import { RICH_CONTENT_EXTENSIONS, SizedImage, customRichContentExtensions, registerTextColors, sanitizeCssColor, TextColor, textColorsForJs } from "./rich-content.js";
|
|
16
|
+
export { CustomBlockNode, GridColumnNode, GridNode, LeadNode, MentionNode, MentionProvider, MergeTagNode, registerRichContentExtensions, RichContentCustomBlock, RichContentRenderer, sanitizeCssColor, SizedImage, SmallMark, TextColor, TextColorMark } from "./rich-content.js";
|
|
17
|
+
import { RichEditorTool } from "./rich-editor-tool.js";
|
|
18
|
+
export { RichEditorTool } from "./rich-editor-tool.js";
|
|
19
|
+
import { RICH_EDITOR_TOOL_DEFINITIONS } from "./rich-editor-tools.js";
|
|
20
|
+
export { RICH_EDITOR_TOOL_DEFINITIONS } from "./rich-editor-tools.js";
|
|
16
21
|
import { TableKit } from '@tiptap/extension-table';
|
|
17
22
|
import { fileURLToPath } from 'node:url';
|
|
18
23
|
import nodePath from 'node:path';
|
|
19
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { constants as fsConstants, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
|
|
20
25
|
views.namespace('filament-forms', fileURLToPath(new URL('../views', import.meta.url)));
|
|
21
26
|
let colorManager = null;
|
|
22
27
|
export const useColorManager = (manager) => { colorManager = manager; };
|
|
@@ -28,7 +33,7 @@ const phpNumber = (n) => {
|
|
|
28
33
|
const [mantissa, exponent] = n.toPrecision(14).split('e');
|
|
29
34
|
return `${mantissa.replace(/\.?0+$/, '')}E${exponent}`;
|
|
30
35
|
};
|
|
31
|
-
|
|
36
|
+
import { isBlank as blank, stringRule, parseRegex } from "./rules.js";
|
|
32
37
|
const headline = (s) => s.replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase()).replace(/ (.)/g, (_, c) => ' ' + c.toLowerCase());
|
|
33
38
|
export class Field extends Component {
|
|
34
39
|
_label = null;
|
|
@@ -41,7 +46,16 @@ export class Field extends Component {
|
|
|
41
46
|
_dehydrated = null;
|
|
42
47
|
_hiddenLabel = false;
|
|
43
48
|
_helperText = null;
|
|
49
|
+
_liveMode = null;
|
|
50
|
+
_validationAttribute = null;
|
|
51
|
+
_validationMessages = {};
|
|
52
|
+
_markedAsRequired = null;
|
|
44
53
|
constructor(name) { super(); this._name = name; }
|
|
54
|
+
/** Sends the state to the server as soon as it changes (`onBlur`: when the field loses focus). `false` turns it off. */
|
|
55
|
+
live(condition = true) { this._liveMode = condition === false ? null : typeof condition === 'object' && condition.onBlur ? 'blur' : 'live'; return this; }
|
|
56
|
+
isLive() { return this._liveMode !== null; }
|
|
57
|
+
/** `wire:model` directive of the field, with its live modifiers. */
|
|
58
|
+
wireModel() { return `wire:model${this._liveMode ? '.live' : ''}${this._liveMode === 'blur' ? '.blur' : ''}`; }
|
|
45
59
|
hasStatePath() { return true; }
|
|
46
60
|
getName() { return this._name; }
|
|
47
61
|
getId() { return this.getKey(); }
|
|
@@ -52,6 +66,9 @@ export class Field extends Component {
|
|
|
52
66
|
default(value) { this._default = value; return this; }
|
|
53
67
|
/** Helper text shown under the field. */
|
|
54
68
|
helperText(text) { this._helperText = text; return this; }
|
|
69
|
+
_belowContent = null;
|
|
70
|
+
/** Text, components or actions under the control (after the helper text). Strings are escaped. */
|
|
71
|
+
belowContent(content) { this._belowContent = content; return this; }
|
|
55
72
|
/** Helper text id, linked to the control through `aria-describedby`. */
|
|
56
73
|
getHelperTextId() { return this._helperText !== null ? `${this.getId()}-helper-text` : null; }
|
|
57
74
|
/** Keeps the label for screen readers only. */
|
|
@@ -110,7 +127,125 @@ export class Field extends Component {
|
|
|
110
127
|
getDefaultState() { return typeof this._default === 'function' ? this._default() : this._default; }
|
|
111
128
|
/** Shapes a record value as it enters the form (hydration). */
|
|
112
129
|
hydrateState(value) { return value; }
|
|
113
|
-
rule
|
|
130
|
+
/** Adds a rule: an object `{ name, passes, message }`, or a Laravel-style string (`'max:255'`, `'regex:/^[a-z]+$/'`). */
|
|
131
|
+
rule(rule, condition = true) {
|
|
132
|
+
if (typeof rule === 'string') {
|
|
133
|
+
const spec = rule;
|
|
134
|
+
// Built now, so that an unknown or malformed rule fails when the form is declared.
|
|
135
|
+
const probe = stringRule(spec, { numeric: false, name: this._name ?? '' });
|
|
136
|
+
if (!probe)
|
|
137
|
+
return this;
|
|
138
|
+
const build = () => stringRule(spec, { numeric: this.hasNumericValidation(), name: this._name ?? '' });
|
|
139
|
+
rule = { name: probe.name, implicit: probe.implicit, passes: (value, ctx) => build().passes(value, ctx), message: (attribute) => build().message(attribute) };
|
|
140
|
+
}
|
|
141
|
+
if (condition === false)
|
|
142
|
+
return this;
|
|
143
|
+
if (condition === true) {
|
|
144
|
+
this._rules.push(rule);
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
const inner = rule;
|
|
148
|
+
this._rules.push({ ...inner, passes: async (value, ctx) => !(await condition(ctx)) || inner.passes(value, ctx) });
|
|
149
|
+
return this;
|
|
150
|
+
}
|
|
151
|
+
/** Several rules at once: `rules(['required', 'max:255'])` or `rules('required|max:255')`. */
|
|
152
|
+
rules(rules, condition = true) {
|
|
153
|
+
for (const rule of typeof rules === 'string' ? rules.split('|').filter((item) => item.trim() !== '') : rules)
|
|
154
|
+
this.rule(rule, condition);
|
|
155
|
+
return this;
|
|
156
|
+
}
|
|
157
|
+
/** Value checked by the rules (a subclass may trim it, for instance). */
|
|
158
|
+
stateForValidation(state) { return state; }
|
|
159
|
+
/** Sizes (`min`, `max`, `size`, `between`) compare the number, not the text length. */
|
|
160
|
+
hasNumericValidation() { return false; }
|
|
161
|
+
/** Name of the field in validation messages (the lowercased label by default). */
|
|
162
|
+
validationAttribute(name) { this._validationAttribute = name; return this; }
|
|
163
|
+
/** Custom messages by rule name (`{ required: 'Tell us your name.', max: 'Too long.' }`); `:attribute` is replaced by the field name. */
|
|
164
|
+
validationMessages(messages) { this._validationMessages = { ...this._validationMessages, ...messages }; return this; }
|
|
165
|
+
_loadStateFromRelationships = null;
|
|
166
|
+
_saveRelationships = null;
|
|
167
|
+
/** Fills the field from the record's relationships (after the record's own values): `(record, ctx) => state`. */
|
|
168
|
+
loadStateFromRelationshipsUsing(callback) { this._loadStateFromRelationships = callback; return this; }
|
|
169
|
+
/** Saves the field into the record's relationships, once the record is created or updated. Such a field is not in the saved data. */
|
|
170
|
+
saveRelationshipsUsing(callback) { this._saveRelationships = callback; return this; }
|
|
171
|
+
/** Whether the field saves itself through `saveRelationshipsUsing()` (and is then left out of the saved data). */
|
|
172
|
+
async savesRelationships() { return this._saveRelationships !== null; }
|
|
173
|
+
async loadStateFromRelationships(record) { return this._loadStateFromRelationships ? { state: await this._loadStateFromRelationships(record, this.context()) } : null; }
|
|
174
|
+
async saveRelationships(record) { if (this._saveRelationships)
|
|
175
|
+
await this._saveRelationships({ record, state: await this.dehydrateState(this.context().state), ctx: this.context() }); }
|
|
176
|
+
/** Shows (or hides, with `false`) the required asterisk, regardless of `required()`. */
|
|
177
|
+
markAsRequired(condition = true) { this._markedAsRequired = condition; return this; }
|
|
178
|
+
async isMarkedAsRequired() { return this._markedAsRequired ?? (await this.isRequired()); }
|
|
179
|
+
stringRuleWith(name, parameters = []) { return this.rule(parameters.length ? `${name}:${parameters.map(String).join(',')}` : name); }
|
|
180
|
+
/** The value must match the pattern (a RegExp, or a `/pattern/flags` string). */
|
|
181
|
+
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.` }); }
|
|
182
|
+
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.` }); }
|
|
183
|
+
alpha() { return this.stringRuleWith('alpha'); }
|
|
184
|
+
alphaDash() { return this.stringRuleWith('alpha_dash'); }
|
|
185
|
+
alphaNum() { return this.stringRuleWith('alpha_num'); }
|
|
186
|
+
ascii() { return this.stringRuleWith('ascii'); }
|
|
187
|
+
uuid() { return this.stringRuleWith('uuid'); }
|
|
188
|
+
ulid() { return this.stringRuleWith('ulid'); }
|
|
189
|
+
json() { return this.stringRuleWith('json'); }
|
|
190
|
+
ip() { return this.stringRuleWith('ip'); }
|
|
191
|
+
ipv4() { return this.stringRuleWith('ipv4'); }
|
|
192
|
+
ipv6() { return this.stringRuleWith('ipv6'); }
|
|
193
|
+
macAddress() { return this.stringRuleWith('mac_address'); }
|
|
194
|
+
hexColor() { return this.stringRuleWith('hex_color'); }
|
|
195
|
+
multipleOf(value) { return this.stringRuleWith('multiple_of', [value]); }
|
|
196
|
+
/** The value must be one of these (a list, or the values of an enum-like object). */
|
|
197
|
+
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.` }); }
|
|
198
|
+
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.` }); }
|
|
199
|
+
/** `in()` with the values of an enum-like object. */
|
|
200
|
+
enum(values) { return this.in(values); }
|
|
201
|
+
startsWith(prefixes) { return this.stringRuleWith('starts_with', [prefixes].flat()); }
|
|
202
|
+
endsWith(suffixes) { return this.stringRuleWith('ends_with', [suffixes].flat()); }
|
|
203
|
+
doesntStartWith(prefixes) { return this.stringRuleWith('doesnt_start_with', [prefixes].flat()); }
|
|
204
|
+
doesntEndWith(suffixes) { return this.stringRuleWith('doesnt_end_with', [suffixes].flat()); }
|
|
205
|
+
/** The field must equal `<name>_confirmation` (or the given field) of the same form. */
|
|
206
|
+
confirmed(field = null) { return this.rule(field ? `confirmed:${field}` : 'confirmed'); }
|
|
207
|
+
same(field) { return this.stringRuleWith('same', [field]); }
|
|
208
|
+
different(field) { return this.stringRuleWith('different', [field]); }
|
|
209
|
+
/** Compares with a number or another field of the same form. */
|
|
210
|
+
gt(fieldOrValue) { return this.stringRuleWith('gt', [fieldOrValue]); }
|
|
211
|
+
gte(fieldOrValue) { return this.stringRuleWith('gte', [fieldOrValue]); }
|
|
212
|
+
lt(fieldOrValue) { return this.stringRuleWith('lt', [fieldOrValue]); }
|
|
213
|
+
lte(fieldOrValue) { return this.stringRuleWith('lte', [fieldOrValue]); }
|
|
214
|
+
/** No duplicates in a list value (tags, multiple select, checkbox list). */
|
|
215
|
+
distinct() { return this.stringRuleWith('distinct'); }
|
|
216
|
+
/** Required when another field of the form has one of these values (`requiredIf('type', 'company')`). */
|
|
217
|
+
requiredIf(field, values) { return this.stringRuleWith('required_if', [field, ...[values].flat()]); }
|
|
218
|
+
requiredUnless(field, values) { return this.stringRuleWith('required_unless', [field, ...[values].flat()]); }
|
|
219
|
+
requiredIfAccepted(field) { return this.stringRuleWith('required_if_accepted', [field]); }
|
|
220
|
+
requiredWith(fields) { return this.stringRuleWith('required_with', [fields].flat()); }
|
|
221
|
+
requiredWithAll(fields) { return this.stringRuleWith('required_with_all', [fields].flat()); }
|
|
222
|
+
requiredWithout(fields) { return this.stringRuleWith('required_without', [fields].flat()); }
|
|
223
|
+
requiredWithoutAll(fields) { return this.stringRuleWith('required_without_all', [fields].flat()); }
|
|
224
|
+
prohibited(condition = true) { return condition ? this.stringRuleWith('prohibited') : this; }
|
|
225
|
+
prohibitedIf(field, values) { return this.stringRuleWith('prohibited_if', [field, ...[values].flat()]); }
|
|
226
|
+
prohibitedUnless(field, values) { return this.stringRuleWith('prohibited_unless', [field, ...[values].flat()]); }
|
|
227
|
+
prohibits(fields) { return this.stringRuleWith('prohibits', [fields].flat()); }
|
|
228
|
+
/**
|
|
229
|
+
* The value must exist: in a column of the host's data adapter (`exists()`, `exists({ column })`), in another adapter
|
|
230
|
+
* (`exists({ adapter: new ObjectionAdapter(Brand), column: 'id' })`), or according to a function.
|
|
231
|
+
*/
|
|
232
|
+
exists(check = {}) {
|
|
233
|
+
return this.rule({
|
|
234
|
+
name: 'exists',
|
|
235
|
+
passes: async (value, ctx) => {
|
|
236
|
+
if (typeof check === 'function')
|
|
237
|
+
return check(value, ctx);
|
|
238
|
+
const adapter = check.adapter ?? ctx.livewire.getDataAdapter?.();
|
|
239
|
+
if (!adapter)
|
|
240
|
+
throw new Error('exists(): no data adapter (pass one with { adapter }).');
|
|
241
|
+
for (const item of Array.isArray(value) ? value : [value])
|
|
242
|
+
if (!(await adapter.exists(check.column ?? this._name, item, null)))
|
|
243
|
+
return false;
|
|
244
|
+
return true;
|
|
245
|
+
},
|
|
246
|
+
message: (a) => `The selected ${a} is invalid.`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
114
249
|
/** `unique` rule: the value must not exist in the column of the same name; `ignoreRecord` excludes the record being edited. */
|
|
115
250
|
unique(options = {}) {
|
|
116
251
|
return this.rule({
|
|
@@ -144,13 +279,16 @@ export class Field extends Component {
|
|
|
144
279
|
if ((await this.isDisabled()) && this._dehydrated !== true)
|
|
145
280
|
return [];
|
|
146
281
|
// An empty string is treated as missing.
|
|
147
|
-
const
|
|
148
|
-
const
|
|
282
|
+
const raw = this.stateForValidation(this.context().state);
|
|
283
|
+
const value = raw === '' ? null : raw;
|
|
284
|
+
const attribute = this._validationAttribute ?? (await this.getLabel()).toLowerCase();
|
|
149
285
|
for (const rule of await this.getValidationRules()) {
|
|
150
|
-
if (rule.name !== 'required' && blank(value))
|
|
286
|
+
if (rule.name !== 'required' && !rule.implicit && blank(value))
|
|
151
287
|
continue;
|
|
152
|
-
if (!(await rule.passes(value, this.context())))
|
|
153
|
-
|
|
288
|
+
if (!(await rule.passes(value, this.context()))) {
|
|
289
|
+
const custom = this._validationMessages[rule.name];
|
|
290
|
+
return [custom === undefined ? rule.message(attribute) : typeof custom === 'function' ? custom(attribute) : custom.replace(/:attribute/g, attribute)];
|
|
291
|
+
}
|
|
154
292
|
}
|
|
155
293
|
return [];
|
|
156
294
|
}
|
|
@@ -169,8 +307,10 @@ export class Field extends Component {
|
|
|
169
307
|
// Helper text: a small inline schema under the control.
|
|
170
308
|
if (this._helperText !== null)
|
|
171
309
|
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>`;
|
|
310
|
+
if (this._belowContent !== null)
|
|
311
|
+
html += await renderSlot(this, this._belowContent, this.getKey());
|
|
172
312
|
return views.render('filament-forms::components.field-wrapper', {
|
|
173
|
-
id: this.getId(), label: await this.getLabel(), required: (await this.
|
|
313
|
+
id: this.getId(), label: await this.getLabel(), required: (await this.isMarkedAsRequired()) && !(await this.isDisabled()),
|
|
174
314
|
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
315
|
});
|
|
176
316
|
}
|
|
@@ -189,6 +329,7 @@ export class TextInput extends Field {
|
|
|
189
329
|
_prefix = null;
|
|
190
330
|
_live = null;
|
|
191
331
|
static make(name) { return configured(new TextInput(name)); }
|
|
332
|
+
hasNumericValidation() { return this._numeric !== null; }
|
|
192
333
|
/** Decimal number: `type="number"`, `step="any"`. */
|
|
193
334
|
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
335
|
/** Integer: `type="number"`, `step="1"`. */
|
|
@@ -198,21 +339,60 @@ export class TextInput extends Field {
|
|
|
198
339
|
step(step) { this._step = step; return this; }
|
|
199
340
|
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
341
|
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; }
|
|
342
|
+
/** Label shown before the input (e.g. `$`); `isInline`: inside the input instead of a separate box. */
|
|
343
|
+
prefix(label, isInline = false) { this._prefix = label; this._prefixInline = isInline; return this; }
|
|
203
344
|
_suffix = null;
|
|
204
345
|
/** Label shown after the input ("hours", "kg"…). */
|
|
205
|
-
suffix(label) { this._suffix = label; return this; }
|
|
346
|
+
suffix(label, isInline = false) { this._suffix = label; this._suffixInline = isInline; return this; }
|
|
347
|
+
_prefixInline = false;
|
|
348
|
+
_suffixInline = false;
|
|
349
|
+
_prefixIcon = null;
|
|
350
|
+
_suffixIcon = null;
|
|
351
|
+
_prefixIconColor = null;
|
|
352
|
+
_suffixIconColor = null;
|
|
353
|
+
_prefixActions = [];
|
|
354
|
+
_suffixActions = [];
|
|
355
|
+
prefixIcon(icon, isInline = false) { this._prefixIcon = icon; this._prefixInline = isInline; return this; }
|
|
356
|
+
suffixIcon(icon, isInline = false) { this._suffixIcon = icon; this._suffixInline = isInline; return this; }
|
|
357
|
+
prefixIconColor(color) { this._prefixIconColor = color; return this; }
|
|
358
|
+
suffixIconColor(color) { this._suffixIconColor = color; return this; }
|
|
359
|
+
/**
|
|
360
|
+
* Icon button before the input, running an action. The action gets the field's `state`, and `set(name, value)` to change a
|
|
361
|
+
* field of the same form.
|
|
362
|
+
*/
|
|
363
|
+
prefixAction(action, isInline = false) { this._prefixActions = [...this._prefixActions, action]; this._prefixInline = isInline; return this; }
|
|
364
|
+
prefixActions(actions) { this._prefixActions = actions; return this; }
|
|
365
|
+
suffixAction(action, isInline = false) { this._suffixActions = [...this._suffixActions, action]; this._suffixInline = isInline; return this; }
|
|
366
|
+
suffixActions(actions) { this._suffixActions = actions; return this; }
|
|
367
|
+
/** Affix actions, mounted through the schema component. */
|
|
368
|
+
getActions() { return [...this.getHintActions(), ...this._prefixActions, ...this._suffixActions]; }
|
|
369
|
+
/** Exact length: `minlength` and `maxlength`, and the matching rule. */
|
|
370
|
+
length(length) { this.minLength(length); this.maxLength(length); return this; }
|
|
371
|
+
_datalist = [];
|
|
372
|
+
/** Suggestions offered by the browser while typing (`<datalist>`). */
|
|
373
|
+
datalist(options) { this._datalist = options.map(String); return this; }
|
|
374
|
+
_readOnly = false;
|
|
375
|
+
/** The value is shown and sent, but cannot be changed (`readonly`). */
|
|
376
|
+
readOnly(condition = true) { this._readOnly = condition; return this; }
|
|
377
|
+
_copyable = null;
|
|
378
|
+
/** A button after the input copies its value (the browser only allows it on HTTPS or `localhost`). */
|
|
379
|
+
copyable(condition = true, copyMessage = null, copyMessageDuration = 2000) { this._copyable = condition ? { message: copyMessage, duration: copyMessageDuration } : null; return this; }
|
|
380
|
+
_trim = false;
|
|
381
|
+
/** Removes the spaces around the value before it is validated and saved. */
|
|
382
|
+
trim(condition = true) { this._trim = condition; return this; }
|
|
383
|
+
hydrateState(value) { return this._trim && typeof value === 'string' ? value.trim() : value; }
|
|
384
|
+
/** `trim()`: the submitted value is trimmed before it is validated and saved. */
|
|
385
|
+
stateForValidation(state) { return this._trim && typeof state === 'string' ? state.trim() : state; }
|
|
386
|
+
dehydrateState(state) { return this._trim && typeof state === 'string' ? state.trim() : state; }
|
|
387
|
+
_inputMode = null;
|
|
388
|
+
_autocapitalize = null;
|
|
389
|
+
/** Virtual keyboard: `none`, `text`, `decimal`, `numeric`, `tel`, `search`, `email` or `url`. */
|
|
390
|
+
inputMode(mode) { this._inputMode = mode; return this; }
|
|
391
|
+
/** `none`, `sentences`, `words` or `characters` (`true` / `false` for `on` / `off`). */
|
|
392
|
+
autocapitalize(mode = true) { this._autocapitalize = mode === true ? 'on' : mode === false ? 'off' : mode; return this; }
|
|
206
393
|
/** Sends the state to the server on every keystroke, or on blur with `onBlur`. */
|
|
207
394
|
live(options = {}) { this._live = options.onBlur ? 'blur' : 'live'; return this; }
|
|
208
395
|
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
396
|
password(condition = true) { this._isPassword = condition; return this; }
|
|
217
397
|
revealable(condition = true) { this._isRevealable = condition; return this; }
|
|
218
398
|
/** `true` → "on", `false` → "off", a string as is. */
|
|
@@ -223,7 +403,16 @@ export class TextInput extends Field {
|
|
|
223
403
|
mask(pattern) { this._mask = pattern; return this; }
|
|
224
404
|
/** Phone number: digits, spaces and common punctuation. */
|
|
225
405
|
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(
|
|
406
|
+
email(condition = true) {
|
|
407
|
+
if (!condition) {
|
|
408
|
+
if (this._type === 'email')
|
|
409
|
+
this._type = null;
|
|
410
|
+
this._rules = this._rules.filter((rule) => rule.name !== 'email');
|
|
411
|
+
return this;
|
|
412
|
+
}
|
|
413
|
+
this._type = 'email';
|
|
414
|
+
return this.rule({ name: 'email', passes: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v)), message: (a) => `The ${a} field must be a valid email address.` });
|
|
415
|
+
}
|
|
227
416
|
/** Web address: `url` input type, a valid http(s) value is required. */
|
|
228
417
|
url() { this._type = 'url'; return this.rule({ name: 'url', passes: (v) => { try {
|
|
229
418
|
return ['http:', 'https:'].includes(new URL(String(v)).protocol);
|
|
@@ -236,6 +425,18 @@ export class TextInput extends Field {
|
|
|
236
425
|
async toHtml() {
|
|
237
426
|
const isDisabled = await this.isDisabled();
|
|
238
427
|
const revealable = this._isPassword && this._isRevealable;
|
|
428
|
+
// Affixes: label, icon and actions (only visible actions, mounted through this component).
|
|
429
|
+
const ctx = this.context();
|
|
430
|
+
const key = this.getKey();
|
|
431
|
+
const visibleActions = async (actions) => { const html = []; for (const action of actions)
|
|
432
|
+
if (await action.isVisible(ctx))
|
|
433
|
+
html.push(await action.iconButton().size('sm').toHtml(ctx, { schemaComponent: key })); return html; };
|
|
434
|
+
const prefixActions = await visibleActions(this._prefixActions);
|
|
435
|
+
const copyLabel = t('filament-forms::components.text_input.actions.copy.label');
|
|
436
|
+
const copyMessage = this._copyable?.message ?? t('filament-forms::components.text_input.actions.copy.message');
|
|
437
|
+
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>`] : [])];
|
|
438
|
+
const hasPrefix = this._prefix !== null || this._prefixIcon !== null || prefixActions.length > 0;
|
|
439
|
+
const hasSuffix = this._suffix !== null || this._suffixIcon !== null || suffixActions.length > 0;
|
|
239
440
|
const type = revealable ? 'password' : this._mask !== null ? 'text' : (this._type ?? (this._isPassword ? 'password' : this._numeric ? 'number' : 'text'));
|
|
240
441
|
const model = `wire:model${this._live ? '.live' : ''}${this._live === 'blur' ? '.blur' : ''}`;
|
|
241
442
|
const input = new Attributes()
|
|
@@ -243,16 +444,19 @@ export class TextInput extends Field {
|
|
|
243
444
|
'aria-invalid': this.hasError() ? 'true' : null,
|
|
244
445
|
'aria-describedby': this.getHelperTextId(),
|
|
245
446
|
autocomplete: this._autocomplete, autofocus: this._autofocus, disabled: isDisabled, id: this.getId(),
|
|
246
|
-
|
|
447
|
+
autocapitalize: this._autocapitalize,
|
|
448
|
+
inputmode: this._inputMode ?? (this._numeric === 'decimal' ? 'decimal' : this._numeric === 'integer' ? 'numeric' : null),
|
|
449
|
+
list: this._datalist.length && !isDisabled && !this._readOnly ? `${this.getId()}-list` : null,
|
|
450
|
+
readonly: this._readOnly,
|
|
247
451
|
max: phpNumber(this._maxValue), min: phpNumber(this._minValue),
|
|
248
452
|
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,
|
|
453
|
+
required: (await this.isRequired()) && !isDisabled && !this._readOnly, step: this._step ?? (this._numeric === 'decimal' ? 'any' : this._numeric === 'integer' ? 1 : null), type,
|
|
250
454
|
// Modifiers (`.live`, `.blur`) also apply to `wire:model.fill`.
|
|
251
455
|
[revealable ? model.replace('wire:model', 'wire:model.fill') : model]: this.getStatePath(),
|
|
252
456
|
'x-bind:type': revealable ? "isPasswordRevealed ? 'text' : 'password'" : null,
|
|
253
457
|
'x-mask': this._mask,
|
|
254
458
|
})
|
|
255
|
-
.class(['fi-input', { 'fi-revealable': revealable }]);
|
|
459
|
+
.class(['fi-input', { 'fi-input-has-inline-prefix': this._prefixInline && hasPrefix, 'fi-input-has-inline-suffix': this._suffixInline && hasSuffix, 'fi-revealable': revealable }]);
|
|
256
460
|
const wrapper = new Attributes()
|
|
257
461
|
.merge({
|
|
258
462
|
'x-data': revealable ? 'passwordRevealFormComponent()' : this._mask !== null ? '{}' : null,
|
|
@@ -263,49 +467,58 @@ export class TextInput extends Field {
|
|
|
263
467
|
.class(['fi-fo-text-input', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
264
468
|
return this.wrap(views.render('filament-forms::components.text-input', {
|
|
265
469
|
input: input.toHtml(), wrapper: wrapper.toHtml(), revealable, prefix: this._prefix, suffix: this._suffix,
|
|
470
|
+
prefixIcon: this._prefixIcon ? icon(this._prefixIcon, { attributes: new Attributes().class(this._prefixIconColor && this._prefixIconColor !== 'gray' ? ['fi-color', `fi-color-${this._prefixIconColor}`] : []) }) : '',
|
|
471
|
+
suffixIcon: this._suffixIcon ? icon(this._suffixIcon, { attributes: new Attributes().class(this._suffixIconColor && this._suffixIconColor !== 'gray' ? ['fi-color', `fi-color-${this._suffixIconColor}`] : []) }) : '',
|
|
472
|
+
prefixActions, suffixActions, hasPrefix, hasSuffix, prefixInline: this._prefixInline, suffixInline: this._suffixInline,
|
|
473
|
+
datalist: this._datalist.length && !isDisabled && !this._readOnly ? { id: `${this.getId()}-list`, options: this._datalist } : null,
|
|
266
474
|
showLabel: t('filament-forms::components.text_input.actions.show_password.label'),
|
|
267
475
|
hideLabel: t('filament-forms::components.text_input.actions.hide_password.label'),
|
|
268
476
|
}));
|
|
269
477
|
}
|
|
270
478
|
}
|
|
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
|
-
|
|
479
|
+
const flattenOptions = (groups) => groups.flatMap((item) => (Array.isArray(item) ? [item] : item.options));
|
|
480
|
+
function normalizeOptions(options) {
|
|
481
|
+
const entries = Array.isArray(options) ? options : Object.entries(options);
|
|
482
|
+
return entries.map(([key, value]) => {
|
|
483
|
+
if (value !== null && typeof value === 'object')
|
|
484
|
+
return { label: String(key), options: (Array.isArray(value) ? value : Object.entries(value)).map(([v, l]) => [String(v), String(l)]) };
|
|
485
|
+
return [String(key), String(value)];
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
/** Objection model of the form (the host's data adapter), for relationships. */
|
|
489
|
+
function modelOfForm(ctx, explicit) {
|
|
490
|
+
if (explicit)
|
|
491
|
+
return explicit;
|
|
492
|
+
const host = ctx.livewire;
|
|
493
|
+
const adapter = host.getDataAdapter?.() ?? host.getDataAdapterForTable?.() ?? host.resource?.adapter ?? null;
|
|
494
|
+
const model = adapter?.model;
|
|
495
|
+
if (typeof model !== 'function' || typeof model.getRelation !== 'function')
|
|
496
|
+
throw new Error('relationship(): the form has no Objection model; pass it with relationship(name, title, modify, { model }).');
|
|
497
|
+
return model;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Copy of a component and of the components it holds (layout children, item schemas), so that each repeated item gets its own
|
|
501
|
+
* instances: components keep a reference to their container.
|
|
502
|
+
*/
|
|
503
|
+
export function cloneComponentTree(component) {
|
|
504
|
+
const copy = Object.assign(Object.create(Object.getPrototypeOf(component)), component);
|
|
505
|
+
for (const [property, value] of Object.entries(copy)) {
|
|
506
|
+
if (value instanceof Component)
|
|
507
|
+
copy[property] = cloneComponentTree(value);
|
|
508
|
+
else if (Array.isArray(value) && value.length && value.every((item) => item instanceof Component))
|
|
509
|
+
copy[property] = value.map((item) => cloneComponentTree(item));
|
|
510
|
+
}
|
|
511
|
+
return copy;
|
|
512
|
+
}
|
|
513
|
+
/** Prefix or suffix of an input wrapper: actions, icon and label (escaped). */
|
|
514
|
+
function inputAffixHtml(side, options) {
|
|
515
|
+
if (options.label === null && !options.icon && !options.actions.length)
|
|
516
|
+
return '';
|
|
517
|
+
const label = options.label !== null ? `<span class="fi-input-wrp-label">${e(options.label)}</span>` : '';
|
|
518
|
+
const actions = options.actions.length ? `<div class="fi-input-wrp-actions">${options.actions.join('')}</div>` : '';
|
|
519
|
+
const focus = options.label !== null || options.icon ? ` x-on:click="if (! $event.target.closest('.fi-input-wrp-actions')) $dispatch('focus-input')"` : '';
|
|
520
|
+
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 }]);
|
|
521
|
+
return `<div${focus} class="${classes}">${side === 'prefix' ? actions + options.icon + label : label + options.icon + actions}</div>`;
|
|
309
522
|
}
|
|
310
523
|
/**
|
|
311
524
|
* Dropdown. Native (`<select>`) by default; `searchable()` switches to the JavaScript select,
|
|
@@ -315,6 +528,15 @@ export class Select extends Field {
|
|
|
315
528
|
_options = [];
|
|
316
529
|
_placeholder = null;
|
|
317
530
|
_searchable = false;
|
|
531
|
+
_native = true;
|
|
532
|
+
_allowHtml = false;
|
|
533
|
+
_maxItems = null;
|
|
534
|
+
_minItems = null;
|
|
535
|
+
_optionsLimit = 50;
|
|
536
|
+
_searchDebounce = 1000;
|
|
537
|
+
_messages = {};
|
|
538
|
+
_relationship = null;
|
|
539
|
+
_affixes = { prefix: null, suffix: null, prefixIcon: null, suffixIcon: null, prefixIconColor: null, suffixIconColor: null, prefixActions: [], suffixActions: [], prefixInline: false, suffixInline: false };
|
|
318
540
|
_dynamic = null;
|
|
319
541
|
_searchOnServer = null;
|
|
320
542
|
_live = false;
|
|
@@ -335,6 +557,97 @@ export class Select extends Field {
|
|
|
335
557
|
* `searchOnServer: false`: search runs in the browser, among the options already sent (for a complete list).
|
|
336
558
|
*/
|
|
337
559
|
options(options, config = {}) { this._options = options; this._dynamic = config.dynamic ?? null; this._searchOnServer = config.searchOnServer ?? null; return this; }
|
|
560
|
+
/** "Yes" / "No" options, saved as `1` / `0`. */
|
|
561
|
+
boolean(trueLabel = null, falseLabel = null, placeholder = null) {
|
|
562
|
+
this._options = [['1', trueLabel ?? t('filament-forms::components.select.boolean.true')], ['0', falseLabel ?? t('filament-forms::components.select.boolean.false')]];
|
|
563
|
+
if (placeholder !== null)
|
|
564
|
+
this._placeholder = placeholder;
|
|
565
|
+
return this;
|
|
566
|
+
}
|
|
567
|
+
/** `false`: the JavaScript select, even without search. */
|
|
568
|
+
native(condition = true) { this._native = condition; return this; }
|
|
569
|
+
/** Option labels are HTML (sanitized on the server; JavaScript select only). */
|
|
570
|
+
allowHtml(condition = true) { this._allowHtml = condition; return this; }
|
|
571
|
+
/** Maximum (and minimum) number of selected items of a multiple select, checked on the server. */
|
|
572
|
+
maxItems(count) { this._maxItems = count; return this; }
|
|
573
|
+
minItems(count) { this._minItems = count; return this; }
|
|
574
|
+
/** Number of options shown at once in the JavaScript select (50 by default). */
|
|
575
|
+
optionsLimit(limit) { this._optionsLimit = Math.max(1, Math.floor(limit)); return this; }
|
|
576
|
+
searchDebounce(ms) { this._searchDebounce = Math.max(0, Math.floor(ms)); return this; }
|
|
577
|
+
loadingMessage(message) { this._messages.loading = message; return this; }
|
|
578
|
+
noSearchResultsMessage(message) { this._messages.noSearchResults = message; return this; }
|
|
579
|
+
searchPrompt(message) { this._messages.searchPrompt = message; return this; }
|
|
580
|
+
searchingMessage(message) { this._messages.searching = message; return this; }
|
|
581
|
+
noOptionsMessage(message) { this._messages.noOptions = message; return this; }
|
|
582
|
+
prefix(label, isInline = false) { this._affixes.prefix = label; this._affixes.prefixInline = isInline; return this; }
|
|
583
|
+
suffix(label, isInline = false) { this._affixes.suffix = label; this._affixes.suffixInline = isInline; return this; }
|
|
584
|
+
prefixIcon(name, isInline = false) { this._affixes.prefixIcon = name; this._affixes.prefixInline = isInline; return this; }
|
|
585
|
+
suffixIcon(name, isInline = false) { this._affixes.suffixIcon = name; this._affixes.suffixInline = isInline; return this; }
|
|
586
|
+
prefixIconColor(color) { this._affixes.prefixIconColor = color; return this; }
|
|
587
|
+
suffixIconColor(color) { this._affixes.suffixIconColor = color; return this; }
|
|
588
|
+
prefixAction(action, isInline = false) { this._affixes.prefixActions = [...this._affixes.prefixActions, action]; this._affixes.prefixInline = isInline; return this; }
|
|
589
|
+
suffixAction(action, isInline = false) { this._affixes.suffixActions = [...this._affixes.suffixActions, action]; this._affixes.suffixInline = isInline; return this; }
|
|
590
|
+
prefixActions(actions) { this._affixes.prefixActions = actions; return this; }
|
|
591
|
+
suffixActions(actions) { this._affixes.suffixActions = actions; return this; }
|
|
592
|
+
/**
|
|
593
|
+
* Options from a relationship of the form's model (Objection `relationMappings`): a belongs-to relationship (the field is the
|
|
594
|
+
* foreign key, `Select.make('author_id').relationship('author', 'name')`), or a many-to-many one with `multiple()` (the field is
|
|
595
|
+
* named after the relationship, and saves the links once the record is saved). `modifyQueryUsing(query, search)` scopes the
|
|
596
|
+
* options; picked keys outside that scope are rejected. With `searchable()`, the options are searched on the server.
|
|
597
|
+
*/
|
|
598
|
+
relationship(name, titleAttribute = null, modifyQueryUsing = null, options = {}) {
|
|
599
|
+
this._relationship = { name, title: titleAttribute, modify: modifyQueryUsing, model: options.model ?? null };
|
|
600
|
+
return this;
|
|
601
|
+
}
|
|
602
|
+
relationInfo() {
|
|
603
|
+
const ctx = this.context();
|
|
604
|
+
const model = modelOfForm(ctx, this._relationship.model);
|
|
605
|
+
const relation = model.getRelation(this._relationship.name);
|
|
606
|
+
const related = relation.relatedModelClass;
|
|
607
|
+
const id = `${related.tableName}.${related.idColumn}`;
|
|
608
|
+
const title = `${related.tableName}.${this._relationship.title ?? related.idColumn}`;
|
|
609
|
+
const isMany = /ManyToMany/.test(relation.constructor?.name ?? '') || typeof relation.joinTable === 'string';
|
|
610
|
+
return { model, relation, related, id, title, isMany };
|
|
611
|
+
}
|
|
612
|
+
async relationshipOptions(search, keys, limit = 1000) {
|
|
613
|
+
const { related, id, title } = this.relationInfo();
|
|
614
|
+
const query = related.query();
|
|
615
|
+
this._relationship.modify?.(query, search ?? undefined);
|
|
616
|
+
if (keys !== null) {
|
|
617
|
+
if (!keys.length)
|
|
618
|
+
return [];
|
|
619
|
+
query.whereIn(id, keys.slice(0, 1000));
|
|
620
|
+
}
|
|
621
|
+
if (search)
|
|
622
|
+
query.where(title, 'like', `%${search.slice(0, 255)}%`);
|
|
623
|
+
const rows = await query.clearSelect().select({ __key: id, __title: title }).orderBy(title).limit(limit);
|
|
624
|
+
return rows.map((row) => [String(row.__key), String(row.__title ?? row.__key)]);
|
|
625
|
+
}
|
|
626
|
+
isManyRelationship() { return this._relationship !== null && this.relationInfo().isMany; }
|
|
627
|
+
async savesRelationships() { return (await super.savesRelationships()) || this.isManyRelationship(); }
|
|
628
|
+
async loadStateFromRelationships(record) {
|
|
629
|
+
if (!this.isManyRelationship())
|
|
630
|
+
return super.loadStateFromRelationships(record);
|
|
631
|
+
const { model, related } = this.relationInfo();
|
|
632
|
+
const rows = await model.relatedQuery(this._relationship.name).for(record[model.idColumn]).clearSelect().select(`${related.tableName}.${related.idColumn} as __key`);
|
|
633
|
+
return { state: rows.map((row) => String(row.__key)) };
|
|
634
|
+
}
|
|
635
|
+
/** Many-to-many: links the picked records and unlinks the others (only keys allowed by the relationship's query). */
|
|
636
|
+
async saveRelationships(record) {
|
|
637
|
+
if (!this.isManyRelationship())
|
|
638
|
+
return super.saveRelationships(record);
|
|
639
|
+
const { model, related } = this.relationInfo();
|
|
640
|
+
const key = record[model.idColumn];
|
|
641
|
+
const desired = new Set((await this.relationshipOptions(null, this.selectedValues())).map(([value]) => value));
|
|
642
|
+
const current = (await this.loadStateFromRelationships(record)).state;
|
|
643
|
+
const removed = current.filter((value) => !desired.has(value));
|
|
644
|
+
if (removed.length)
|
|
645
|
+
await model.relatedQuery(this._relationship.name).for(key).unrelate().whereIn(`${related.tableName}.${related.idColumn}`, removed);
|
|
646
|
+
for (const value of desired)
|
|
647
|
+
if (!current.includes(value))
|
|
648
|
+
await model.relatedQuery(this._relationship.name).for(key).relate(related.idColumn === 'id' && /^\d+$/.test(value) ? Number(value) : value);
|
|
649
|
+
await super.saveRelationships(record);
|
|
650
|
+
}
|
|
338
651
|
/** Disables an option depending on the context (e.g. already picked in another row). */
|
|
339
652
|
disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
|
|
340
653
|
/** Sends the state to the server as soon as it changes. */
|
|
@@ -356,33 +669,52 @@ export class Select extends Field {
|
|
|
356
669
|
async labelsOf(values) {
|
|
357
670
|
if (!values.length)
|
|
358
671
|
return [];
|
|
359
|
-
const known = this._optionLabelsUsing ? await this._optionLabelsUsing(values, this.context()) : await this.resolveOptions();
|
|
672
|
+
const known = this._relationship ? await this.relationshipOptions(null, values) : this._optionLabelsUsing ? await this._optionLabelsUsing(values, this.context()) : await this.resolveOptions();
|
|
360
673
|
const map = new Map((Array.isArray(known) ? known : Object.entries(known)).map(([value, label]) => [String(value), String(label)]));
|
|
361
674
|
return values.filter((value) => map.has(value)).map((value) => [value, map.get(value)]);
|
|
362
675
|
}
|
|
363
|
-
|
|
364
|
-
|
|
676
|
+
_preload = false;
|
|
677
|
+
/** Relationship with `searchable()`: loads the options with the form, instead of on search only. Other options are always loaded. */
|
|
678
|
+
preload(condition = true) { this._preload = condition; return this; }
|
|
365
679
|
hydrateState(value) {
|
|
366
680
|
if (this._multiple)
|
|
367
681
|
return (Array.isArray(value) ? value : []).map(String);
|
|
368
682
|
return value === null || value === undefined || value === '' ? null : String(value);
|
|
369
683
|
}
|
|
370
684
|
dehydrateState(state) { return this._multiple ? this.selectedValues() : state; }
|
|
371
|
-
|
|
685
|
+
/** Options with their groups (labels sanitized when `allowHtml()`). */
|
|
686
|
+
async resolveGroupedOptions() {
|
|
687
|
+
if (this._relationship)
|
|
688
|
+
return this._searchable && !this._preload ? [] : this.relationshipOptions(null, null);
|
|
372
689
|
const options = typeof this._options === 'function' ? await this._options(this.context()) : this._options;
|
|
373
|
-
|
|
690
|
+
const groups = normalizeOptions(options);
|
|
691
|
+
if (!this._allowHtml)
|
|
692
|
+
return groups;
|
|
693
|
+
const clean = ([value, label]) => [value, sanitizeHtml(label)];
|
|
694
|
+
return groups.map((item) => (Array.isArray(item) ? clean(item) : { label: item.label, options: item.options.map(clean) }));
|
|
695
|
+
}
|
|
696
|
+
async resolveOptions() { return flattenOptions(await this.resolveGroupedOptions()); }
|
|
697
|
+
itemCountRules() {
|
|
698
|
+
const count = (v) => (Array.isArray(v) ? new Set(v.map(String)).size : 0);
|
|
699
|
+
return [
|
|
700
|
+
...(this._minItems !== null ? [{ name: 'min', passes: (v) => count(v) >= this._minItems, message: (a) => `The ${a} field must have at least ${this._minItems} items.` }] : []),
|
|
701
|
+
...(this._maxItems !== null ? [{ name: 'max', passes: (v) => count(v) <= this._maxItems, message: (a) => `The ${a} field must not have more than ${this._maxItems} items.` }] : []),
|
|
702
|
+
];
|
|
374
703
|
}
|
|
375
704
|
async getValidationRules() {
|
|
376
705
|
// With server search, a value is valid if the server can label it; otherwise it must be among the options.
|
|
377
706
|
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)));
|
|
707
|
+
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.` }];
|
|
708
|
+
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
709
|
return [...(await super.getValidationRules()), { name: 'in', passes, message: (a) => `The selected ${a} is invalid.` }];
|
|
381
710
|
}
|
|
382
711
|
// Methods exposed to the select's JavaScript (called through callSchemaComponentMethod).
|
|
383
712
|
getExposedMethods() { return ['getOptionLabel', 'getSearchResultsForJs', 'getOptionsForJs', 'getOptionLabelsForJs']; }
|
|
384
713
|
static forJs(options) { return options.map(([value, label]) => ({ label, value, isDisabled: false })); }
|
|
385
|
-
|
|
714
|
+
groupsForJs(groups) {
|
|
715
|
+
return groups.map((item) => (Array.isArray(item) ? this.withDisabled(Select.forJs([item]))[0] : { label: item.label, options: this.withDisabled(Select.forJs(item.options)) }));
|
|
716
|
+
}
|
|
717
|
+
async getOptionsForJs() { return this.groupsForJs(await this.resolveGroupedOptions()); }
|
|
386
718
|
withDisabled(options) {
|
|
387
719
|
if (!this._disableOptionWhen)
|
|
388
720
|
return options;
|
|
@@ -390,17 +722,21 @@ export class Select extends Field {
|
|
|
390
722
|
return options.map((option) => ({ ...option, isDisabled: this._disableOptionWhen(option.value, ctx) }));
|
|
391
723
|
}
|
|
392
724
|
async getSearchResultsForJs(search = '') {
|
|
725
|
+
if (this._relationship)
|
|
726
|
+
return this.withDisabled(Select.forJs(await this.relationshipOptions(String(search ?? '').slice(0, 1000), null, this._optionsLimit)));
|
|
393
727
|
if (this._searchResultsUsing) {
|
|
394
728
|
const found = await this._searchResultsUsing(String(search ?? '').slice(0, 1000), this.context());
|
|
395
729
|
return Select.forJs((Array.isArray(found) ? found : Object.entries(found)).map(([value, label]) => [String(value), String(label)]));
|
|
396
730
|
}
|
|
397
731
|
const needle = String(search ?? '').toLowerCase();
|
|
398
|
-
return Select.forJs((await this.resolveOptions()).filter(([, label]) => label.toLowerCase().includes(needle)));
|
|
732
|
+
return this.withDisabled(Select.forJs((await this.resolveOptions()).filter(([, label]) => label.toLowerCase().includes(needle))));
|
|
399
733
|
}
|
|
400
734
|
async getOptionLabel() {
|
|
401
735
|
const state = this.context().state;
|
|
402
736
|
if (state === null || state === undefined || state === '')
|
|
403
737
|
return null;
|
|
738
|
+
if (this._relationship)
|
|
739
|
+
return (await this.relationshipOptions(null, [String(state)]))[0]?.[1] ?? null;
|
|
404
740
|
if (this._optionLabelUsing)
|
|
405
741
|
return this._optionLabelUsing(String(state), this.context());
|
|
406
742
|
return (await this.resolveOptions()).find(([value]) => value === String(state))?.[1] ?? null;
|
|
@@ -449,9 +785,16 @@ export class Select extends Field {
|
|
|
449
785
|
.modalSubmitActionLabel(tr('modal.actions.create.label'))
|
|
450
786
|
.schema(this._createOptionForm)
|
|
451
787
|
.action(async ({ data }) => {
|
|
452
|
-
|
|
788
|
+
let created;
|
|
789
|
+
if (this._createOptionUsing)
|
|
790
|
+
created = await this._createOptionUsing(data, this.context());
|
|
791
|
+
else if (this._relationship) {
|
|
792
|
+
const { related } = this.relationInfo();
|
|
793
|
+
const record = await related.query().insert(data);
|
|
794
|
+
created = record[related.idColumn];
|
|
795
|
+
}
|
|
796
|
+
else
|
|
453
797
|
throw new Error(`Select [${this.getStatePath()}]: createOptionUsing() is required with createOptionForm().`);
|
|
454
|
-
const created = await this._createOptionUsing(data, this.context());
|
|
455
798
|
if (created === null || created === undefined || created === '')
|
|
456
799
|
return;
|
|
457
800
|
this.setState(this._multiple ? [...new Set([...this.selectedValues(), String(created)])] : String(created));
|
|
@@ -481,36 +824,53 @@ export class Select extends Field {
|
|
|
481
824
|
return this._modifyEditOptionAction?.(action) ?? action;
|
|
482
825
|
}
|
|
483
826
|
/** 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
|
-
|
|
827
|
+
getActions() { return [...this.getHintActions(), ...this._affixes.prefixActions, ...this._affixes.suffixActions, ...[this.getCreateOptionAction(), this.getEditOptionAction()].filter((action) => action !== null)]; }
|
|
828
|
+
affixIcon(name, color) { return name ? icon(name, { attributes: new Attributes().class(color && color !== 'gray' ? ['fi-color', `fi-color-${color}`] : []) }) : ''; }
|
|
829
|
+
async affixActionsHtml(actions) {
|
|
830
|
+
const ctx = this.context();
|
|
831
|
+
const html = [];
|
|
832
|
+
for (const action of actions)
|
|
833
|
+
if (await action.isVisible(ctx))
|
|
834
|
+
html.push(await action.iconButton().size('sm').toHtml(ctx, { schemaComponent: this.getKey() }));
|
|
835
|
+
return html;
|
|
836
|
+
}
|
|
837
|
+
async prefixHtml() {
|
|
838
|
+
const a = this._affixes;
|
|
839
|
+
return inputAffixHtml('prefix', { label: a.prefix, icon: this.affixIcon(a.prefixIcon, a.prefixIconColor), actions: await this.affixActionsHtml(a.prefixActions), inline: a.prefixInline });
|
|
840
|
+
}
|
|
841
|
+
/** Suffix: label, icon, actions, then the "+" and "edit" buttons. */
|
|
486
842
|
async suffixHtml() {
|
|
487
843
|
const ctx = this.context();
|
|
488
844
|
const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: this.getKey() };
|
|
489
|
-
const buttons =
|
|
845
|
+
const buttons = await this.affixActionsHtml(this._affixes.suffixActions);
|
|
490
846
|
for (const action of [this.getCreateOptionAction(), this.getEditOptionAction()]) {
|
|
491
847
|
if (action && (await action.isVisible(ctx)))
|
|
492
848
|
buttons.push((await action.toLinkHtml(ctx, context, '')).replace(' wire:key=""', ''));
|
|
493
849
|
}
|
|
494
|
-
|
|
850
|
+
const a = this._affixes;
|
|
851
|
+
return inputAffixHtml('suffix', { label: a.suffix, icon: this.affixIcon(a.suffixIcon, a.suffixIconColor), actions: buttons, inline: a.suffixInline });
|
|
495
852
|
}
|
|
496
853
|
async toHtml() {
|
|
497
854
|
const isDisabled = await this.isDisabled();
|
|
498
|
-
const
|
|
855
|
+
const grouped = await this.resolveGroupedOptions();
|
|
856
|
+
const resolved = flattenOptions(grouped);
|
|
499
857
|
const suffix = await this.suffixHtml();
|
|
500
|
-
|
|
858
|
+
const prefix = await this.prefixHtml();
|
|
859
|
+
if (this._searchable || this._multiple || !this._native || this._allowHtml) {
|
|
501
860
|
const isRequired = (await this.isRequired()) && !isDisabled;
|
|
502
861
|
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(
|
|
862
|
+
return this.wrap(await this.toJsHtml(grouped, isDisabled, suffix, prefix), { wrapperClass: 'fi-fo-select-wrp', after });
|
|
504
863
|
}
|
|
505
864
|
const select = new Attributes()
|
|
506
865
|
.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
866
|
.class(['fi-select-input']);
|
|
508
867
|
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
|
-
|
|
868
|
+
const option = ([value, label]) => `<option value="${e(value)}"${this._disableOptionWhen?.(value, this.context()) ? ' disabled' : ''}>${e(label)}</option>`;
|
|
869
|
+
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('');
|
|
870
|
+
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
871
|
}
|
|
512
872
|
/** JavaScript select rendering. */
|
|
513
|
-
async toJsHtml(
|
|
873
|
+
async toJsHtml(grouped, isDisabled, suffix = '', prefix = '') {
|
|
514
874
|
const state = this.context().state;
|
|
515
875
|
const hasState = !this._multiple && state !== null && state !== undefined && state !== '';
|
|
516
876
|
const selected = this._multiple ? await this.labelsOf(this.selectedValues()) : [];
|
|
@@ -518,7 +878,7 @@ export class Select extends Field {
|
|
|
518
878
|
// Required field: a screen-reader instruction ("Select an option.") is linked to the select.
|
|
519
879
|
const describedBy = [isRequired ? `${this.getId()}-required` : null, this.hasError() ? `${this.getId()}-error` : null].filter(Boolean).join(' ') || null;
|
|
520
880
|
const accessibility = JSON.stringify({ 'aria-describedby': describedBy, 'aria-invalid': this.hasError() ? 'true' : null, 'aria-required': isRequired ? 'true' : null });
|
|
521
|
-
const forJs = this.
|
|
881
|
+
const forJs = this.groupsForJs(grouped);
|
|
522
882
|
// Dynamic options come from a function, so they may change between requests.
|
|
523
883
|
const hasDynamicOptions = this._dynamic ?? typeof this._options === 'function';
|
|
524
884
|
const tr = (key, replace = {}) => t(`filament-forms::components.select.${key}`, replace);
|
|
@@ -528,41 +888,41 @@ export class Select extends Field {
|
|
|
528
888
|
clearButtonLabel: ${js(tr('actions.clear.label'))},
|
|
529
889
|
errorMessage: ${js(tr('error_message'))},
|
|
530
890
|
hasDynamicOptions: ${hasDynamicOptions ? 'true' : 'false'},
|
|
531
|
-
hasDynamicSearchResults: ${this._searchResultsUsing || (this._searchOnServer ?? hasDynamicOptions) ? 'true' : 'false'},
|
|
532
|
-
hasInitialNoOptionsMessage: ${this._searchResultsUsing || this._hasNoInitialOptions ? 'false' : 'true'},
|
|
891
|
+
hasDynamicSearchResults: ${this._searchResultsUsing || (this._relationship && this._searchable) || (this._searchOnServer ?? hasDynamicOptions) ? 'true' : 'false'},
|
|
892
|
+
hasInitialNoOptionsMessage: ${this._searchResultsUsing || (this._relationship && this._searchable && !this._preload) || this._hasNoInitialOptions ? 'false' : 'true'},
|
|
533
893
|
id: ${js(this.getId())},
|
|
534
894
|
initialOptionLabel: ${js(hasState ? await this.getOptionLabel() : null)},
|
|
535
895
|
initialOptionLabels: ${selected.length ? js(selected.map(([value, label]) => ({ label, value, isDisabled: false }))) : '[]'},
|
|
536
896
|
initialState: ${this._multiple ? (selected.length ? js(selected.map(([value]) => value)) : '[]') : js(hasState ? String(state) : null)},
|
|
537
897
|
isAutofocused: ${this._autofocus ? 'true' : 'false'},
|
|
538
898
|
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
539
|
-
isHtmlAllowed: false,
|
|
899
|
+
isHtmlAllowed: ${this._allowHtml ? 'true' : 'false'},
|
|
540
900
|
isMultiple: ${this._multiple ? 'true' : 'false'},
|
|
541
901
|
isReorderable: false,
|
|
542
|
-
isSearchable: true,
|
|
902
|
+
isSearchable: ${this._searchable ? 'true' : 'false'},
|
|
543
903
|
key: ${js(this.getKey())},
|
|
544
904
|
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'))},
|
|
905
|
+
loadingMessage: ${js(this._messages.loading ?? tr('loading_message'))},
|
|
906
|
+
maxItems: ${this._maxItems ?? 'null'},
|
|
907
|
+
maxItemsMessage: ${js(tr('max_items_message', { count: String(this._maxItems ?? '') }))},
|
|
908
|
+
noOptionsMessage: ${js(this._messages.noOptions ?? tr('no_options_message'))},
|
|
909
|
+
noSearchResultsMessage: ${js(this._messages.noSearchResults ?? tr('no_search_results_message'))},
|
|
550
910
|
options: ${forJs.length ? js(forJs) : '[]'},
|
|
551
|
-
optionsLimit:
|
|
911
|
+
optionsLimit: ${this._optionsLimit},
|
|
552
912
|
placeholder: ${js(this._placeholder ?? tr('placeholder'))},
|
|
553
913
|
position: null,
|
|
554
914
|
removeButtonLabel: ${js(tr('actions.remove_option.label'))},
|
|
555
|
-
searchDebounce:
|
|
556
|
-
searchingMessage: ${js(tr('searching_message'))},
|
|
915
|
+
searchDebounce: ${this._searchDebounce},
|
|
916
|
+
searchingMessage: ${js(this._messages.searching ?? tr('searching_message'))},
|
|
557
917
|
searchLabel: ${js(tr('search_label'))},
|
|
558
|
-
searchPrompt: ${js(tr('search_prompt'))},
|
|
918
|
+
searchPrompt: ${js(this._messages.searchPrompt ?? tr('search_prompt'))},
|
|
559
919
|
searchableOptionFields: ${js(['label'])},
|
|
560
920
|
state: $wire.$entangle(${js(this.getStatePath())}, ${this._live ? 'true' : 'false'}),
|
|
561
921
|
statePath: ${js(this.getStatePath())},
|
|
562
922
|
})`;
|
|
563
923
|
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
924
|
const key = `${this.getLivewireKey()}.${createHash('md5').update(JSON.stringify(forJs)).digest('hex')}`;
|
|
565
|
-
return `<div ${wrapper.toHtml()}
|
|
925
|
+
return `<div ${wrapper.toHtml()}>${prefix}<div class="fi-input-wrp-content-ctn"><div class="fi-hidden" data-select-accessibility="${e(accessibility)}" x-data="{
|
|
566
926
|
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
567
927
|
init() {
|
|
568
928
|
const container = $el.nextElementSibling
|
|
@@ -587,28 +947,106 @@ export class TableColumn {
|
|
|
587
947
|
const ITEM_KEY = /^[A-Za-z0-9_-]{1,64}$/;
|
|
588
948
|
const isItemKey = (key) => ITEM_KEY.test(key) && key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
|
|
589
949
|
/**
|
|
590
|
-
* List of repeated items sharing the same fields
|
|
950
|
+
* List of repeated items sharing the same fields: stacked cards (default), a table (`table()`), or one field per item (`simple()`).
|
|
591
951
|
* The state is a map `item key → fields`; key order is display order.
|
|
592
|
-
* Add, delete and move are component actions, mounted with `schemaComponent`.
|
|
952
|
+
* Add, delete, clone and move are component actions, mounted with `schemaComponent`.
|
|
593
953
|
*/
|
|
594
954
|
export class Repeater extends Field {
|
|
595
955
|
_schema = () => [];
|
|
596
956
|
_columns = [];
|
|
597
957
|
_defaultItems = 0;
|
|
598
958
|
_maxItems = 500;
|
|
959
|
+
_minItems = null;
|
|
599
960
|
_itemLinks = [];
|
|
961
|
+
_simple = null;
|
|
962
|
+
_grid = null;
|
|
963
|
+
_collapsible = false;
|
|
964
|
+
_collapsed = false;
|
|
965
|
+
_persistCollapsed = false;
|
|
966
|
+
_cloneable = false;
|
|
967
|
+
_addable = true;
|
|
968
|
+
_deletable = true;
|
|
969
|
+
_reorderableWithButtons = false;
|
|
970
|
+
_itemLabel = null;
|
|
971
|
+
_itemNumbers = false;
|
|
972
|
+
_truncateItemLabel = true;
|
|
973
|
+
_headingTag = 'h4';
|
|
974
|
+
_addActionLabel = null;
|
|
975
|
+
_addActionAlignment = null;
|
|
976
|
+
_labelBetweenItems = null;
|
|
977
|
+
_itemHeaders = true;
|
|
978
|
+
_relationship = null;
|
|
600
979
|
static make(name) { return configured(new Repeater(name)); }
|
|
601
|
-
/** Item
|
|
602
|
-
schema(factory) { this._schema = factory; return this; }
|
|
980
|
+
/** Item components: a function called for each item. */
|
|
981
|
+
schema(factory) { this._schema = typeof factory === 'function' ? factory : () => factory.map(cloneComponentTree); return this; }
|
|
603
982
|
/** Table display, one column per field. */
|
|
604
983
|
table(columns) { this._columns = columns; return this; }
|
|
984
|
+
isTable() { return this._columns.length > 0; }
|
|
985
|
+
/**
|
|
986
|
+
* One field per item, without a card: the state is saved as a list of values (`['a@example.com', 'b@example.com']`).
|
|
987
|
+
* The field is cloned for each item.
|
|
988
|
+
*/
|
|
989
|
+
simple(field) { this._simple = field; return this; }
|
|
605
990
|
defaultItems(count) { this._defaultItems = count; return this; }
|
|
606
991
|
maxItems(count) { this._maxItems = count; return this; }
|
|
992
|
+
/** Minimum number of items, checked on the server. */
|
|
993
|
+
minItems(count) { this._minItems = count; return this; }
|
|
607
994
|
_reorderable = true;
|
|
608
995
|
/** `false`: items keep their order (e.g. a relationship without an order column). */
|
|
609
996
|
reorderable(condition = true) { this._reorderable = condition; return this; }
|
|
997
|
+
_dragAndDrop = true;
|
|
998
|
+
/** Move up / move down buttons in each item header, besides drag and drop. */
|
|
999
|
+
reorderableWithButtons(condition = true) { this._reorderableWithButtons = condition; return this; }
|
|
1000
|
+
/** `false`: no drag and drop (buttons may still reorder, with `reorderableWithButtons()`). */
|
|
1001
|
+
reorderableWithDragAndDrop(condition = true) { this._dragAndDrop = condition; return this; }
|
|
1002
|
+
/** Reordering is off with `reorderable(false)`, and for a relationship without `orderColumn()` (the order could not be saved). */
|
|
1003
|
+
isReorderable() { return this._reorderable && !(this._relationship !== null && this._relationship.orderColumn === null && this._orderColumnPending === null); }
|
|
1004
|
+
canDrag() { return this.isReorderable() && this._dragAndDrop; }
|
|
1005
|
+
canMove() { return this.isReorderable() && (this._dragAndDrop || this._reorderableWithButtons); }
|
|
610
1006
|
/** Link shown on each row (an item action pointing to a URL). */
|
|
611
1007
|
extraItemLink(resolver) { this._itemLinks.push(resolver); return this; }
|
|
1008
|
+
/** Items laid out in a grid (`2`, or `{ md: 2, xl: 3 }`). */
|
|
1009
|
+
grid(columns = 2) { this._grid = columns; return this; }
|
|
1010
|
+
/** Items can be collapsed from their header; `collapsed()` collapses them on load. */
|
|
1011
|
+
collapsible(condition = true) { this._collapsible = condition; return this; }
|
|
1012
|
+
collapsed(condition = true) { this._collapsed = condition; if (condition)
|
|
1013
|
+
this._collapsible = true; return this; }
|
|
1014
|
+
/** Keeps each item's collapsed state in the browser (local storage). */
|
|
1015
|
+
persistCollapsed(condition = true) { this._persistCollapsed = condition; return this; }
|
|
1016
|
+
cloneable(condition = true) { this._cloneable = condition; return this; }
|
|
1017
|
+
/** `false`: no "Add" button (and adding is refused on the server). */
|
|
1018
|
+
addable(condition = true) { this._addable = condition; return this; }
|
|
1019
|
+
/** `false`: no delete button (and deleting is refused on the server). */
|
|
1020
|
+
deletable(condition = true) { this._deletable = condition; return this; }
|
|
1021
|
+
/** Item header label: a string, or a function of the item's state (`({ state }) => state.name ?? null`). Escaped. */
|
|
1022
|
+
itemLabel(label) { this._itemLabel = label; return this; }
|
|
1023
|
+
/** Item number after the label. */
|
|
1024
|
+
itemNumbers(condition = true) { this._itemNumbers = condition; return this; }
|
|
1025
|
+
truncateItemLabel(condition = true) { this._truncateItemLabel = condition; return this; }
|
|
1026
|
+
/** `false`: items have no header (and so no buttons): only their fields. */
|
|
1027
|
+
itemHeaders(condition = true) { this._itemHeaders = condition; return this; }
|
|
1028
|
+
addActionLabel(label) { this._addActionLabel = label; return this; }
|
|
1029
|
+
addActionAlignment(alignment) { this._addActionAlignment = alignment; return this; }
|
|
1030
|
+
labelBetweenItems(label) { this._labelBetweenItems = label; return this; }
|
|
1031
|
+
/**
|
|
1032
|
+
* Items are the records of a has-many relationship of the form's model (Objection `relationMappings`): loaded when the form is
|
|
1033
|
+
* filled, then created, updated and deleted once the record is saved. `orderColumn()` saves the items' order.
|
|
1034
|
+
*/
|
|
1035
|
+
relationship(name = null, modifyQueryUsing = null, options = {}) {
|
|
1036
|
+
this._relationship = { name: name ?? this._name, modify: modifyQueryUsing, orderColumn: this._relationship?.orderColumn ?? null, model: options.model ?? null };
|
|
1037
|
+
return this;
|
|
1038
|
+
}
|
|
1039
|
+
orderColumn(column = 'sort') { if (this._relationship)
|
|
1040
|
+
this._relationship.orderColumn = column;
|
|
1041
|
+
else
|
|
1042
|
+
this._orderColumnPending = column; return this; }
|
|
1043
|
+
_orderColumnPending = null;
|
|
1044
|
+
itemComponents() {
|
|
1045
|
+
if (this._simple)
|
|
1046
|
+
return [cloneComponentTree(this._simple).hiddenLabel()];
|
|
1047
|
+
return this._schema();
|
|
1048
|
+
}
|
|
1049
|
+
itemFields() { return this.itemComponents().filter((component) => component instanceof Field); }
|
|
612
1050
|
/** Well-formed items only: keys come from the browser, so only simple keys are kept. */
|
|
613
1051
|
getItems() {
|
|
614
1052
|
const state = this.context().state;
|
|
@@ -617,28 +1055,118 @@ export class Repeater extends Field {
|
|
|
617
1055
|
return Object.fromEntries(Object.entries(state).filter(([key, item]) => isItemKey(key) && item !== null && typeof item === 'object' && !Array.isArray(item)).slice(0, this._maxItems));
|
|
618
1056
|
}
|
|
619
1057
|
setItems(items) { writePath(this.livewire, this.getStatePath().split('.'), items); }
|
|
620
|
-
itemDefaults() {
|
|
1058
|
+
itemDefaults() {
|
|
1059
|
+
const data = {};
|
|
1060
|
+
fillSchema(new Schema(this.container.livewire).statePath('').components(this.itemComponents()), data);
|
|
1061
|
+
return data;
|
|
1062
|
+
}
|
|
621
1063
|
getDefaultState() { return Object.fromEntries(Array.from({ length: this._defaultItems }, () => [randomUUID(), this.itemDefaults()])); }
|
|
622
|
-
/**
|
|
1064
|
+
/** A list (records → `record-<id>` items; simple values → one-field items) or a keyed map, limited to the declared fields. */
|
|
623
1065
|
hydrateState(value) {
|
|
1066
|
+
if (typeof value === 'string') {
|
|
1067
|
+
try {
|
|
1068
|
+
value = JSON.parse(value);
|
|
1069
|
+
}
|
|
1070
|
+
catch {
|
|
1071
|
+
value = null;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
624
1074
|
if (!Array.isArray(value))
|
|
625
1075
|
return value && typeof value === 'object' ? value : {};
|
|
626
|
-
const fields = this.
|
|
627
|
-
return Object.fromEntries(value.
|
|
1076
|
+
const fields = this.itemFields();
|
|
1077
|
+
return Object.fromEntries(value.slice(0, this._maxItems).map((row) => {
|
|
1078
|
+
if (this._simple)
|
|
1079
|
+
return [randomUUID(), { [this._simple.getName()]: this._simple.hydrateState(row) }];
|
|
1080
|
+
const key = row && typeof row === 'object' && row.id !== undefined && row.id !== null ? `record-${row.id}` : randomUUID();
|
|
1081
|
+
return [key, Object.fromEntries(fields.map((field) => [field.getName(), field.hydrateState(row?.[field.getName()] ?? field.getDefaultState())]))];
|
|
1082
|
+
}));
|
|
628
1083
|
}
|
|
629
1084
|
dehydrateState() { return Object.fromEntries(Object.keys(this.getItems()).map((key) => [key, {}])); }
|
|
1085
|
+
/** Simple repeater: saved as a list of values. */
|
|
1086
|
+
finalizeDehydratedState(state) {
|
|
1087
|
+
if (!this._simple || !state || typeof state !== 'object' || Array.isArray(state))
|
|
1088
|
+
return state;
|
|
1089
|
+
const name = this._simple.getName();
|
|
1090
|
+
return Object.values(state).map((item) => item?.[name] ?? null);
|
|
1091
|
+
}
|
|
630
1092
|
getChildSchemas() {
|
|
631
1093
|
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.
|
|
1094
|
+
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
1095
|
}
|
|
1096
|
+
_childColumns = null;
|
|
1097
|
+
/** Grid columns inside each item. */
|
|
1098
|
+
columns(columns) { this._childColumns = columns; return this; }
|
|
634
1099
|
async getValidationRules() {
|
|
635
1100
|
const rules = [];
|
|
1101
|
+
const count = () => Object.keys(this.getItems()).length;
|
|
636
1102
|
if (await this.isRequired())
|
|
637
|
-
rules.push({ name: 'required', passes: () =>
|
|
1103
|
+
rules.push({ name: 'required', passes: () => count() > 0, message: (a) => `The ${a} field is required.` });
|
|
1104
|
+
if (this._minItems !== null)
|
|
1105
|
+
rules.push({ name: 'min', implicit: true, passes: () => count() >= this._minItems, message: (a) => `The ${a} field must have at least ${this._minItems} items.` });
|
|
1106
|
+
rules.push({ name: 'max', implicit: true, passes: () => count() <= this._maxItems, message: (a) => `The ${a} field must not have more than ${this._maxItems} items.` });
|
|
638
1107
|
return [...rules, ...this._rules];
|
|
639
1108
|
}
|
|
1109
|
+
// ---- relationship
|
|
1110
|
+
relationInfo() {
|
|
1111
|
+
const model = modelOfForm(this.context(), this._relationship.model);
|
|
1112
|
+
const relation = model.getRelation(this._relationship.name);
|
|
1113
|
+
return { model, relation, related: relation.relatedModelClass };
|
|
1114
|
+
}
|
|
1115
|
+
async savesRelationships() { return (await super.savesRelationships()) || this._relationship !== null; }
|
|
1116
|
+
relatedQuery(record) {
|
|
1117
|
+
const { model } = this.relationInfo();
|
|
1118
|
+
const query = model.relatedQuery(this._relationship.name).for(record[model.idColumn]);
|
|
1119
|
+
this._relationship.modify?.(query);
|
|
1120
|
+
return query;
|
|
1121
|
+
}
|
|
1122
|
+
async loadStateFromRelationships(record) {
|
|
1123
|
+
if (!this._relationship)
|
|
1124
|
+
return super.loadStateFromRelationships(record);
|
|
1125
|
+
if (this._orderColumnPending !== null) {
|
|
1126
|
+
this._relationship.orderColumn = this._orderColumnPending;
|
|
1127
|
+
this._orderColumnPending = null;
|
|
1128
|
+
}
|
|
1129
|
+
const { related } = this.relationInfo();
|
|
1130
|
+
const query = this.relatedQuery(record);
|
|
1131
|
+
if (this._relationship.orderColumn)
|
|
1132
|
+
query.orderBy(`${related.tableName}.${this._relationship.orderColumn}`);
|
|
1133
|
+
const rows = await query.limit(this._maxItems);
|
|
1134
|
+
return { state: rows.map((row) => ({ ...row, id: row[related.idColumn] })) };
|
|
1135
|
+
}
|
|
1136
|
+
/** Creates, updates and deletes the related records; only records of this owner (and query) are touched. */
|
|
1137
|
+
async saveRelationships(record) {
|
|
1138
|
+
if (!this._relationship)
|
|
1139
|
+
return super.saveRelationships(record);
|
|
1140
|
+
if (this._orderColumnPending !== null) {
|
|
1141
|
+
this._relationship.orderColumn = this._orderColumnPending;
|
|
1142
|
+
this._orderColumnPending = null;
|
|
1143
|
+
}
|
|
1144
|
+
const { related } = this.relationInfo();
|
|
1145
|
+
const existing = new Map((await this.relatedQuery(record)).map((row) => [String(row[related.idColumn]), row]));
|
|
1146
|
+
const kept = new Set();
|
|
1147
|
+
const keys = Object.keys(this.getItems());
|
|
1148
|
+
const schemas = this.getChildSchemas();
|
|
1149
|
+
for (const [index, key] of keys.entries()) {
|
|
1150
|
+
const data = await getSchemaState(schemas[index]);
|
|
1151
|
+
if (this._relationship.orderColumn)
|
|
1152
|
+
data[this._relationship.orderColumn] = index + 1;
|
|
1153
|
+
const id = key.startsWith('record-') ? key.slice('record-'.length) : null;
|
|
1154
|
+
if (id !== null && existing.has(id)) {
|
|
1155
|
+
kept.add(id);
|
|
1156
|
+
await related.query().findById(existing.get(id)[related.idColumn]).patch(data);
|
|
1157
|
+
}
|
|
1158
|
+
else {
|
|
1159
|
+
await this.relatedQuery(record).insert(data);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
const removed = [...existing.keys()].filter((id) => !kept.has(id));
|
|
1163
|
+
if (removed.length)
|
|
1164
|
+
await related.query().whereIn(related.idColumn, removed.map((id) => existing.get(id)[related.idColumn])).delete();
|
|
1165
|
+
}
|
|
640
1166
|
getActions() {
|
|
641
1167
|
const move = (offset) => ({ arguments: args }) => {
|
|
1168
|
+
if (!this.canMove())
|
|
1169
|
+
return;
|
|
642
1170
|
const items = this.getItems();
|
|
643
1171
|
const keys = Object.keys(items);
|
|
644
1172
|
const from = keys.indexOf(String(args.item));
|
|
@@ -648,16 +1176,26 @@ export class Repeater extends Field {
|
|
|
648
1176
|
[keys[from], keys[to]] = [keys[to], keys[from]];
|
|
649
1177
|
this.setItems(Object.fromEntries(keys.map((key) => [key, items[key]])));
|
|
650
1178
|
};
|
|
1179
|
+
const insertAfter = (items, after, key, item) => Object.fromEntries(Object.entries(items).flatMap(([k, v]) => (k === after ? [[k, v], [key, item]] : [[k, v]])));
|
|
651
1180
|
return [
|
|
652
1181
|
...this.getHintActions(),
|
|
653
|
-
Action.make('add').action(() => { const items = this.getItems(); if (Object.keys(items).length >= this._maxItems)
|
|
1182
|
+
Action.make('add').action(() => { const items = this.getItems(); if (!this._addable || Object.keys(items).length >= this._maxItems)
|
|
654
1183
|
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))
|
|
1184
|
+
Action.make('delete').action(({ arguments: args }) => { const items = this.getItems(); const key = String(args.item); if (!this._deletable || !Object.hasOwn(items, key))
|
|
656
1185
|
return; delete items[key]; this.setItems(items); }),
|
|
1186
|
+
Action.make('clone').action(({ arguments: args }) => {
|
|
1187
|
+
const items = this.getItems();
|
|
1188
|
+
const key = String(args.item);
|
|
1189
|
+
if (!this._cloneable || !Object.hasOwn(items, key) || Object.keys(items).length >= this._maxItems)
|
|
1190
|
+
return;
|
|
1191
|
+
this.setItems(insertAfter(items, key, randomUUID(), JSON.parse(JSON.stringify(items[key]))));
|
|
1192
|
+
}),
|
|
657
1193
|
Action.make('moveUp').action(move(-1)),
|
|
658
1194
|
Action.make('moveDown').action(move(1)),
|
|
659
1195
|
// Drag and drop: the browser sends the new order, only accepted if it is a permutation of the existing keys.
|
|
660
1196
|
Action.make('reorder').action(({ arguments: args }) => {
|
|
1197
|
+
if (!this.canDrag())
|
|
1198
|
+
return;
|
|
661
1199
|
const items = this.getItems();
|
|
662
1200
|
const keys = Object.keys(items);
|
|
663
1201
|
const order = Array.isArray(args.items) ? args.items.map(String) : [];
|
|
@@ -667,9 +1205,85 @@ export class Repeater extends Field {
|
|
|
667
1205
|
}),
|
|
668
1206
|
];
|
|
669
1207
|
}
|
|
1208
|
+
async itemLabelOf(key, index, item) {
|
|
1209
|
+
const label = typeof this._itemLabel === 'function' ? await this._itemLabel({ state: item, key, index }) : this._itemLabel;
|
|
1210
|
+
return label === null || label === undefined || label === '' ? null : String(label);
|
|
1211
|
+
}
|
|
670
1212
|
async toHtml() {
|
|
671
1213
|
if (!colorManager)
|
|
672
1214
|
throw new Error('No color manager: call useColorManager().');
|
|
1215
|
+
if (this.isTable())
|
|
1216
|
+
return this.toTableHtml();
|
|
1217
|
+
const id = this.getId();
|
|
1218
|
+
const key = this.getKey();
|
|
1219
|
+
const items = this.getItems();
|
|
1220
|
+
const keys = Object.keys(items);
|
|
1221
|
+
const schemas = this.getChildSchemas();
|
|
1222
|
+
const tr = (name, replace = {}) => t(`filament-forms::components.repeater.${name}`, replace);
|
|
1223
|
+
const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: key };
|
|
1224
|
+
const expression = (action, args) => `mountAction(${js(action)}, ${args ? js(args) : '{}'}, ${js(context)})`;
|
|
1225
|
+
const descriptionId = (itemKey) => `${id}-item-${createHash('md5').update(itemKey).digest('hex')}`;
|
|
1226
|
+
const danger = colorManager.getComponentClasses('icon-button', 'danger');
|
|
1227
|
+
const gray = colorManager.getComponentClasses('icon-button', 'gray');
|
|
1228
|
+
const button = (options) => {
|
|
1229
|
+
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);
|
|
1230
|
+
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>`;
|
|
1231
|
+
};
|
|
1232
|
+
const rows = [];
|
|
1233
|
+
for (const [index, itemKey] of keys.entries()) {
|
|
1234
|
+
const isFirst = index === 0, isLast = index === keys.length - 1;
|
|
1235
|
+
const moveButton = (action, label, iconName, direction, disabled) => {
|
|
1236
|
+
const expr = expression(action, { item: itemKey });
|
|
1237
|
+
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>`;
|
|
1238
|
+
};
|
|
1239
|
+
const content = await schemas[index].toHtml();
|
|
1240
|
+
if (this._simple) {
|
|
1241
|
+
const actions = [
|
|
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
|
+
...(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>`] : []),
|
|
1245
|
+
...(this._deletable ? [`<li x-on:click.stop>${button({ label: tr('actions.delete.label'), icon: 'm-trash', expression: expression('delete', { item: itemKey }), colors: danger })}</li>`] : []),
|
|
1246
|
+
];
|
|
1247
|
+
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>`);
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
const label = await this.itemLabelOf(itemKey, index, items[itemKey]);
|
|
1251
|
+
const start = [
|
|
1252
|
+
...(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>`] : []),
|
|
1253
|
+
...(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)] : []),
|
|
1254
|
+
];
|
|
1255
|
+
const links = this._itemLinks.map((resolve) => resolve({ item: items[itemKey], key: itemKey })).filter((link) => link !== null && /^(\/(?!\/)|https?:\/\/)/.test(link.url))
|
|
1256
|
+
.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>`);
|
|
1257
|
+
const end = [
|
|
1258
|
+
...links,
|
|
1259
|
+
...(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>`] : []),
|
|
1260
|
+
...(this._deletable ? [`<li x-on:click.stop>${button({ label: tr('actions.delete.label'), icon: 'm-trash', expression: expression('delete', { item: itemKey }), colors: danger })}</li>`] : []),
|
|
1261
|
+
...(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>`] : []),
|
|
1262
|
+
];
|
|
1263
|
+
const hasHeader = this._itemHeaders && (start.length > 0 || label !== null || end.length > 0);
|
|
1264
|
+
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>` : '';
|
|
1265
|
+
const itemConfig = { isCollapsed: this._collapsed, collapseKey: this._persistCollapsed ? `filament:repeater:collapsed:${key}-${itemKey}` : null };
|
|
1266
|
+
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>`);
|
|
1267
|
+
if (!isLast && this._labelBetweenItems)
|
|
1268
|
+
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>`);
|
|
1269
|
+
}
|
|
1270
|
+
const prefix = this._simple ? 'fi-fo-simple-repeater' : 'fi-fo-repeater';
|
|
1271
|
+
const gridAttributes = this._grid !== null ? new Attributes().grid(this._grid).toHtml() : '';
|
|
1272
|
+
const gridClass = /class="([^"]*)"/.exec(gridAttributes)?.[1] ?? '';
|
|
1273
|
+
const gridStyle = /style="([^"]*)"/.exec(gridAttributes)?.[1] ?? null;
|
|
1274
|
+
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>` : '';
|
|
1275
|
+
const label = await this.getLabel();
|
|
1276
|
+
const addExpr = expression('add', null);
|
|
1277
|
+
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>` : '';
|
|
1278
|
+
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>` : '';
|
|
1279
|
+
const itemLabel = tr('reordering.item');
|
|
1280
|
+
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('');
|
|
1281
|
+
const config = { statePath: this.getStatePath(), schemaComponent: key, moveUpAction: 'moveUp', moveDownAction: 'moveDown', reorderAction: 'reorder', message: tr('reordering.moved'), hasCustomReorderAction: false };
|
|
1282
|
+
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>`;
|
|
1283
|
+
return this.wrap(html, { labelAsDiv: true });
|
|
1284
|
+
}
|
|
1285
|
+
async toTableHtml() {
|
|
1286
|
+
const colors = colorManager;
|
|
673
1287
|
const id = this.getId();
|
|
674
1288
|
const key = this.getKey();
|
|
675
1289
|
const livewireKey = this.getLivewireKey() ?? '';
|
|
@@ -686,7 +1300,7 @@ export class Repeater extends Field {
|
|
|
686
1300
|
const descriptionId = (itemKey) => `${id}-item-${createHash('md5').update(itemKey).digest('hex')}`;
|
|
687
1301
|
const rows = [];
|
|
688
1302
|
// No reorder column while there is only one row.
|
|
689
|
-
const hasReorderColumn = this.
|
|
1303
|
+
const hasReorderColumn = this.canDrag() && keys.length > 1;
|
|
690
1304
|
for (const [index, itemKey] of keys.entries()) {
|
|
691
1305
|
const moveButton = (action, label, iconName, direction, disabled) => {
|
|
692
1306
|
const expr = expression(action, { item: itemKey });
|
|
@@ -695,68 +1309,291 @@ export class Repeater extends Field {
|
|
|
695
1309
|
const cells = [];
|
|
696
1310
|
for (const field of schemas[index].getComponents())
|
|
697
1311
|
cells.push(`<td>${await field.toSchemaHtml(await field.isVisible())}</td>`);
|
|
698
|
-
|
|
1312
|
+
// Only local or http(s) URLs: a link built from item data cannot become a `javascript:` URL.
|
|
1313
|
+
const links = this._itemLinks.map((resolve) => resolve({ item: items[itemKey], key: itemKey })).filter((link) => link !== null && /^(\/(?!\/)|https?:\/\/)/.test(link.url))
|
|
699
1314
|
.map((link) => `<div x-on:click.stop><a href="${e(link.url)}" target="_blank" x-tooltip="{
|
|
700
1315
|
content: ${e(js(link.label))},
|
|
701
1316
|
theme: $store.theme,
|
|
702
1317
|
allowHTML: false,
|
|
703
1318
|
}" aria-label="${e(link.label)}" class="${button} fi-size-sm">${icon(link.icon)}</a></div>`);
|
|
704
1319
|
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('')}
|
|
1320
|
+
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
1321
|
}
|
|
707
1322
|
const head = this._columns.map((column) => `<th scope="col"${column.getWidth() ? ` style="width: ${e(column.getWidth())};"` : ''}>${e(column.label)}</th>`).join('');
|
|
708
1323
|
const addExpr = expression('add', null);
|
|
709
1324
|
const label = await this.getLabel();
|
|
710
1325
|
const config = { statePath: this.getStatePath(), schemaComponent: key, moveUpAction: 'moveUp', moveDownAction: 'moveDown', reorderAction: 'reorder', message: tr('reordering.moved'), hasCustomReorderAction: false };
|
|
711
1326
|
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>`;
|
|
1327
|
+
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
1328
|
return this.wrap(table, { labelAsDiv: true });
|
|
714
1329
|
}
|
|
715
1330
|
}
|
|
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>`);
|
|
1331
|
+
/** 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. */
|
|
1332
|
+
export function phpDateFormatToDayjs(format) {
|
|
1333
|
+
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' };
|
|
1334
|
+
let out = '';
|
|
1335
|
+
for (let index = 0; index < format.length; index++) {
|
|
1336
|
+
const char = format[index];
|
|
1337
|
+
if (char === '\\' && index + 1 < format.length) {
|
|
1338
|
+
out += `[${format[++index]}]`;
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
out += Object.hasOwn(tokens, char) ? tokens[char] : /[a-zA-Z]/.test(char) ? `[${char}]` : char;
|
|
734
1342
|
}
|
|
1343
|
+
return out;
|
|
735
1344
|
}
|
|
736
|
-
|
|
737
|
-
|
|
1345
|
+
const pad = (n, size = 2) => String(n).padStart(size, '0');
|
|
1346
|
+
/** `YYYY-MM-DD HH:MM:SS` of a wall-clock time in a time zone, from a UTC instant. */
|
|
1347
|
+
function formatInZone(date, timeZone) {
|
|
1348
|
+
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]));
|
|
1349
|
+
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
|
|
1350
|
+
}
|
|
1351
|
+
/** UTC instant of a wall-clock `YYYY-MM-DD HH:MM:SS` in a time zone. */
|
|
1352
|
+
function zonedToUtc(value, timeZone) {
|
|
1353
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/.exec(value);
|
|
1354
|
+
if (!match)
|
|
1355
|
+
return null;
|
|
1356
|
+
const asUtc = Date.UTC(+match[1], +match[2] - 1, +match[3], +match[4], +match[5], +(match[6] ?? 0));
|
|
1357
|
+
// Offset of the zone at that moment (twice, to settle around DST changes).
|
|
1358
|
+
let guess = asUtc;
|
|
1359
|
+
for (let i = 0; i < 2; i++) {
|
|
1360
|
+
const shown = Date.parse(formatInZone(new Date(guess), timeZone).replace(' ', 'T') + 'Z');
|
|
1361
|
+
guess = asUtc - (shown - guess);
|
|
1362
|
+
}
|
|
1363
|
+
return new Date(guess);
|
|
1364
|
+
}
|
|
1365
|
+
/** Normalizes a date-like value to `YYYY-MM-DD HH:MM:SS` (or null). */
|
|
1366
|
+
function normalizeDateTime(value) {
|
|
1367
|
+
if (value instanceof Date)
|
|
1368
|
+
return Number.isNaN(value.getTime()) ? null : `${value.getUTCFullYear()}-${pad(value.getUTCMonth() + 1)}-${pad(value.getUTCDate())} ${pad(value.getUTCHours())}:${pad(value.getUTCMinutes())}:${pad(value.getUTCSeconds())}`;
|
|
1369
|
+
if (typeof value !== 'string' || value === '')
|
|
1370
|
+
return null;
|
|
1371
|
+
const match = /^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value);
|
|
1372
|
+
if (match)
|
|
1373
|
+
return `${match[1]} ${match[2] ?? '00'}:${match[3] ?? '00'}:${match[4] ?? '00'}`;
|
|
1374
|
+
const time = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value);
|
|
1375
|
+
return time ? `1970-01-01 ${time[1]}:${time[2]}:${time[3] ?? '00'}` : null;
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* Base of the date, time and date-time pickers: native browser input by default, or Filament's JavaScript picker with
|
|
1379
|
+
* `native(false)` (display format, disabled dates, first day of the week, locale). Limits and disabled dates are checked on the server.
|
|
1380
|
+
*/
|
|
1381
|
+
class TemporalField extends Field {
|
|
738
1382
|
_seconds = true;
|
|
739
|
-
|
|
1383
|
+
_native = true;
|
|
1384
|
+
_maxDate = null;
|
|
1385
|
+
_minDate = null;
|
|
1386
|
+
_displayFormat = null;
|
|
1387
|
+
_format = null;
|
|
1388
|
+
_firstDayOfWeek = 1;
|
|
1389
|
+
_disabledDates = [];
|
|
1390
|
+
_closeOnDateSelection = false;
|
|
1391
|
+
_locale = null;
|
|
1392
|
+
_timezone = null;
|
|
1393
|
+
_hoursStep = 1;
|
|
1394
|
+
_minutesStep = 1;
|
|
1395
|
+
_secondsStep = 1;
|
|
1396
|
+
_defaultFocusedDate = null;
|
|
1397
|
+
_temporalPlaceholder = null;
|
|
740
1398
|
seconds(condition = true) { this._seconds = condition; return this; }
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1399
|
+
hasSeconds() { return this.hasTime() && this._seconds; }
|
|
1400
|
+
/** `false`: Filament's JavaScript picker instead of the browser's native input. */
|
|
1401
|
+
native(condition = true) { this._native = condition; return this; }
|
|
1402
|
+
isNative() { return this._native; }
|
|
1403
|
+
/** Display format of the JavaScript picker, in PHP's notation (`'d/m/Y'`, `'M j, Y H:i'`). */
|
|
1404
|
+
displayFormat(format) { this._displayFormat = format; return this; }
|
|
1405
|
+
/** 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). */
|
|
1406
|
+
format(format) { this._format = format; return this; }
|
|
1407
|
+
/** First day of the week in the JavaScript picker: 0 (Sunday) to 6. */
|
|
1408
|
+
firstDayOfWeek(day) { this._firstDayOfWeek = Math.min(6, Math.max(0, Math.floor(day))); return this; }
|
|
1409
|
+
weekStartsOnMonday() { return this.firstDayOfWeek(1); }
|
|
1410
|
+
weekStartsOnSunday() { return this.firstDayOfWeek(0); }
|
|
1411
|
+
/** Dates that cannot be picked (`YYYY-MM-DD`), also rejected on the server. */
|
|
1412
|
+
disabledDates(dates) { this._disabledDates = dates; return this; }
|
|
1413
|
+
closeOnDateSelection(condition = true) { this._closeOnDateSelection = condition; return this; }
|
|
1414
|
+
/** Locale of the JavaScript picker's month and day names (`'fr'`). */
|
|
1415
|
+
locale(locale) { this._locale = locale; return this; }
|
|
1416
|
+
/** Shows and edits the value in this time zone, while it is saved in UTC (date-time pickers). */
|
|
1417
|
+
timezone(timezone) { if (timezone !== null)
|
|
1418
|
+
new Intl.DateTimeFormat('en', { timeZone: timezone }); this._timezone = timezone; return this; }
|
|
1419
|
+
hoursStep(step) { this._hoursStep = step; return this; }
|
|
1420
|
+
minutesStep(step) { this._minutesStep = step; return this; }
|
|
1421
|
+
secondsStep(step) { this._secondsStep = step; return this; }
|
|
1422
|
+
/** Date focused when the JavaScript picker opens on an empty field. */
|
|
1423
|
+
defaultFocusedDate(date) { this._defaultFocusedDate = date; return this; }
|
|
1424
|
+
placeholder(text) { this._temporalPlaceholder = text; return this; }
|
|
1425
|
+
static resolveLimit(date) {
|
|
1426
|
+
const day = (offset) => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10);
|
|
1427
|
+
return date === 'today' ? day(0) : date === 'tomorrow' ? day(1) : date === 'yesterday' ? day(-1) : date;
|
|
1428
|
+
}
|
|
1429
|
+
async limit(date) { const value = await evaluate(date, this.context()); return value ? TemporalField.resolveLimit(String(value)) : null; }
|
|
1430
|
+
/** Latest accepted date: `'today'`, a date, or a context function (`({ get }) => get('ends_at')`). */
|
|
1431
|
+
maxDate(date) {
|
|
1432
|
+
this._maxDate = date;
|
|
1433
|
+
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'}.` });
|
|
1434
|
+
}
|
|
1435
|
+
minDate(date) {
|
|
1436
|
+
this._minDate = date;
|
|
1437
|
+
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'}.` });
|
|
1438
|
+
}
|
|
1439
|
+
/** Comparable string of a value: the date only for date pickers, the time only for time pickers. */
|
|
1440
|
+
comparable(value) {
|
|
1441
|
+
const normalized = normalizeDateTime(this.toUtc(value)) ?? String(value);
|
|
1442
|
+
if (!this.hasTime())
|
|
1443
|
+
return normalized.slice(0, 10);
|
|
1444
|
+
if (!this.hasDate())
|
|
1445
|
+
return normalized.slice(11, this._seconds ? 19 : 16);
|
|
1446
|
+
return normalized;
|
|
746
1447
|
}
|
|
1448
|
+
async getValidationRules() {
|
|
1449
|
+
const rules = await super.getValidationRules();
|
|
1450
|
+
if (!this.hasDate())
|
|
1451
|
+
return rules;
|
|
1452
|
+
return [...rules,
|
|
1453
|
+
{ 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.` },
|
|
1454
|
+
{ 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.` },];
|
|
1455
|
+
}
|
|
1456
|
+
/** Record value to form state, shown in the field's time zone. */
|
|
1457
|
+
hydrateState(value) {
|
|
1458
|
+
const normalized = normalizeDateTime(value);
|
|
1459
|
+
if (normalized === null)
|
|
1460
|
+
return value === undefined ? null : value === '' ? null : typeof value === 'string' ? value : null;
|
|
1461
|
+
let local = normalized;
|
|
1462
|
+
if (this._timezone && this.hasDate() && this.hasTime())
|
|
1463
|
+
local = formatInZone(new Date(normalized.replace(' ', 'T') + 'Z'), this._timezone);
|
|
1464
|
+
return this.stateOf(local);
|
|
1465
|
+
}
|
|
1466
|
+
/** Form state as the input expects it. */
|
|
1467
|
+
stateOf(local) {
|
|
1468
|
+
if (!this._native)
|
|
1469
|
+
return this.hasDate() ? local : `${new Date().toISOString().slice(0, 10)} ${local.slice(11)}`;
|
|
1470
|
+
if (!this.hasTime())
|
|
1471
|
+
return local.slice(0, 10);
|
|
1472
|
+
if (!this.hasDate())
|
|
1473
|
+
return local.slice(11, this._seconds ? 19 : 16);
|
|
1474
|
+
return local.slice(0, this._seconds ? 19 : 16);
|
|
1475
|
+
}
|
|
1476
|
+
/** Wall-clock value of the field's time zone to UTC. */
|
|
1477
|
+
toUtc(value) {
|
|
1478
|
+
if (!this._timezone || !this.hasDate() || !this.hasTime() || typeof value !== 'string')
|
|
1479
|
+
return value;
|
|
1480
|
+
const instant = zonedToUtc(value, this._timezone);
|
|
1481
|
+
return instant ? normalizeDateTime(instant) : value;
|
|
1482
|
+
}
|
|
1483
|
+
/** Saved value: in UTC, in `format()` (or the default format). */
|
|
1484
|
+
dehydrateState(state) {
|
|
1485
|
+
if (state === '' || state === null || state === undefined)
|
|
1486
|
+
return null;
|
|
1487
|
+
const normalized = normalizeDateTime(this.toUtc(typeof state === 'string' ? state.replace('T', ' ') : state));
|
|
1488
|
+
if (normalized === null)
|
|
1489
|
+
return state;
|
|
1490
|
+
if (this._format)
|
|
1491
|
+
return formatPhpDate(normalized, this._format);
|
|
1492
|
+
if (!this.hasTime())
|
|
1493
|
+
return normalized.slice(0, 10);
|
|
1494
|
+
if (!this.hasDate())
|
|
1495
|
+
return normalized.slice(11, this._seconds ? 19 : 16);
|
|
1496
|
+
return normalized;
|
|
1497
|
+
}
|
|
1498
|
+
defaultDisplayFormat() {
|
|
1499
|
+
if (!this.hasTime())
|
|
1500
|
+
return 'M j, Y';
|
|
1501
|
+
const time = this._seconds ? 'H:i:s' : 'H:i';
|
|
1502
|
+
return this.hasDate() ? `M j, Y ${time}` : time;
|
|
1503
|
+
}
|
|
1504
|
+
nativeType() { return !this.hasTime() ? 'date' : !this.hasDate() ? 'time' : 'datetime-local'; }
|
|
747
1505
|
async toHtml() {
|
|
748
1506
|
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']);
|
|
1507
|
+
const min = await this.limit(this._minDate), max = await this.limit(this._maxDate);
|
|
755
1508
|
const wrapper = new Attributes()
|
|
756
1509
|
.merge({ 'x-on:focus-input.stop': "$el.querySelector('input:not([type=hidden])')?.focus()" })
|
|
757
1510
|
.class(['fi-fo-date-time-picker', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
758
|
-
|
|
1511
|
+
const model = this.wireModel();
|
|
1512
|
+
if (this._native) {
|
|
1513
|
+
const limit = (value) => (value === null ? null : !this.hasTime() ? value.slice(0, 10) : !this.hasDate() ? (value.length > 8 ? value.slice(11) : value) : value.replace(' ', 'T'));
|
|
1514
|
+
const input = new Attributes()
|
|
1515
|
+
.merge({
|
|
1516
|
+
'aria-invalid': this.hasError() ? 'true' : null, disabled: isDisabled, id: this.getId(),
|
|
1517
|
+
max: limit(max), min: limit(min), placeholder: this._temporalPlaceholder,
|
|
1518
|
+
required: (await this.isRequired()) && !isDisabled, step: this.hasTime() && this._seconds ? 1 : null, type: this.nativeType(), [model]: this.getStatePath(),
|
|
1519
|
+
})
|
|
1520
|
+
.class(['fi-input']);
|
|
1521
|
+
return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><input ${input.toHtml()} /></div></div>`);
|
|
1522
|
+
}
|
|
1523
|
+
const id = this.getId();
|
|
1524
|
+
const hasDate = this.hasDate(), hasTime = this.hasTime(), hasSeconds = this.hasSeconds();
|
|
1525
|
+
const disabledDates = (await evaluate(this._disabledDates, this.context())) ?? [];
|
|
1526
|
+
const jsLimit = (value) => (value === null ? '' : normalizeDateTime(value.length <= 8 ? `1970-01-01 ${value}` : value) ?? value);
|
|
1527
|
+
const livewireKey = this.getLivewireKey() ?? '';
|
|
1528
|
+
const label = await this.getLabel();
|
|
1529
|
+
const tr = (key) => t(`filament-forms::components.date_time_picker.${key}`);
|
|
1530
|
+
const describedBy = [this.getHelperTextId(), this.hasError() ? `${id}-error` : null].filter(Boolean).join(' ') || null;
|
|
1531
|
+
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']);
|
|
1532
|
+
const config = `dateTimePickerFormComponent({
|
|
1533
|
+
defaultFocusedDate: ${js(this._defaultFocusedDate)},
|
|
1534
|
+
displayFormat: ${js(phpDateFormatToDayjs(this._displayFormat ?? this.defaultDisplayFormat()))},
|
|
1535
|
+
firstDayOfWeek: ${this._firstDayOfWeek},
|
|
1536
|
+
hasDate: ${hasDate ? 'true' : 'false'},
|
|
1537
|
+
isAutofocused: ${this._autofocus && !isDisabled ? 'true' : 'false'},
|
|
1538
|
+
locale: ${js(this._locale ?? 'en')},
|
|
1539
|
+
shouldCloseOnDateSelection: ${this._closeOnDateSelection ? 'true' : 'false'},
|
|
1540
|
+
state: $wire.$entangle(${js(this.getStatePath())}${this.isLive() ? ', true' : ''}),
|
|
1541
|
+
})`;
|
|
1542
|
+
const keyHash = createHash('md5').update(JSON.stringify([disabledDates, isDisabled, max, min, hasDate, hasTime, hasSeconds])).digest('hex');
|
|
1543
|
+
const events = hasDate
|
|
1544
|
+
? ` 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()"`
|
|
1545
|
+
: ` 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()"`;
|
|
1546
|
+
const buttonEvents = isDisabled ? (!hasDate ? ' x-on:keydown.enter.prevent.stop="if (false) { isOpen() ? selectDate() : togglePanelVisibility() }"' : '') : [
|
|
1547
|
+
!hasDate ? ' x-on:keydown.enter.prevent.stop="if (true) { isOpen() ? selectDate() : togglePanelVisibility() }"' : '',
|
|
1548
|
+
' x-on:click="togglePanelVisibility()"',
|
|
1549
|
+
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()"',
|
|
1550
|
+
' x-on:keydown.backspace.prevent.stop="clearState()" x-on:keydown.clear.prevent.stop="clearState()" x-on:keydown.delete.prevent.stop="clearState()"',
|
|
1551
|
+
].join('');
|
|
1552
|
+
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>` : '';
|
|
1553
|
+
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}" />`;
|
|
1554
|
+
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>` : '';
|
|
1555
|
+
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>`;
|
|
1556
|
+
return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn">${picker}</div></div>`);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
/** Formats `YYYY-MM-DD HH:MM:SS` with a PHP date format (the usual tokens). */
|
|
1560
|
+
function formatPhpDate(value, format) {
|
|
1561
|
+
const [date, time] = value.split(' ');
|
|
1562
|
+
const [year, month, day] = date.split('-').map(Number);
|
|
1563
|
+
const [hour, minute, second] = (time ?? '00:00:00').split(':').map(Number);
|
|
1564
|
+
const tokens = {
|
|
1565
|
+
Y: () => String(year), y: () => pad(year % 100), m: () => pad(month), n: () => String(month), d: () => pad(day), j: () => String(day),
|
|
1566
|
+
H: () => pad(hour), G: () => String(hour), h: () => pad(hour % 12 || 12), g: () => String(hour % 12 || 12), i: () => pad(minute), s: () => pad(second),
|
|
1567
|
+
A: () => (hour < 12 ? 'AM' : 'PM'), a: () => (hour < 12 ? 'am' : 'pm'), U: () => String(Date.UTC(year, month - 1, day, hour, minute, second) / 1000),
|
|
1568
|
+
};
|
|
1569
|
+
let out = '';
|
|
1570
|
+
for (let index = 0; index < format.length; index++) {
|
|
1571
|
+
const char = format[index];
|
|
1572
|
+
if (char === '\\' && index + 1 < format.length) {
|
|
1573
|
+
out += format[++index];
|
|
1574
|
+
continue;
|
|
1575
|
+
}
|
|
1576
|
+
out += Object.hasOwn(tokens, char) ? tokens[char]() : char;
|
|
759
1577
|
}
|
|
1578
|
+
return out;
|
|
1579
|
+
}
|
|
1580
|
+
/** Date picker: native `<input type="date">` by default; the saved state is `YYYY-MM-DD`. */
|
|
1581
|
+
export class DatePicker extends TemporalField {
|
|
1582
|
+
static make(name) { return configured(new DatePicker(name)); }
|
|
1583
|
+
hasDate() { return true; }
|
|
1584
|
+
hasTime() { return false; }
|
|
1585
|
+
}
|
|
1586
|
+
/** Time picker: native `<input type="time">` by default; the state is `HH:MM:SS` (`HH:MM` with `seconds(false)`). */
|
|
1587
|
+
export class TimePicker extends TemporalField {
|
|
1588
|
+
static make(name) { return configured(new TimePicker(name)); }
|
|
1589
|
+
hasDate() { return false; }
|
|
1590
|
+
hasTime() { return true; }
|
|
1591
|
+
}
|
|
1592
|
+
/** Date-time picker: native `datetime-local` input by default; the saved state is `YYYY-MM-DD HH:MM:SS`. */
|
|
1593
|
+
export class DateTimePicker extends TemporalField {
|
|
1594
|
+
static make(name) { return configured(new DateTimePicker(name)); }
|
|
1595
|
+
hasDate() { return true; }
|
|
1596
|
+
hasTime() { return true; }
|
|
760
1597
|
}
|
|
761
1598
|
/** Single choice as radio buttons. */
|
|
762
1599
|
export class Radio extends Field {
|
|
@@ -771,8 +1608,16 @@ export class Radio extends Field {
|
|
|
771
1608
|
/** Options side by side. */
|
|
772
1609
|
inline(condition = true) { this._inline = condition; return this; }
|
|
773
1610
|
live(condition = true) { this._live = condition; return this; }
|
|
1611
|
+
_disableOptionWhen = null;
|
|
1612
|
+
/** Options that cannot be picked; they are refused on the server too. */
|
|
1613
|
+
disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
|
|
1614
|
+
isOptionDisabled(value, label) { return this._disableOptionWhen ? this._disableOptionWhen(value, label) : false; }
|
|
1615
|
+
/** "Yes" (`1`) and "No" (`0`) options. */
|
|
1616
|
+
boolean(trueLabel = null, falseLabel = null) {
|
|
1617
|
+
return this.options([['1', trueLabel ?? t('filament-forms::components.radio.boolean.true')], ['0', falseLabel ?? t('filament-forms::components.radio.boolean.false')]]);
|
|
1618
|
+
}
|
|
774
1619
|
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.` }];
|
|
1620
|
+
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
1621
|
}
|
|
777
1622
|
async toHtml() {
|
|
778
1623
|
const id = this.getId();
|
|
@@ -783,7 +1628,7 @@ export class Radio extends Field {
|
|
|
783
1628
|
.class(['fi-fo-radio', this._inline ? 'fi-inline' : null]);
|
|
784
1629
|
const items = this._options.map(([value, label]) => {
|
|
785
1630
|
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() })
|
|
1631
|
+
.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
1632
|
.class(['fi-radio-input', this.hasError() ? 'fi-invalid' : 'fi-valid']);
|
|
788
1633
|
const description = this._descriptions[value] ? `<p class="fi-fo-radio-label-description">${e(this._descriptions[value])}</p>` : '';
|
|
789
1634
|
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 +1642,40 @@ export class Textarea extends Field {
|
|
|
797
1642
|
static make(name) { return configured(new Textarea(name)); }
|
|
798
1643
|
rows(rows) { this._rows = rows; return this; }
|
|
799
1644
|
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.` }); }
|
|
1645
|
+
_minLength = null;
|
|
1646
|
+
_placeholder = null;
|
|
1647
|
+
_readOnly = false;
|
|
1648
|
+
_autosize = false;
|
|
1649
|
+
_grammarly = true;
|
|
1650
|
+
_trim = false;
|
|
1651
|
+
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.` }); }
|
|
1652
|
+
/** Exact length. */
|
|
1653
|
+
length(length) { this.minLength(length); return this.maxLength(length); }
|
|
1654
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
1655
|
+
readOnly(condition = true) { this._readOnly = condition; return this; }
|
|
1656
|
+
/** The textarea grows with its content. */
|
|
1657
|
+
autosize(condition = true) { this._autosize = condition; return this; }
|
|
1658
|
+
/** Stops the Grammarly extension from analyzing the content. */
|
|
1659
|
+
disableGrammarly(condition = true) { this._grammarly = !condition; return this; }
|
|
1660
|
+
/** Removes the spaces around the value before it is validated and saved. */
|
|
1661
|
+
trim(condition = true) { this._trim = condition; return this; }
|
|
1662
|
+
hydrateState(value) { return this._trim && typeof value === 'string' ? value.trim() : value; }
|
|
1663
|
+
/** `trim()`: the submitted value is trimmed before it is validated and saved. */
|
|
1664
|
+
stateForValidation(state) { return this._trim && typeof state === 'string' ? state.trim() : state; }
|
|
1665
|
+
dehydrateState(state) { return this._trim && typeof state === 'string' ? state.trim() : state; }
|
|
800
1666
|
async toHtml() {
|
|
801
1667
|
const isDisabled = await this.isDisabled();
|
|
802
1668
|
// Initial height: 1.5rem per row plus padding.
|
|
803
1669
|
const height = (this._rows ?? 2) * 1.5 + 0.75;
|
|
804
1670
|
const textarea = new Attributes().merge({
|
|
805
|
-
'aria-invalid': this.hasError() ? 'true' : null,
|
|
806
|
-
|
|
1671
|
+
'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',
|
|
1672
|
+
disabled: isDisabled, id: this.getId(), maxlength: this._maxLength, minlength: this._minLength, placeholder: this._placeholder, readonly: this._readOnly,
|
|
1673
|
+
required: (await this.isRequired()) && !isDisabled && !this._readOnly, rows: this._rows, [this.wireModel()]: this.getStatePath(),
|
|
807
1674
|
});
|
|
808
1675
|
const wrapper = new Attributes().class(['fi-fo-textarea', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
809
1676
|
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
1677
|
initialHeight: ${height ?? 'null'},
|
|
811
|
-
shouldAutosize: false,
|
|
1678
|
+
shouldAutosize: ${this._autosize ? 'true' : 'false'},
|
|
812
1679
|
state: $wire.$entangle(${e(js(this.getStatePath()))}),
|
|
813
1680
|
})" x-model="state" ${textarea.toHtml()}></textarea></div></div></div>`, { wrapperClass: 'fi-fo-textarea-wrp' });
|
|
814
1681
|
}
|
|
@@ -824,9 +1691,28 @@ export class TagsInput extends Field {
|
|
|
824
1691
|
suggestions(suggestions) { this._suggestions = suggestions.map(String).slice(0, 1000); return this; }
|
|
825
1692
|
/** Keys that confirm the current tag, besides Enter (`['Tab', ',']`). */
|
|
826
1693
|
splitKeys(keys) { this._splitKeys = keys; return this; }
|
|
1694
|
+
_separator = null;
|
|
1695
|
+
_tagPrefix = null;
|
|
1696
|
+
_tagSuffix = null;
|
|
1697
|
+
_reorderable = false;
|
|
1698
|
+
_tagColor = null;
|
|
1699
|
+
_tagRules = [];
|
|
1700
|
+
/** Saves the tags as one string joined with this separator (`'a,b,c'`) instead of a list. */
|
|
1701
|
+
separator(separator = ',') { this._separator = separator; return this; }
|
|
1702
|
+
/** Text shown before / after each tag (not saved). */
|
|
1703
|
+
tagPrefix(prefix) { this._tagPrefix = prefix; return this; }
|
|
1704
|
+
tagSuffix(suffix) { this._tagSuffix = suffix; return this; }
|
|
1705
|
+
/** Tags can be reordered by dragging. */
|
|
1706
|
+
reorderable(condition = true) { this._reorderable = condition; return this; }
|
|
1707
|
+
/** Badge color of the tags (`primary` by default). */
|
|
1708
|
+
color(color) { this._tagColor = color; return this; }
|
|
1709
|
+
/** Rules applied to each tag (e.g. a maximum length); the message names the tag. */
|
|
1710
|
+
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
1711
|
getDefaultState() { return this._default ?? []; }
|
|
828
1712
|
hydrateState(value) {
|
|
829
|
-
if (typeof value === 'string')
|
|
1713
|
+
if (typeof value === 'string' && this._separator !== null)
|
|
1714
|
+
value = value === '' ? [] : value.split(this._separator);
|
|
1715
|
+
else if (typeof value === 'string') {
|
|
830
1716
|
try {
|
|
831
1717
|
value = JSON.parse(value);
|
|
832
1718
|
}
|
|
@@ -834,10 +1720,23 @@ export class TagsInput extends Field {
|
|
|
834
1720
|
value = value === '' ? [] : [value];
|
|
835
1721
|
}
|
|
836
1722
|
}
|
|
837
|
-
return Array.isArray(value) ? value.filter((tag) => typeof tag === 'string' || typeof tag === 'number').map(String) : [];
|
|
1723
|
+
return Array.isArray(value) ? value.filter((tag) => typeof tag === 'string' || typeof tag === 'number').map((tag) => String(tag).trim()).filter((tag) => tag !== '') : [];
|
|
1724
|
+
}
|
|
1725
|
+
/** Only non-empty strings are saved, whatever shape the browser sends (joined with the separator, if any). */
|
|
1726
|
+
dehydrateState(state) {
|
|
1727
|
+
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);
|
|
1728
|
+
return this._separator !== null ? tags.join(this._separator) : tags;
|
|
1729
|
+
}
|
|
1730
|
+
async getValidationRules() {
|
|
1731
|
+
if (!this._tagRules.length)
|
|
1732
|
+
return super.getValidationRules();
|
|
1733
|
+
return [...(await super.getValidationRules()), ...this._tagRules.map((rule) => ({
|
|
1734
|
+
...rule,
|
|
1735
|
+
passes: async (value, ctx) => { for (const tag of Array.isArray(value) ? value : [])
|
|
1736
|
+
if (!(await rule.passes(tag, ctx)))
|
|
1737
|
+
return false; return true; },
|
|
1738
|
+
}))];
|
|
838
1739
|
}
|
|
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
1740
|
async toHtml() {
|
|
842
1741
|
if (!colorManager)
|
|
843
1742
|
throw new Error('No color manager: call useColorManager().');
|
|
@@ -850,14 +1749,15 @@ export class TagsInput extends Field {
|
|
|
850
1749
|
const input = new Attributes()
|
|
851
1750
|
.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
1751
|
.class(['fi-input']);
|
|
853
|
-
const badge = ['fi-badge', 'fi-size-md', ...colorManager.getComponentClasses('badge', 'primary')].join(' ');
|
|
1752
|
+
const badge = ['fi-badge', 'fi-size-md', ...colorManager.getComponentClasses('badge', this._tagColor ?? 'primary'), this._reorderable && !isDisabled ? 'fi-reorderable' : null].filter(Boolean).join(' ');
|
|
1753
|
+
const reorderable = this._reorderable && !isDisabled;
|
|
854
1754
|
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
1755
|
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
1756
|
state: $wire.$entangle(${e(js(this.getStatePath()))}, false),
|
|
857
1757
|
splitKeys: ${this._splitKeys.length ? e(js(this._splitKeys)) : '[]'},
|
|
858
1758
|
tagAddedMessage: ${e(js(t('filament-forms::components.tags_input.tag_added')))},
|
|
859
1759
|
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"
|
|
1760
|
+
})"><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
1761
|
}
|
|
862
1762
|
}
|
|
863
1763
|
/** On/off switch. */
|
|
@@ -865,14 +1765,32 @@ export class Toggle extends Field {
|
|
|
865
1765
|
static make(name) { return configured(new Toggle(name)); }
|
|
866
1766
|
getDefaultState() { return this._default ?? false; }
|
|
867
1767
|
hydrateState(value) { return value === true || value === 1 || value === '1'; }
|
|
1768
|
+
_onIcon = null;
|
|
1769
|
+
_offIcon = null;
|
|
1770
|
+
_onColor = null;
|
|
1771
|
+
_offColor = null;
|
|
1772
|
+
_inline = true;
|
|
1773
|
+
/** Icons inside the switch's knob. */
|
|
1774
|
+
onIcon(icon) { this._onIcon = icon; return this; }
|
|
1775
|
+
offIcon(icon) { this._offIcon = icon; return this; }
|
|
1776
|
+
/** Colors when on (`primary` by default) and off (`gray`). */
|
|
1777
|
+
onColor(color) { this._onColor = color; return this; }
|
|
1778
|
+
offColor(color) { this._offColor = color; return this; }
|
|
1779
|
+
/** `false`: the label goes above the switch instead of beside it. */
|
|
1780
|
+
inline(condition = true) { this._inline = condition; return this; }
|
|
868
1781
|
async toHtml() {
|
|
869
1782
|
if (!colorManager)
|
|
870
1783
|
throw new Error('No color manager: call useColorManager().');
|
|
871
1784
|
const on = this.context().state === true;
|
|
872
|
-
const colors = colorManager.getComponentClasses('toggle', 'primary').join(' ');
|
|
1785
|
+
const colors = colorManager.getComponentClasses('toggle', this._onColor ?? 'primary').join(' ');
|
|
1786
|
+
const offColors = this._offColor && this._offColor !== 'gray' ? ` ${colorManager.getComponentClasses('toggle', this._offColor).join(' ')}` : '';
|
|
1787
|
+
const onIconHtml = this._onIcon ? icon(this._onIcon, { size: 'xs' }) : '';
|
|
1788
|
+
const offIconHtml = this._offIcon ? icon(this._offIcon, { size: 'xs' }) : '';
|
|
873
1789
|
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
|
-
|
|
1790
|
+
const knob = `<div><div aria-hidden="true">${offIconHtml}</div><div aria-hidden="true">${onIconHtml}</div></div>`;
|
|
1791
|
+
const placeholderKnob = `<div><div aria-hidden="true"></div><div aria-hidden="true">${onIconHtml}</div></div>`;
|
|
1792
|
+
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>` : ''}`;
|
|
1793
|
+
return this._inline ? this.wrap('', { labelPrefix: control }) : this.wrap(control);
|
|
876
1794
|
}
|
|
877
1795
|
}
|
|
878
1796
|
/** Single choice shown as buttons. */
|
|
@@ -895,8 +1813,59 @@ export class ToggleButtons extends Field {
|
|
|
895
1813
|
inline(condition = true) { this._inline = condition; return this; }
|
|
896
1814
|
/** Buttons joined into a single group. */
|
|
897
1815
|
grouped(condition = true) { this._grouped = condition; return this; }
|
|
1816
|
+
_multiple = false;
|
|
1817
|
+
_tooltips = {};
|
|
1818
|
+
_size = null;
|
|
1819
|
+
_labelsHidden = false;
|
|
1820
|
+
_fullWidth = false;
|
|
1821
|
+
_columns = null;
|
|
1822
|
+
_disableOptionWhen = null;
|
|
1823
|
+
/** Several buttons can be selected: the state is a list of values. */
|
|
1824
|
+
multiple(condition = true) { this._multiple = condition; return this; }
|
|
1825
|
+
/** Tooltip per value. */
|
|
1826
|
+
tooltips(tooltips) { this._tooltips = tooltips; return this; }
|
|
1827
|
+
/** Button size: `xs`, `sm`, `md` (default), `lg` or `xl`. */
|
|
1828
|
+
size(size) { this._size = size; return this; }
|
|
1829
|
+
/** Icons only: the labels stay for screen readers. */
|
|
1830
|
+
hiddenButtonLabels(condition = true) { this._labelsHidden = condition; return this; }
|
|
1831
|
+
/** Buttons stretch to the width of the field. */
|
|
1832
|
+
fullWidth(condition = true) { this._fullWidth = condition; return this; }
|
|
1833
|
+
/** Number of columns the buttons are spread over (from `lg`), when not inline. */
|
|
1834
|
+
columns(columns) { this._columns = columns; return this; }
|
|
1835
|
+
/** Options that cannot be selected; they are refused on the server too. */
|
|
1836
|
+
disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
|
|
1837
|
+
isOptionDisabled(value, label) { return this._disableOptionWhen ? this._disableOptionWhen(value, label) : false; }
|
|
1838
|
+
/** "Yes" (`1`) and "No" (`0`) buttons, green and red, with check and cross icons. */
|
|
1839
|
+
boolean(trueLabel = null, falseLabel = null) {
|
|
1840
|
+
this.options([['1', trueLabel ?? t('filament-forms::components.toggle_buttons.boolean.true')], ['0', falseLabel ?? t('filament-forms::components.toggle_buttons.boolean.false')]]);
|
|
1841
|
+
this._colors = { 1: 'success', 0: 'danger', ...this._colors };
|
|
1842
|
+
this._icons = { 1: 'o-check', 0: 'o-x-mark', ...this._icons };
|
|
1843
|
+
return this;
|
|
1844
|
+
}
|
|
1845
|
+
getDefaultState() { return this._default ?? (this._multiple ? [] : null); }
|
|
1846
|
+
hydrateState(value) {
|
|
1847
|
+
if (!this._multiple)
|
|
1848
|
+
return value;
|
|
1849
|
+
if (typeof value === 'string') {
|
|
1850
|
+
try {
|
|
1851
|
+
value = JSON.parse(value);
|
|
1852
|
+
}
|
|
1853
|
+
catch {
|
|
1854
|
+
value = value === '' ? [] : [value];
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
return (Array.isArray(value) ? value : []).map(String);
|
|
1858
|
+
}
|
|
1859
|
+
/** Multiple: only offered values, in option order. */
|
|
1860
|
+
dehydrateState(state) {
|
|
1861
|
+
if (!this._multiple)
|
|
1862
|
+
return state;
|
|
1863
|
+
const chosen = new Set((Array.isArray(state) ? state : []).map(String));
|
|
1864
|
+
return this._options.map(([value]) => value).filter((value) => chosen.has(value));
|
|
1865
|
+
}
|
|
898
1866
|
async getValidationRules() {
|
|
899
|
-
|
|
1867
|
+
const allowed = (v) => this._options.some(([value, label]) => value === String(v) && !this.isOptionDisabled(value, label));
|
|
1868
|
+
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
1869
|
}
|
|
901
1870
|
async toHtml() {
|
|
902
1871
|
if (!colorManager)
|
|
@@ -907,36 +1876,43 @@ export class ToggleButtons extends Field {
|
|
|
907
1876
|
const describedBy = [isRequired ? `${id}-required` : null, this.hasError() ? `${id}-error` : null].filter(Boolean).join(' ') || null;
|
|
908
1877
|
const container = new Attributes()
|
|
909
1878
|
.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]);
|
|
1879
|
+
.class(['fi-fo-toggle-buttons', this._grouped ? 'fi-btn-group' : null, !this._grouped && this._inline ? 'fi-inline' : null, { 'fi-width-full': this._fullWidth }]);
|
|
1880
|
+
if (this._multiple)
|
|
1881
|
+
container.merge({ role: 'group', 'aria-required': null });
|
|
1882
|
+
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
1883
|
const manager = colorManager;
|
|
912
|
-
const button = (value) => ['fi-btn', this._grouped ? 'fi-btn-group-btn' : null,
|
|
1884
|
+
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
1885
|
const items = this._options.map(([value, label]) => {
|
|
914
|
-
const
|
|
915
|
-
const
|
|
1886
|
+
const optionDisabled = this.isOptionDisabled(value, label);
|
|
1887
|
+
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']);
|
|
1888
|
+
const tooltip = this._tooltips[value] ? ` x-tooltip="${e(`{ content: ${js(this._tooltips[value])}, theme: $store.theme, allowHTML: false }`)}"` : '';
|
|
1889
|
+
const iconSize = this._size === 'xs' || this._size === 'sm' ? 'sm' : undefined;
|
|
1890
|
+
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
1891
|
return this._grouped ? html : `<div class="fi-fo-toggle-buttons-btn-ctn">${html}</div>`;
|
|
917
1892
|
});
|
|
918
1893
|
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 });
|
|
1894
|
+
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
1895
|
}
|
|
921
1896
|
}
|
|
922
|
-
// Server-side TipTap schema: the nodes and marks the rich editor
|
|
923
|
-
|
|
924
|
-
const RICH_EXTENSIONS = [StarterKit, TextAlign.configure({ types: ['heading', 'paragraph'], defaultAlignment: 'start' }), Subscript, Superscript, Highlight, Image, TableKit];
|
|
1897
|
+
// Server-side TipTap schema: the nodes and marks the rich editor can produce, and the extensions your application registers.
|
|
1898
|
+
const richExtensions = () => [StarterKit, TextAlign.configure({ types: ['heading', 'paragraph'], defaultAlignment: 'start' }), Subscript, Superscript, Highlight, SizedImage, TableKit, ...RICH_CONTENT_EXTENSIONS, ...customRichContentExtensions];
|
|
925
1899
|
const isRichDocument = (value) => !!value && typeof value === 'object' && value.type === 'doc';
|
|
926
1900
|
const richText = (node) => (typeof node?.text === 'string' ? node.text : '') + (Array.isArray(node?.content) ? node.content.map(richText).join('') : '');
|
|
927
|
-
const hasRichContent = (node) =>
|
|
1901
|
+
const hasRichContent = (node) => ['image', 'horizontalRule', 'mergeTag', 'mention', 'customBlock'].includes(node?.type) || (Array.isArray(node?.content) && node.content.some(hasRichContent));
|
|
928
1902
|
/** Stored HTML → TipTap document. Parsing drops anything the schema does not know (scripts, event attributes, `javascript:` links). */
|
|
929
|
-
export function richHtmlToDocument(html) { return generateJSON(html ?? '',
|
|
1903
|
+
export function richHtmlToDocument(html) { return generateJSON(html ?? '', richExtensions()); }
|
|
930
1904
|
/**
|
|
931
1905
|
* TipTap document → HTML to save, or null if empty or invalid. The document comes from the browser,
|
|
932
1906
|
* so it is rendered then re-parsed to keep only what the schema accepts.
|
|
933
1907
|
*/
|
|
1908
|
+
/** HTML of a document already sanitized (`RichContentRenderer`): no re-parsing. */
|
|
1909
|
+
export function renderRichDocument(document) { return generateHTML(document, richExtensions()); }
|
|
934
1910
|
export function richDocumentToHtml(document) {
|
|
935
1911
|
if (!isRichDocument(document))
|
|
936
1912
|
return null;
|
|
937
1913
|
try {
|
|
938
|
-
const clean = generateJSON(generateHTML(document,
|
|
939
|
-
return richText(clean).trim() === '' && !hasRichContent(clean) ? null : generateHTML(clean,
|
|
1914
|
+
const clean = generateJSON(generateHTML(document, richExtensions()), richExtensions());
|
|
1915
|
+
return richText(clean).trim() === '' && !hasRichContent(clean) ? null : generateHTML(clean, richExtensions());
|
|
940
1916
|
}
|
|
941
1917
|
catch {
|
|
942
1918
|
return null;
|
|
@@ -944,13 +1920,154 @@ export function richDocumentToHtml(document) {
|
|
|
944
1920
|
}
|
|
945
1921
|
/**
|
|
946
1922
|
* Rich text editor: the browser works on a TipTap document, the database receives HTML.
|
|
947
|
-
* Not supported yet:
|
|
1923
|
+
* Not supported yet: custom blocks, merge tags, mentions, text colors.
|
|
1924
|
+
*/
|
|
1925
|
+
/**
|
|
1926
|
+
* Stores a file uploaded by an editor (`componentFileAttachments.<state path>[.<id>]`): type checked on the file's content, size,
|
|
1927
|
+
* then stored; the reference is consumed. Returns the public URL (local or http(s) only).
|
|
1928
|
+
*/
|
|
1929
|
+
async function storeEditorAttachment(field, id, options) {
|
|
1930
|
+
if (!options.storage)
|
|
1931
|
+
return null;
|
|
1932
|
+
const livewire = field.livewire;
|
|
1933
|
+
const segments = field.getStatePath().split('.');
|
|
1934
|
+
const holder = readPath(livewire.componentFileAttachments ?? {}, id === null ? segments.slice(0, -1) : segments);
|
|
1935
|
+
const name = id ?? segments[segments.length - 1];
|
|
1936
|
+
if (!holder || typeof holder !== 'object' || !Object.hasOwn(holder, name))
|
|
1937
|
+
return null;
|
|
1938
|
+
const uploads = livewire._runtime?.uploads;
|
|
1939
|
+
const file = uploads?.resolve(holder[name]);
|
|
1940
|
+
delete holder[name];
|
|
1941
|
+
if (!file || !options.types.includes(file.type) || file.size > options.maxSize * 1024)
|
|
1942
|
+
return null;
|
|
1943
|
+
const key = await options.storage.store(file, options.directory);
|
|
1944
|
+
uploads.remove(file.id);
|
|
1945
|
+
const info = await options.storage.info(key);
|
|
1946
|
+
return info && /^(\/(?!\/)|https?:\/\/)/.test(info.url) ? info.url : null;
|
|
1947
|
+
}
|
|
1948
|
+
/** Tools of the rich editor's toolbar, in their default groups. */
|
|
1949
|
+
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']];
|
|
1950
|
+
/** The editor selection sent by the browser with a toolbar action. */
|
|
1951
|
+
const selectionOf = (args) => (args.editorSelection ?? null);
|
|
1952
|
+
// `details` needs a TipTap extension the server schema does not have: its content would be lost on save.
|
|
1953
|
+
const BUILT_IN_TOOLS = new Map(RICH_EDITOR_TOOL_DEFINITIONS.filter((definition) => definition.name !== 'details').map((definition) => [definition.name, definition]));
|
|
1954
|
+
/**
|
|
1955
|
+
* Rich text editor (TipTap): the browser works on a document, the database receives HTML (or JSON with `json()`).
|
|
1956
|
+
* Besides the starter kit: text colors, merge tags, mentions, custom blocks, columns, resizable images, and your own
|
|
1957
|
+
* tools and TipTap extensions.
|
|
948
1958
|
*/
|
|
949
1959
|
export class RichEditor extends Field {
|
|
1960
|
+
_toolbar = null;
|
|
1961
|
+
_disabledTools = [];
|
|
1962
|
+
_json = false;
|
|
1963
|
+
_richPlaceholder = null;
|
|
1964
|
+
_attachmentsStorage = null;
|
|
1965
|
+
_attachmentsDirectory = null;
|
|
1966
|
+
_attachmentTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
1967
|
+
_attachmentMaxSize = 12288;
|
|
1968
|
+
_customTools = [];
|
|
1969
|
+
_textColors = null;
|
|
1970
|
+
_customTextColors = false;
|
|
1971
|
+
_mergeTags = {};
|
|
1972
|
+
_noMergeTagResults = null;
|
|
1973
|
+
_mentions = [];
|
|
1974
|
+
_customBlocks = [];
|
|
1975
|
+
_minimalCustomBlockControls = false;
|
|
1976
|
+
_customBlocksGrid = false;
|
|
1977
|
+
_searchableCustomBlocks = false;
|
|
1978
|
+
_activePanel = null;
|
|
1979
|
+
_stickyToolbar = false;
|
|
1980
|
+
_resizableImages = false;
|
|
1981
|
+
_jsExtensions = [];
|
|
1982
|
+
_editorActions = [];
|
|
950
1983
|
static make(name) { return configured(new RichEditor(name)); }
|
|
951
|
-
|
|
1984
|
+
/** Toolbar tools, by group (`[['bold', 'italic', 'link'], ['h2', 'h3'], ['undo', 'redo']]`); see `RICH_EDITOR_TOOLS` and `RICH_EDITOR_TOOL_DEFINITIONS`. */
|
|
1985
|
+
toolbarButtons(groups) {
|
|
1986
|
+
const list = (groups.length && typeof groups[0] === 'string' ? [groups] : groups);
|
|
1987
|
+
this._toolbar = list;
|
|
1988
|
+
this.assertKnownTools();
|
|
1989
|
+
return this;
|
|
1990
|
+
}
|
|
1991
|
+
assertKnownTools() {
|
|
1992
|
+
if (!this._toolbar)
|
|
1993
|
+
return;
|
|
1994
|
+
const unknown = this._toolbar.flat().filter((tool) => !this.findTool(tool));
|
|
1995
|
+
if (unknown.length)
|
|
1996
|
+
throw new Error(`RichEditor: unknown toolbar buttons ${unknown.join(', ')} (available: ${[...BUILT_IN_TOOLS.keys(), ...this._customTools.map((tool) => tool.getName())].join(', ')}).`);
|
|
1997
|
+
}
|
|
1998
|
+
/** Removes buttons; with no argument, removes them all. */
|
|
1999
|
+
disableToolbarButtons(tools = []) { if (!tools.length)
|
|
2000
|
+
this._toolbar = []; this._disabledTools = [...this._disabledTools, ...tools]; return this; }
|
|
2001
|
+
disableAllToolbarButtons(condition = true) { if (condition)
|
|
2002
|
+
this._toolbar = []; return this; }
|
|
2003
|
+
/** Your own toolbar buttons (or replacements of built-in ones, by name); place them with `toolbarButtons()`. */
|
|
2004
|
+
tools(tools) { this._customTools = [...this._customTools.filter((tool) => !tools.some((other) => other.getName() === tool.getName())), ...tools]; this.assertKnownTools(); return this; }
|
|
2005
|
+
/** Actions mounted by your tools (`RichEditorTool.make('x').action()`), run with the editor selection in `arguments.editorSelection`. */
|
|
2006
|
+
editorActions(actions) { this._editorActions = [...this._editorActions, ...actions]; return this; }
|
|
2007
|
+
/** Browser TipTap extensions: URLs of ES modules exporting an extension (or an array of them). Register their server side with `registerRichContentExtensions()`. */
|
|
2008
|
+
extensions(urls) { this._jsExtensions = [...this._jsExtensions, ...urls]; return this; }
|
|
2009
|
+
/** Saves the content as the editor's JSON document instead of HTML. */
|
|
2010
|
+
json(condition = true) { this._json = condition; return this; }
|
|
2011
|
+
placeholder(text) { this._richPlaceholder = text; return this; }
|
|
2012
|
+
/** Where files attached in the editor are stored (`FileUpload.storage` by default); without storage, attaching is off. */
|
|
2013
|
+
fileAttachmentsStorage(storage) { this._attachmentsStorage = storage; return this; }
|
|
2014
|
+
fileAttachmentsDirectory(directory) { this._attachmentsDirectory = directory; return this; }
|
|
2015
|
+
/** Accepted types of attached files, checked on the file's content (images only: they are inserted as images). */
|
|
2016
|
+
fileAttachmentsAcceptedFileTypes(types) { this._attachmentTypes = types.filter((type) => type.startsWith('image/') && type !== 'image/svg+xml'); return this; }
|
|
2017
|
+
/** Maximum size of attached files, in kilobytes. */
|
|
2018
|
+
fileAttachmentsMaxSize(kilobytes) { this._attachmentMaxSize = kilobytes; return this; }
|
|
2019
|
+
/** Text colors offered by the `textColor` tool (the Tailwind palette by default): `{ brand: TextColor.make('Brand', '#0ea5e9', '#38bdf8') }`. */
|
|
2020
|
+
textColors(colors) { this._textColors = colors; if (colors)
|
|
2021
|
+
registerTextColors(colors); return this; }
|
|
2022
|
+
getTextColors() { return this._textColors ?? TextColor.getDefaults(); }
|
|
2023
|
+
/** Lets users pick any color, besides the listed ones. */
|
|
2024
|
+
customTextColors(condition = true) { this._customTextColors = condition; return this; }
|
|
2025
|
+
/** Merge tags users insert (`mergeTags` tool and `{{` in the text), replaced by values when the content is rendered: `['name', 'today']` or `{ name: 'Name' }`. */
|
|
2026
|
+
mergeTags(tags) { this._mergeTags = Array.isArray(tags) ? Object.fromEntries(tags.map((tag) => [tag, tag])) : { ...tags }; return this; }
|
|
2027
|
+
noMergeTagSearchResultsMessage(message) { this._noMergeTagResults = message; return this; }
|
|
2028
|
+
/** Mentions: `[MentionProvider.make('@').items({ 1: 'Ada' })]`. */
|
|
2029
|
+
mentions(providers) { this._mentions = providers; return this; }
|
|
2030
|
+
/** Custom blocks (`customBlocks` tool), as a list or grouped by label. */
|
|
2031
|
+
customBlocks(blocks) { this._customBlocks = blocks; return this; }
|
|
2032
|
+
minimalCustomBlockControls(condition = true) { this._minimalCustomBlockControls = condition; return this; }
|
|
2033
|
+
/** Shows the blocks of the panel in a grid. */
|
|
2034
|
+
customBlocksGrid(condition = true) { this._customBlocksGrid = condition; return this; }
|
|
2035
|
+
/** Adds a search field to the blocks panel. */
|
|
2036
|
+
searchableCustomBlocks(condition = true) { this._searchableCustomBlocks = condition; return this; }
|
|
2037
|
+
/** Panel open when the editor loads (`customBlocks`, `mergeTags`). */
|
|
2038
|
+
activePanel(panel) { this._activePanel = panel; return this; }
|
|
2039
|
+
/** Keeps the toolbar visible while scrolling the content. */
|
|
2040
|
+
stickyToolbar(condition = true) { this._stickyToolbar = condition; return this; }
|
|
2041
|
+
/** Lets users resize images with handles; the size is saved on the image. */
|
|
2042
|
+
resizableImages(condition = true) { this._resizableImages = condition; return this; }
|
|
2043
|
+
attachmentsStorage() { return this._attachmentsStorage ?? FileUpload.storage; }
|
|
2044
|
+
findTool(name) {
|
|
2045
|
+
const custom = this._customTools.find((tool) => tool.getName() === name);
|
|
2046
|
+
if (custom)
|
|
2047
|
+
return custom;
|
|
2048
|
+
const definition = BUILT_IN_TOOLS.get(name);
|
|
2049
|
+
return definition ? RichEditorTool.fromDefinition(definition) : null;
|
|
2050
|
+
}
|
|
2051
|
+
toolGroups() { return (this._toolbar ?? RICH_EDITOR_TOOLS).map((group) => group.filter((tool) => !this._disabledTools.includes(tool) && (tool !== 'attachFiles' || this.attachmentsStorage() !== null))).filter((group) => group.length); }
|
|
2052
|
+
blockGroups() { return Array.isArray(this._customBlocks) ? [['', this._customBlocks]] : Object.entries(this._customBlocks); }
|
|
2053
|
+
getCustomBlock(id) { return this.blockGroups().flatMap(([, blocks]) => blocks).find((block) => block.getId() === id) ?? null; }
|
|
2054
|
+
hydrateState(value) {
|
|
2055
|
+
if (typeof value === 'string' && value.trim().startsWith('{')) {
|
|
2056
|
+
try {
|
|
2057
|
+
const parsed = JSON.parse(value);
|
|
2058
|
+
if (isRichDocument(parsed))
|
|
2059
|
+
return parsed;
|
|
2060
|
+
}
|
|
2061
|
+
catch { /* HTML */ }
|
|
2062
|
+
}
|
|
2063
|
+
return isRichDocument(value) ? value : richHtmlToDocument(typeof value === 'string' ? value : '');
|
|
2064
|
+
}
|
|
952
2065
|
getDefaultState() { return richHtmlToDocument(typeof this._default === 'string' ? this._default : ''); }
|
|
953
|
-
|
|
2066
|
+
/** HTML (sanitized by the editor's schema), or the JSON document with `json()` (normalized through the same schema). */
|
|
2067
|
+
dehydrateState(state) {
|
|
2068
|
+
const html = richDocumentToHtml(state);
|
|
2069
|
+
return this._json ? (html === null ? null : richHtmlToDocument(html)) : html;
|
|
2070
|
+
}
|
|
954
2071
|
async getValidationRules() {
|
|
955
2072
|
const rules = [];
|
|
956
2073
|
if (await this.isRequired())
|
|
@@ -958,15 +2075,63 @@ export class RichEditor extends Field {
|
|
|
958
2075
|
return [...rules, ...this._rules];
|
|
959
2076
|
}
|
|
960
2077
|
getExposedMethods() { return ['getMentionSearchResultsForJs', 'getMentionLabelsForJs', 'getCustomBlockPreviewsForJs', 'getUploadedFileAttachmentTemporaryUrl', 'saveUploadedFileAttachmentAndGetUrl']; }
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
2078
|
+
mentionProvider(char) { return this._mentions.find((provider) => provider.getTriggerCharacter() === char) ?? this._mentions[0] ?? null; }
|
|
2079
|
+
/** Mention search, for the editor's JavaScript: `[{ id, label }]`. */
|
|
2080
|
+
async getMentionSearchResultsForJs(search = '', triggerCharacter = '@') {
|
|
2081
|
+
const provider = this.mentionProvider(triggerCharacter ?? '@');
|
|
2082
|
+
if (!provider || (await this.isDisabled()))
|
|
2083
|
+
return [];
|
|
2084
|
+
return Object.entries(await provider.getSearchResults(typeof search === 'string' ? search.slice(0, 200) : '')).slice(0, 50).map(([id, label]) => ({ id, label }));
|
|
2085
|
+
}
|
|
2086
|
+
/** Current labels of mentioned ids, by trigger character. */
|
|
2087
|
+
async getMentionLabelsForJs(mentions = []) {
|
|
2088
|
+
const byChar = new Map();
|
|
2089
|
+
for (const mention of Array.isArray(mentions) ? mentions.slice(0, 500) : []) {
|
|
2090
|
+
const char = String(mention?.triggerCharacter ?? '@'), id = mention?.id;
|
|
2091
|
+
if (id === null || id === undefined)
|
|
2092
|
+
continue;
|
|
2093
|
+
byChar.set(char, [...(byChar.get(char) ?? []), String(id)]);
|
|
2094
|
+
}
|
|
2095
|
+
const labels = {};
|
|
2096
|
+
for (const [char, ids] of byChar) {
|
|
2097
|
+
const provider = this.mentionProvider(char);
|
|
2098
|
+
if (provider)
|
|
2099
|
+
labels[char] = await provider.getLabels(ids);
|
|
2100
|
+
}
|
|
2101
|
+
return labels;
|
|
2102
|
+
}
|
|
2103
|
+
/** Previews of inserted blocks, for the editor's JavaScript. */
|
|
2104
|
+
getCustomBlockPreviewsForJs(customBlocks = []) {
|
|
2105
|
+
const previews = [];
|
|
2106
|
+
for (const item of Array.isArray(customBlocks) ? customBlocks.slice(0, 200) : []) {
|
|
2107
|
+
const block = this.getCustomBlock(item?.id);
|
|
2108
|
+
const config = item?.config && typeof item.config === 'object' && !Array.isArray(item.config) ? item.config : {};
|
|
2109
|
+
if (!block || !Number.isInteger(item?.key))
|
|
2110
|
+
continue;
|
|
2111
|
+
previews.push({ key: item.key, label: block.getPreviewLabel(config), preview: Buffer.from(block.toPreviewHtml(config) ?? '').toString('base64'), shouldApplyProseStylingToPreview: block.shouldApplyProseStylingToPreview(config) });
|
|
2112
|
+
}
|
|
2113
|
+
return previews;
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* URL of a file the editor just uploaded: the file is checked (type from its content, size), stored, and its public URL returned.
|
|
2117
|
+
* It stays stored even if the form is not saved.
|
|
2118
|
+
*/
|
|
2119
|
+
async getUploadedFileAttachmentTemporaryUrl(args = {}) {
|
|
2120
|
+
const id = String(args?.attachment ?? '');
|
|
2121
|
+
const storage = this.attachmentsStorage();
|
|
2122
|
+
if (!storage || !/^[A-Za-z0-9_-]{1,64}$/.test(id) || !this.toolGroups().flat().includes('attachFiles') || (await this.isDisabled()))
|
|
2123
|
+
return null;
|
|
2124
|
+
return storeEditorAttachment(this, id, { storage, directory: this._attachmentsDirectory, types: this._attachmentTypes, maxSize: this._attachmentMaxSize });
|
|
2125
|
+
}
|
|
2126
|
+
async saveUploadedFileAttachmentAndGetUrl(args = {}) { return this.getUploadedFileAttachmentTemporaryUrl(args); }
|
|
2127
|
+
/** Sends commands to the editor in the browser (after an action), on the given selection. */
|
|
2128
|
+
runCommands(livewire, commands, editorSelection = null) {
|
|
2129
|
+
const key = this.getKey();
|
|
2130
|
+
livewire.dispatch('run-rich-editor-commands', { awaitSchemaComponent: key, livewireId: livewire.id, key, editorSelection, commands: commands.map((command) => ({ name: command.name, arguments: command.arguments ?? [] })) });
|
|
2131
|
+
}
|
|
2132
|
+
/** Toolbar actions: link, text color, custom block, and the ones of `editorActions()`. */
|
|
967
2133
|
getActions() {
|
|
968
2134
|
const tr = (name) => t(`filament-forms::components.rich_editor.actions.link.${name}`);
|
|
969
|
-
const key = this.getKey();
|
|
970
2135
|
return [
|
|
971
2136
|
...this.getHintActions(),
|
|
972
2137
|
Action.make('link').label(tr('label')).modalHeading(tr('modal.heading')).modalWidth('lg')
|
|
@@ -978,29 +2143,174 @@ export class RichEditor extends Field {
|
|
|
978
2143
|
Checkbox.make('shouldOpenInNewTab').label(tr('modal.form.should_open_in_new_tab.label')),
|
|
979
2144
|
])
|
|
980
2145
|
.action(({ livewire, arguments: args, data }) => {
|
|
981
|
-
const selection = (args
|
|
2146
|
+
const selection = selectionOf(args);
|
|
982
2147
|
const single = selection?.head === selection?.anchor;
|
|
983
2148
|
const url = typeof data.url === 'string' && data.url !== '' ? data.url : null;
|
|
984
|
-
|
|
2149
|
+
this.runCommands(livewire, [
|
|
985
2150
|
...(single ? [{ name: 'extendMarkRange', arguments: ['link'] }] : []),
|
|
986
|
-
url ? { name: 'setLink', arguments: [{ href: url, target: data.shouldOpenInNewTab ? '_blank' : null }] } : { name: 'unsetLink'
|
|
987
|
-
];
|
|
988
|
-
livewire.dispatch('run-rich-editor-commands', { awaitSchemaComponent: key, livewireId: livewire.id, key, editorSelection: selection, commands });
|
|
2151
|
+
url ? { name: 'setLink', arguments: [{ href: url, target: data.shouldOpenInNewTab ? '_blank' : null }] } : { name: 'unsetLink' },
|
|
2152
|
+
], selection);
|
|
989
2153
|
}),
|
|
2154
|
+
this.textColorAction(),
|
|
2155
|
+
this.customBlockAction(),
|
|
2156
|
+
...this._editorActions,
|
|
990
2157
|
];
|
|
991
2158
|
}
|
|
2159
|
+
textColorAction() {
|
|
2160
|
+
const tr = (name) => t(`filament-forms::components.rich_editor.actions.text_color.${name}`);
|
|
2161
|
+
return Action.make('textColor').label(tr('label')).modalHeading(tr('modal.heading')).modalWidth('lg')
|
|
2162
|
+
.fillForm(({ arguments: args }) => {
|
|
2163
|
+
const color = typeof args.color === 'string' ? args.color : null;
|
|
2164
|
+
if (!color)
|
|
2165
|
+
return {};
|
|
2166
|
+
const known = Object.hasOwn(this.getTextColors(), color);
|
|
2167
|
+
return { color: known ? color : null, customColor: this._customTextColors && !known ? color : null };
|
|
2168
|
+
})
|
|
2169
|
+
.schema(() => {
|
|
2170
|
+
const options = Object.fromEntries(Object.entries(this.getTextColors()).map(([name, color]) => [name, `<div class="fi-fo-rich-editor-text-color-select-option"><div class="fi-fo-rich-editor-text-color-select-option-preview" style="--color: ${e(sanitizeCssColor(color.getColor()) ?? '')}; --dark-color: ${e(sanitizeCssColor(color.getDarkColor()) ?? '')}"></div><div>${e(color.getLabel() ?? name)}</div></div>`]));
|
|
2171
|
+
return [
|
|
2172
|
+
...(Object.keys(options).length ? [Select.make('color').label(tr('modal.form.color.label')).options(options).allowHtml().native(false).live()] : []),
|
|
2173
|
+
...(this._customTextColors ? [ColorPicker.make('customColor').label(tr('modal.form.custom_color.label')).visible(({ get }) => !get('color'))] : []),
|
|
2174
|
+
];
|
|
2175
|
+
})
|
|
2176
|
+
.action(({ livewire, arguments: args, data }) => {
|
|
2177
|
+
const selection = selectionOf(args);
|
|
2178
|
+
const single = selection?.head === selection?.anchor;
|
|
2179
|
+
const picked = typeof data.color === 'string' && Object.hasOwn(this.getTextColors(), data.color) ? data.color : this._customTextColors ? sanitizeCssColor(data.customColor) : null;
|
|
2180
|
+
this.runCommands(livewire, picked
|
|
2181
|
+
? [...(single ? [{ name: 'extendMarkRange', arguments: ['textColor'] }] : []), { name: 'setTextColor', arguments: [{ color: picked }] }]
|
|
2182
|
+
: [...(single ? [{ name: 'extendMarkRange', arguments: ['textColor'] }] : []), { name: 'unsetTextColor' }], selection);
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
/** Modal of a custom block, built from the block class named in the arguments (`id`): insert or edit. */
|
|
2186
|
+
customBlockAction() {
|
|
2187
|
+
const blockOf = (args) => this.getCustomBlock(args?.id);
|
|
2188
|
+
const configured = (args) => { const block = blockOf(args); return block ? block.configureEditorAction(Action.make('customBlock')) : null; };
|
|
2189
|
+
const argumentsOf = (livewire) => livewire?.mountedActions?.find?.((mounted) => mounted.name === 'customBlock')?.arguments ?? {};
|
|
2190
|
+
return Action.make('customBlock').modalWidth('lg')
|
|
2191
|
+
.modalHeading(({ arguments: args }) => blockOf(args)?.getLabel() ?? null)
|
|
2192
|
+
.modalSubmitActionLabel(({ arguments: args }) => t(`filament-forms::components.rich_editor.actions.custom_block.modal.actions.${args.mode === 'edit' ? 'save' : 'insert'}.label`))
|
|
2193
|
+
.modalHidden(async (ctx) => { const action = configured(ctx.arguments); return !action || (await action.isModalHidden(ctx)); })
|
|
2194
|
+
.fillForm(({ arguments: args }) => (args.config && typeof args.config === 'object' && !Array.isArray(args.config) ? args.config : {}))
|
|
2195
|
+
.schema((schema) => configured(argumentsOf(schema.livewire))?.applySchema(schema) ?? schema.components([]))
|
|
2196
|
+
.action(({ livewire, arguments: args, data }) => {
|
|
2197
|
+
const block = blockOf(args);
|
|
2198
|
+
if (!block)
|
|
2199
|
+
return;
|
|
2200
|
+
const config = data ?? {};
|
|
2201
|
+
const content = { type: 'customBlock', attrs: { config, id: block.getId(), label: block.getPreviewLabel(config), preview: Buffer.from(block.toPreviewHtml(config) ?? '').toString('base64'), shouldApplyProseStylingToPreview: block.shouldApplyProseStylingToPreview(config) } };
|
|
2202
|
+
const selection = { ...(selectionOf(args) ?? {}) };
|
|
2203
|
+
if (args.dragPosition !== undefined && args.dragPosition !== null) {
|
|
2204
|
+
this.runCommands(livewire, [{ name: 'insertContentAt', arguments: [args.dragPosition, content] }]);
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
if (selection.type === 'node' && args.mode === 'insert') {
|
|
2208
|
+
this.runCommands(livewire, [{ name: 'insertContentAt', arguments: [Number(selection.anchor ?? -1) + 1, content] }]);
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
if (args.mode === 'edit' && selection.type !== 'node') {
|
|
2212
|
+
selection.type = 'node';
|
|
2213
|
+
selection.anchor = Number(selection.anchor ?? 1) - 1;
|
|
2214
|
+
delete selection.head;
|
|
2215
|
+
}
|
|
2216
|
+
this.runCommands(livewire, [{ name: 'insertContent', arguments: [content] }], selection);
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
customBlocksPanelHtml(key) {
|
|
2220
|
+
const searchable = this._searchableCustomBlocks;
|
|
2221
|
+
const groups = this.blockGroups();
|
|
2222
|
+
const allLabels = groups.flatMap(([label, blocks]) => [label, ...blocks.map((block) => block.getLabel())]).filter(Boolean);
|
|
2223
|
+
const parts = [];
|
|
2224
|
+
if (searchable)
|
|
2225
|
+
parts.push(`<div class="fi-fo-rich-editor-custom-blocks-search">${icon('m-magnifying-glass')}<input type="search" x-model="customBlockSearch" x-on:keydown.enter.prevent aria-label="${e(t('filament-forms::components.rich_editor.custom_blocks.search_label'))}" placeholder="${e(t('filament-forms::components.rich_editor.custom_blocks.search_prompt'))}" class="fi-fo-rich-editor-custom-blocks-search-input" /></div>`);
|
|
2226
|
+
const list = groups.map(([groupLabel, blocks]) => {
|
|
2227
|
+
const groupLabels = [groupLabel, ...blocks.map((block) => block.getLabel())];
|
|
2228
|
+
const show = (labels) => (searchable ? ` x-show="${e(`matchesCustomBlockSearch(${js(labels)})`)}"` : '');
|
|
2229
|
+
const header = groupLabel ? `<h4${show(groupLabels)} class="fi-fo-rich-editor-custom-blocks-group-header">${e(groupLabel)}</h4>` : '';
|
|
2230
|
+
const buttons = blocks.map((block) => {
|
|
2231
|
+
const id = block.getId();
|
|
2232
|
+
const blockIcon = block.getIcon();
|
|
2233
|
+
return `<button draggable="true" type="button" data-block-id="${e(id)}" x-data="{ isLoading: false }"${show([groupLabel, block.getLabel()])} x-on:click="${e(`isLoading = true\n$wire.mountAction('customBlock', { editorSelection, id: ${js(id)}, mode: 'insert' }, { schemaComponent: ${js(key)} })`)}" x-on:dragstart="${e(`$event.dataTransfer.setData('customBlock', ${js(id)})`)}" x-on:open-modal.window="isLoading = false" x-on:run-rich-editor-commands.window="isLoading = false" class="fi-fo-rich-editor-custom-block-btn">${blockIcon ? `<span x-show="! isLoading" class="fi-fo-rich-editor-custom-block-icon">${icon(blockIcon)}</span>` : ''}${loadingIndicator(new Attributes({ 'x-show': 'isLoading', 'x-cloak': '' }))}<span>${e(block.getLabel())}</span></button>`;
|
|
2234
|
+
}).join('');
|
|
2235
|
+
return `${header}<div${show(groupLabels)} class="${cls(['fi-fo-rich-editor-custom-blocks-list', { 'fi-fo-rich-editor-custom-blocks-grid': this._customBlocksGrid }])}">${buttons}</div>`;
|
|
2236
|
+
}).join('');
|
|
2237
|
+
const noResults = searchable ? `<p x-show="${e(`! matchesCustomBlockSearch(${js(allLabels)})`)}" x-cloak role="status" class="fi-fo-rich-editor-custom-blocks-no-results">${e(t('filament-forms::components.rich_editor.custom_blocks.no_search_results_message'))}</p>` : '';
|
|
2238
|
+
return `${parts.join('')}<div class="fi-fo-rich-editor-custom-blocks-ctn">${list}${noResults}</div>`;
|
|
2239
|
+
}
|
|
2240
|
+
mergeTagsPanelHtml() {
|
|
2241
|
+
return Object.entries(this._mergeTags).map(([id, label]) => `<button draggable="true" type="button" x-on:click="${e(`insertMergeTag(${js(id)})`)}" x-on:dragstart="${e(`$event.dataTransfer.setData('mergeTag', ${js(id)})`)}" class="fi-fo-rich-editor-merge-tag-btn"><span data-type="mergeTag" data-id="${e(id)}">${e(label)}</span></button>`).join('');
|
|
2242
|
+
}
|
|
992
2243
|
async toHtml() {
|
|
993
|
-
|
|
2244
|
+
const key = this.getKey();
|
|
2245
|
+
let html = views.render('filament-forms::components.rich-editor', { livewireId: this.livewire.id, key, statePath: this.getStatePath(), label: await this.getLabel(), js });
|
|
2246
|
+
// Toolbar: the configured groups, each tool rendered from its definition.
|
|
2247
|
+
const toolbarStart = html.indexOf('data-rich-editor-toolbar="main"');
|
|
2248
|
+
if (toolbarStart >= 0) {
|
|
2249
|
+
const open = html.indexOf('>', toolbarStart) + 1;
|
|
2250
|
+
// The toolbar ends right before the "uploading file" message.
|
|
2251
|
+
const message = html.indexOf('x-show="isUploadingFile"', open);
|
|
2252
|
+
const close = message < 0 ? -1 : html.lastIndexOf('<div', message);
|
|
2253
|
+
if (close < 0)
|
|
2254
|
+
throw new Error('RichEditor: unexpected view markup (toolbar end not found).');
|
|
2255
|
+
const body = html.slice(open, close);
|
|
2256
|
+
const groups = this.toolGroups().map((group) => `<div class="fi-fo-rich-editor-toolbar-group">${group.map((tool) => this.findTool(tool)?.toHtml() ?? '').join('')}</div>`).join('');
|
|
2257
|
+
const endOfToolbar = body.lastIndexOf('</div>');
|
|
2258
|
+
html = html.slice(0, open) + groups + body.slice(endOfToolbar) + html.slice(close);
|
|
2259
|
+
if (this._stickyToolbar)
|
|
2260
|
+
html = html.replace('class="fi-fo-rich-editor-toolbar "', 'class="fi-fo-rich-editor-toolbar fi-fo-rich-editor-sticky-toolbar"');
|
|
2261
|
+
}
|
|
2262
|
+
// Panels: custom blocks and merge tags.
|
|
2263
|
+
const blocksStart = html.indexOf('<div class="fi-fo-rich-editor-custom-blocks-ctn">');
|
|
2264
|
+
if (blocksStart >= 0) {
|
|
2265
|
+
const blocksEnd = html.indexOf('</div>', blocksStart) + '</div>'.length;
|
|
2266
|
+
html = html.slice(0, blocksStart) + this.customBlocksPanelHtml(key) + html.slice(blocksEnd);
|
|
2267
|
+
}
|
|
2268
|
+
const tagsStart = html.indexOf('<div class="fi-fo-rich-editor-merge-tags-list">');
|
|
2269
|
+
if (tagsStart >= 0) {
|
|
2270
|
+
const open = html.indexOf('>', tagsStart) + 1;
|
|
2271
|
+
html = html.slice(0, open) + this.mergeTagsPanelHtml() + html.slice(html.indexOf('</div>', open));
|
|
2272
|
+
}
|
|
2273
|
+
const attach = this.toolGroups().flat().includes('attachFiles');
|
|
2274
|
+
const mentions = await Promise.all(this._mentions.map((provider) => provider.toJs()));
|
|
2275
|
+
html = html.replace('canAttachFiles: true,', `canAttachFiles: ${attach ? 'true' : 'false'},`)
|
|
2276
|
+
.replace('maxFileSize: 12288,', `maxFileSize: ${this._attachmentMaxSize},`)
|
|
2277
|
+
.replace(/maxFileSizeValidationMessage: '[^']*',/, () => `maxFileSizeValidationMessage: ${js(`Uploaded files must not be greater than ${this._attachmentMaxSize} kilobytes.`)},`)
|
|
2278
|
+
.replace(/acceptedFileTypes: JSON\.parse\('[^']*'\),/, () => `acceptedFileTypes: ${js(this._attachmentTypes)},`)
|
|
2279
|
+
.replace(/acceptedFileTypesValidationMessage: '[^']*',/, () => `acceptedFileTypesValidationMessage: ${js(`Uploaded files must be of type: ${this._attachmentTypes.join(', ')}.`)},`)
|
|
2280
|
+
.replace('activePanel: null,', () => `activePanel: ${js(this._activePanel)},`)
|
|
2281
|
+
.replace('extensions: [],', () => `extensions: ${js(this._jsExtensions)},`)
|
|
2282
|
+
.replace('hasResizableImages: false,', `hasResizableImages: ${this._resizableImages ? 'true' : 'false'},`)
|
|
2283
|
+
.replace('hasMinimalCustomBlockControls: false,', `hasMinimalCustomBlockControls: ${this._minimalCustomBlockControls ? 'true' : 'false'},`)
|
|
2284
|
+
.replace('hasStickyToolbar: false,', `hasStickyToolbar: ${this._stickyToolbar ? 'true' : 'false'},`)
|
|
2285
|
+
.replace('mentions: [],', () => `mentions: ${js(mentions)},`)
|
|
2286
|
+
.replace('mergeTags: [],', () => `mergeTags: ${js(Object.keys(this._mergeTags).length ? this._mergeTags : [])},`)
|
|
2287
|
+
.replace(/noMergeTagSearchResultsMessage: '[^']*',/, () => `noMergeTagSearchResultsMessage: ${js(this._noMergeTagResults ?? t('filament-forms::components.rich_editor.no_merge_tag_search_results_message'))},`)
|
|
2288
|
+
.replace(/textColors: JSON\.parse\('[^']*'\),/, () => `textColors: ${js(textColorsForJs(this.getTextColors()))},`)
|
|
2289
|
+
.replace('placeholder: null,', `placeholder: ${js(this._richPlaceholder)},`)
|
|
2290
|
+
.replace('isLiveOnBlur: false,', `isLiveOnBlur: ${this._liveMode === 'blur' ? 'true' : 'false'},`)
|
|
2291
|
+
.replace(/state: \$wire\.\$entangle\(('[^']*'), false\)/, (_m, path) => `state: $wire.$entangle(${path}, ${this._liveMode === 'live' ? 'true' : 'false'})`);
|
|
2292
|
+
return this.wrap(html, { labelAsDiv: true });
|
|
994
2293
|
}
|
|
995
2294
|
}
|
|
996
2295
|
/** Color picker (hex format). */
|
|
997
2296
|
export class ColorPicker extends Field {
|
|
998
2297
|
static make(name) { return configured(new ColorPicker(name)); }
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
2298
|
+
_format = 'hex';
|
|
2299
|
+
/** Color format of the state: `#ff0000` (default), `rgb(255, 0, 0)`, `rgba(255, 0, 0, 1)` or `hsl(0, 100%, 50%)`. */
|
|
2300
|
+
format(format) { this._format = format; return this; }
|
|
2301
|
+
hex() { return this.format('hex'); }
|
|
2302
|
+
rgb() { return this.format('rgb'); }
|
|
2303
|
+
rgba() { return this.format('rgba'); }
|
|
2304
|
+
hsl() { return this.format('hsl'); }
|
|
2305
|
+
async getValidationRules() {
|
|
2306
|
+
const patterns = {
|
|
2307
|
+
hex: /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/,
|
|
2308
|
+
rgb: /^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$/,
|
|
2309
|
+
rgba: /^rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0|1|0?\.\d+)\s*\)$/,
|
|
2310
|
+
hsl: /^hsl\(\s*\d{1,3}(\.\d+)?\s*,\s*\d{1,3}(\.\d+)?%\s*,\s*\d{1,3}(\.\d+)?%\s*\)$/,
|
|
2311
|
+
};
|
|
2312
|
+
const pattern = patterns[this._format];
|
|
2313
|
+
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
2314
|
}
|
|
1005
2315
|
async toHtml() {
|
|
1006
2316
|
const isDisabled = await this.isDisabled();
|
|
@@ -1024,17 +2334,20 @@ export class ColorPicker extends Field {
|
|
|
1024
2334
|
state: $wire.$entangle(${e(js(this.getStatePath()))}),
|
|
1025
2335
|
})" 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
2336
|
'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"
|
|
2337
|
+
}" 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
2338
|
}
|
|
1029
2339
|
}
|
|
1030
2340
|
export class Checkbox extends Field {
|
|
2341
|
+
_inline = true;
|
|
1031
2342
|
static make(name) { return configured(new Checkbox(name)); }
|
|
1032
2343
|
getDefaultState() { return this._default ?? false; }
|
|
2344
|
+
/** `false`: the label goes above the checkbox instead of beside it. */
|
|
2345
|
+
inline(condition = true) { this._inline = condition; return this; }
|
|
1033
2346
|
async toHtml() {
|
|
1034
2347
|
const input = new Attributes()
|
|
1035
|
-
.merge({ type: 'checkbox', disabled: await this.isDisabled(), id: this.getId(), required: await this.isRequired(),
|
|
2348
|
+
.merge({ type: 'checkbox', disabled: await this.isDisabled(), id: this.getId(), required: await this.isRequired(), [this.wireModel()]: this.getStatePath() })
|
|
1036
2349
|
.class(['fi-checkbox-input', this.hasError() ? 'fi-invalid' : 'fi-valid']);
|
|
1037
|
-
return this.wrap('', { labelPrefix: `<input ${input.toHtml()} />` });
|
|
2350
|
+
return this._inline ? this.wrap('', { labelPrefix: `<input ${input.toHtml()} />` }) : this.wrap(`<input ${input.toHtml()} />`);
|
|
1038
2351
|
}
|
|
1039
2352
|
}
|
|
1040
2353
|
/** Checkboxes for a list of values (no search or "select all" yet). */
|
|
@@ -1045,6 +2358,23 @@ export class CheckboxList extends Field {
|
|
|
1045
2358
|
options(options) { this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]); return this; }
|
|
1046
2359
|
/** Number of columns the options are spread over (from `lg`). */
|
|
1047
2360
|
columns(columns) { this._optionColumns = columns; return this; }
|
|
2361
|
+
_descriptions = {};
|
|
2362
|
+
_searchable = false;
|
|
2363
|
+
_bulkToggleable = false;
|
|
2364
|
+
_disableOptionWhen = null;
|
|
2365
|
+
/** Helper text per value, under the option label. */
|
|
2366
|
+
descriptions(descriptions) { this._descriptions = descriptions; return this; }
|
|
2367
|
+
/** A search field filters the options in the browser. */
|
|
2368
|
+
searchable(condition = true) { this._searchable = condition; return this; }
|
|
2369
|
+
_searchPrompt = 'Search';
|
|
2370
|
+
_noSearchResultsMessage = 'No search results.';
|
|
2371
|
+
searchPrompt(prompt) { this._searchPrompt = prompt; return this; }
|
|
2372
|
+
noSearchResultsMessage(message) { this._noSearchResultsMessage = message; return this; }
|
|
2373
|
+
/** "Select all" / "Deselect all" links. */
|
|
2374
|
+
bulkToggleable(condition = true) { this._bulkToggleable = condition; return this; }
|
|
2375
|
+
/** Options that cannot be checked; they are refused on the server too. */
|
|
2376
|
+
disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
|
|
2377
|
+
isOptionDisabled(value, label) { return this._disableOptionWhen ? this._disableOptionWhen(value, label) : false; }
|
|
1048
2378
|
getDefaultState() { return this._default ?? []; }
|
|
1049
2379
|
hydrateState(value) {
|
|
1050
2380
|
let list = value;
|
|
@@ -1064,21 +2394,28 @@ export class CheckboxList extends Field {
|
|
|
1064
2394
|
return this._options.map(([value]) => value).filter((value) => chosen.has(value));
|
|
1065
2395
|
}
|
|
1066
2396
|
async getValidationRules() {
|
|
1067
|
-
|
|
2397
|
+
// A disabled option cannot be checked, even by a forged request.
|
|
2398
|
+
const known = new Set(this._options.filter(([value, label]) => !this.isOptionDisabled(value, label)).map(([value]) => value));
|
|
1068
2399
|
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
2400
|
}
|
|
1070
2401
|
async toHtml() {
|
|
1071
2402
|
const isDisabled = await this.isDisabled();
|
|
1072
2403
|
const key = this.getLivewireKey() ?? '';
|
|
1073
2404
|
const options = this._options.map(([value, label]) => {
|
|
1074
|
-
const input = new Attributes().merge({ disabled: isDisabled, type: 'checkbox', value, '
|
|
1075
|
-
|
|
2405
|
+
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']);
|
|
2406
|
+
const description = this._descriptions[value] ? `<p class="fi-fo-checkbox-list-option-description">${e(this._descriptions[value])}</p>` : '';
|
|
2407
|
+
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
2408
|
});
|
|
1077
2409
|
const columns = this._optionColumns ?? 1;
|
|
1078
2410
|
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;"';
|
|
2411
|
+
const searchPrompt = this._searchPrompt;
|
|
2412
|
+
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>` : '';
|
|
2413
|
+
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>`;
|
|
2414
|
+
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>` : '';
|
|
2415
|
+
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
2416
|
return this.wrap(`<div role="group" x-load x-load-src="${e(asset('checkbox-list'))}" x-data="checkboxListFormComponent({
|
|
1080
2417
|
livewireId: ${e(js(this.livewire.id))},
|
|
1081
|
-
})" aria-labelledby="${e(this.getId())}-label" class="fi-fo-checkbox-list"
|
|
2418
|
+
})" 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
2419
|
}
|
|
1083
2420
|
}
|
|
1084
2421
|
/** Free-form key/value pairs, saved as an object. */
|
|
@@ -1090,6 +2427,26 @@ export class KeyValue extends Field {
|
|
|
1090
2427
|
keyLabel(label) { this._keyLabel = label; return this; }
|
|
1091
2428
|
valueLabel(label) { this._valueLabel = label; return this; }
|
|
1092
2429
|
reorderable(condition = true) { this._reorderable = condition; return this; }
|
|
2430
|
+
_addable = true;
|
|
2431
|
+
_deletable = true;
|
|
2432
|
+
_editableKeys = true;
|
|
2433
|
+
_editableValues = true;
|
|
2434
|
+
_keyPlaceholder = null;
|
|
2435
|
+
_valuePlaceholder = null;
|
|
2436
|
+
_addLabel = null;
|
|
2437
|
+
_deleteLabel = null;
|
|
2438
|
+
/** `false`: no "Add row" button. */
|
|
2439
|
+
addable(condition = true) { this._addable = condition; return this; }
|
|
2440
|
+
/** `false`: rows cannot be deleted. */
|
|
2441
|
+
deletable(condition = true) { this._deletable = condition; return this; }
|
|
2442
|
+
/** `false`: keys cannot be changed. The server keeps the saved keys. */
|
|
2443
|
+
editableKeys(condition = true) { this._editableKeys = condition; return this; }
|
|
2444
|
+
/** `false`: values cannot be changed. */
|
|
2445
|
+
editableValues(condition = true) { this._editableValues = condition; return this; }
|
|
2446
|
+
keyPlaceholder(text) { this._keyPlaceholder = text; return this; }
|
|
2447
|
+
valuePlaceholder(text) { this._valuePlaceholder = text; return this; }
|
|
2448
|
+
addActionLabel(label) { this._addLabel = label; return this; }
|
|
2449
|
+
deleteActionLabel(label) { this._deleteLabel = label; return this; }
|
|
1093
2450
|
// In the form, the state is a list of `{ key, value }` pairs (what the browser component expects, and it keeps order);
|
|
1094
2451
|
// it becomes an object again when saved.
|
|
1095
2452
|
getDefaultState() { return KeyValue.toPairs(this._default); }
|
|
@@ -1105,10 +2462,29 @@ export class KeyValue extends Field {
|
|
|
1105
2462
|
}
|
|
1106
2463
|
return object && typeof object === 'object' && !Array.isArray(object) ? Object.entries(object).map(([key, v]) => ({ key, value: v })) : [];
|
|
1107
2464
|
}
|
|
1108
|
-
|
|
2465
|
+
_originalValue = null;
|
|
2466
|
+
hydrateState(value) { this._originalValue = value; return KeyValue.toPairs(value); }
|
|
1109
2467
|
/** Flat object of strings: empty keys, nested objects and dangerous keys are dropped; 200 pairs at most. */
|
|
1110
2468
|
dehydrateState(state) {
|
|
1111
2469
|
const out = {};
|
|
2470
|
+
// Restrictions set on the field are enforced on the server: a forged state cannot add, delete or edit what the UI forbids.
|
|
2471
|
+
// The saved value: the one hydrated by this instance, else the record's (fields are rebuilt on every request), else the default.
|
|
2472
|
+
const record = this.context().record;
|
|
2473
|
+
const saved = KeyValue.toPairs(this._originalValue ?? (record && typeof record === 'object' ? record[this._name] : null) ?? this._default);
|
|
2474
|
+
if (!this._addable || !this._deletable || !this._editableKeys || !this._editableValues) {
|
|
2475
|
+
const received = Array.isArray(state) ? state.filter((pair) => pair && typeof pair === 'object' && typeof pair.key === 'string') : [];
|
|
2476
|
+
const byKey = new Map(received.map((pair) => [String(pair.key).trim(), pair]));
|
|
2477
|
+
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 }));
|
|
2478
|
+
if (!this._editableValues)
|
|
2479
|
+
rows = rows.map((row) => ({ ...row, value: saved.find((pair) => pair.key === row.key)?.value ?? null }));
|
|
2480
|
+
if (!this._addable)
|
|
2481
|
+
rows = rows.filter((row) => saved.some((pair) => pair.key === row.key));
|
|
2482
|
+
if (!this._deletable)
|
|
2483
|
+
for (const pair of saved)
|
|
2484
|
+
if (!rows.some((row) => row.key === pair.key))
|
|
2485
|
+
rows.push({ key: pair.key, value: byKey.get(pair.key)?.value ?? pair.value });
|
|
2486
|
+
state = rows;
|
|
2487
|
+
}
|
|
1112
2488
|
if (!Array.isArray(state))
|
|
1113
2489
|
return out;
|
|
1114
2490
|
for (const pair of state.slice(0, 200)) {
|
|
@@ -1134,7 +2510,7 @@ export class KeyValue extends Field {
|
|
|
1134
2510
|
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
2511
|
state: $wire.$entangle(${e(js(this.getStatePath()))}, false),
|
|
1136
2512
|
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
|
|
2513
|
+
})" 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
2514
|
}
|
|
1139
2515
|
}
|
|
1140
2516
|
/** Developer-written JavaScript expression (e.g. a Slider tooltip formatter), inserted as is: never put user data in it. */
|
|
@@ -1153,10 +2529,10 @@ function accessibilityOf(field, options = {}) {
|
|
|
1153
2529
|
}
|
|
1154
2530
|
/** Second `$entangle()` argument: true only for a plain `live()` field (no `onBlur`, no `debounce`). */
|
|
1155
2531
|
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']];
|
|
2532
|
+
const MARKDOWN_TOOLBAR = [['bold', 'italic', 'strike', 'link'], ['heading'], ['blockquote', 'codeBlock', 'bulletList', 'orderedList'], ['table', 'attachFiles'], ['undo', 'redo']];
|
|
1157
2533
|
/**
|
|
1158
2534
|
* Markdown editor (EasyMDE). The state is raw Markdown text, to sanitize before any HTML display.
|
|
1159
|
-
*
|
|
2535
|
+
* Files can be attached (images, stored on upload) when a storage is set (`fileAttachmentsStorage()` or `FileUpload.storage`); when read-only (`disabled()`),
|
|
1160
2536
|
* the text is shown escaped, without Markdown conversion.
|
|
1161
2537
|
*/
|
|
1162
2538
|
export class MarkdownEditor extends Field {
|
|
@@ -1191,9 +2567,31 @@ export class MarkdownEditor extends Field {
|
|
|
1191
2567
|
async getValidationRules() {
|
|
1192
2568
|
return [...(await super.getValidationRules()), { name: 'string', passes: (v) => typeof v === 'string', message: (a) => `The ${a} field must be a string.` }];
|
|
1193
2569
|
}
|
|
2570
|
+
_attachmentsStorage = null;
|
|
2571
|
+
_attachmentsDirectory = null;
|
|
2572
|
+
_attachmentTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
2573
|
+
_attachmentMaxSize = 12288;
|
|
2574
|
+
/** Where attached files are stored (`FileUpload.storage` by default); without storage, the "Attach files" button is hidden. */
|
|
2575
|
+
fileAttachmentsStorage(storage) { this._attachmentsStorage = storage; return this; }
|
|
2576
|
+
fileAttachmentsDirectory(directory) { this._attachmentsDirectory = directory; return this; }
|
|
2577
|
+
/** Accepted types of attached files (images), checked on the file's content. */
|
|
2578
|
+
fileAttachmentsAcceptedFileTypes(types) { this._attachmentTypes = types.filter((type) => type.startsWith('image/') && type !== 'image/svg+xml'); return this; }
|
|
2579
|
+
/** Maximum size of attached files, in kilobytes. */
|
|
2580
|
+
fileAttachmentsMaxSize(kilobytes) { this._attachmentMaxSize = kilobytes; return this; }
|
|
2581
|
+
canAttachFiles() { return this.hasToolbarButton('attachFiles') && (this._attachmentsStorage ?? FileUpload.storage) !== null; }
|
|
2582
|
+
visibleToolbar() {
|
|
2583
|
+
if (this.canAttachFiles())
|
|
2584
|
+
return this._toolbarButtons;
|
|
2585
|
+
return this._toolbarButtons.map((group) => (Array.isArray(group) ? group.filter((name) => name !== 'attachFiles') : group)).filter((group) => (Array.isArray(group) ? group.length > 0 : group !== 'attachFiles'));
|
|
2586
|
+
}
|
|
1194
2587
|
getExposedMethods() { return ['getUploadedFileAttachmentTemporaryUrl', 'saveUploadedFileAttachmentAndGetUrl']; }
|
|
1195
2588
|
getUploadedFileAttachmentTemporaryUrl() { return null; }
|
|
1196
|
-
|
|
2589
|
+
/** Stores the file just uploaded to `componentFileAttachments.<state path>` and returns its URL. */
|
|
2590
|
+
async saveUploadedFileAttachmentAndGetUrl() {
|
|
2591
|
+
if (!this.canAttachFiles() || (await this.isDisabled()))
|
|
2592
|
+
return null;
|
|
2593
|
+
return storeEditorAttachment(this, null, { storage: this._attachmentsStorage ?? FileUpload.storage, directory: this._attachmentsDirectory, types: this._attachmentTypes, maxSize: this._attachmentMaxSize });
|
|
2594
|
+
}
|
|
1197
2595
|
async toHtml() {
|
|
1198
2596
|
const id = this.getId();
|
|
1199
2597
|
const accessibility = accessibilityOf(this);
|
|
@@ -1214,7 +2612,7 @@ export class MarkdownEditor extends Field {
|
|
|
1214
2612
|
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
2613
|
};
|
|
1216
2614
|
const config = `markdownEditorFormComponent({
|
|
1217
|
-
canAttachFiles: false,
|
|
2615
|
+
canAttachFiles: ${this.canAttachFiles() ? 'true' : 'false'},
|
|
1218
2616
|
isLiveDebounced: ${this._live?.debounce !== null && this._live?.debounce !== undefined ? 'true' : 'false'},
|
|
1219
2617
|
isLiveOnBlur: ${this._live?.onBlur ? 'true' : 'false'},
|
|
1220
2618
|
label: ${js(await this.getLabel())},
|
|
@@ -1223,10 +2621,26 @@ export class MarkdownEditor extends Field {
|
|
|
1223
2621
|
minHeight: ${js(this._minHeight)},
|
|
1224
2622
|
placeholder: ${js(this._placeholder)},
|
|
1225
2623
|
state: $wire.$entangle(${js(this.getStatePath())}, ${entangleLive(this._live)}),
|
|
1226
|
-
toolbarButtons: ${js(this.
|
|
2624
|
+
toolbarButtons: ${js(this.visibleToolbar())},
|
|
1227
2625
|
translations: ${js(translations)},
|
|
1228
2626
|
uploadFileAttachmentUsing: async (file, onSuccess, onError) => {
|
|
2627
|
+
const acceptedTypes = ${js(this._attachmentTypes)}
|
|
2628
|
+
if (acceptedTypes && ! acceptedTypes.includes(file.type)) {
|
|
2629
|
+
return onError(${js(tr('file_attachments_accepted_file_types_message').replace(':values', this._attachmentTypes.join(', ')))})
|
|
2630
|
+
}
|
|
2631
|
+
const maxSize = ${js(this._attachmentMaxSize)}
|
|
2632
|
+
if (maxSize && file.size > +maxSize * 1024) {
|
|
2633
|
+
return onError(${js(tr('file_attachments_max_size_message').replace(':max', String(this._attachmentMaxSize)))})
|
|
2634
|
+
}
|
|
2635
|
+
$wire.upload(${js(`componentFileAttachments.${this.getStatePath()}`)}, file, () => {
|
|
2636
|
+
$saveUploadedFileAttachmentAndGetUrl()
|
|
2637
|
+
.then((url) => {
|
|
2638
|
+
if (! url) {
|
|
1229
2639
|
return onError()
|
|
2640
|
+
}
|
|
2641
|
+
onSuccess(url)
|
|
2642
|
+
})
|
|
2643
|
+
})
|
|
1230
2644
|
},
|
|
1231
2645
|
})`;
|
|
1232
2646
|
const wrapper = new Attributes().class(['fi-fo-markdown-editor', 'fi-input-wrp', { 'fi-invalid': this.hasError() }]);
|
|
@@ -1508,6 +2922,26 @@ export function fillSchemaFromRecord(schema, target, record) {
|
|
|
1508
2922
|
writePath(target, segments, c.hydrateState(readPath(record, segments) ?? c.getDefaultState()));
|
|
1509
2923
|
}
|
|
1510
2924
|
}
|
|
2925
|
+
/** Fills the fields that load their state from the record's relationships (`loadStateFromRelationshipsUsing()`, `Select::relationship()`…). */
|
|
2926
|
+
export async function loadSchemaRelationships(schema, target, record) {
|
|
2927
|
+
if (!record)
|
|
2928
|
+
return;
|
|
2929
|
+
for (const c of schema.getFlatComponents()) {
|
|
2930
|
+
if (!(c instanceof Field))
|
|
2931
|
+
continue;
|
|
2932
|
+
const loaded = await c.loadStateFromRelationships(record);
|
|
2933
|
+
if (loaded)
|
|
2934
|
+
writePath(target, relativePath(schema, c), c.hydrateState(loaded.state));
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
/** Saves the visible fields that write the record's relationships, once the record is created or updated. */
|
|
2938
|
+
export async function saveSchemaRelationships(schema, record) {
|
|
2939
|
+
if (!record)
|
|
2940
|
+
return;
|
|
2941
|
+
for (const c of await schema.getVisibleFlatComponents())
|
|
2942
|
+
if (c instanceof Field && (await c.isDehydrated() || c._dehydrated === false) && (await c.savesRelationships()))
|
|
2943
|
+
await c.saveRelationships(record);
|
|
2944
|
+
}
|
|
1511
2945
|
/** Validates every visible field of a schema; returns errors by state path. */
|
|
1512
2946
|
export async function validateSchema(schema) {
|
|
1513
2947
|
const errors = {};
|
|
@@ -1524,11 +2958,21 @@ export async function validateSchema(schema) {
|
|
|
1524
2958
|
export async function getSchemaState(schema) {
|
|
1525
2959
|
const data = {};
|
|
1526
2960
|
const fields = [];
|
|
2961
|
+
const skipped = [];
|
|
1527
2962
|
for (const c of await schema.getVisibleFlatComponents()) {
|
|
1528
2963
|
if (!(c instanceof Field) || !(await c.isDehydrated()))
|
|
1529
2964
|
continue;
|
|
2965
|
+
// Relationship fields (and the fields of their items) save themselves after the record (see saveSchemaRelationships), unless forced with `dehydrated()`.
|
|
2966
|
+
if (skipped.some((path) => c.getStatePath().startsWith(`${path}.`)))
|
|
2967
|
+
continue;
|
|
2968
|
+
if (c._dehydrated !== true && (await c.savesRelationships())) {
|
|
2969
|
+
skipped.push(c.getStatePath());
|
|
2970
|
+
continue;
|
|
2971
|
+
}
|
|
1530
2972
|
const state = await c.dehydrateState(c.context().state);
|
|
1531
2973
|
writePath(data, relativePath(schema, c), state === '' || state === undefined ? null : state);
|
|
2974
|
+
for (const [name, value] of Object.entries(c.getExtraDehydratedState?.() ?? {}))
|
|
2975
|
+
writePath(data, [...relativePath(schema, c).slice(0, -1), name], value);
|
|
1532
2976
|
fields.push(c);
|
|
1533
2977
|
}
|
|
1534
2978
|
// Deepest first: a container sees its children already shaped.
|
|
@@ -1716,6 +3160,60 @@ export class Wizard extends Component {
|
|
|
1716
3160
|
}
|
|
1717
3161
|
}
|
|
1718
3162
|
/** Local disk storage, served under a public URL. File names are random; the extension comes from the detected type. */
|
|
3163
|
+
/**
|
|
3164
|
+
* A file name chosen by the application or the user, made safe for storage: no directory part, only letters, digits, `_` and
|
|
3165
|
+
* `-`, and always the extension detected from the file's content (an uploaded `shell.php` declared as an image is stored
|
|
3166
|
+
* as `shell.png`).
|
|
3167
|
+
*/
|
|
3168
|
+
export function safeStorageName(name, extension) {
|
|
3169
|
+
const base = (name.split(/[\\/]/).pop() ?? '').replace(/\.[^.]*$/, '').normalize('NFKD').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 180) || randomUUID();
|
|
3170
|
+
return `${base}.${extension.replace(/[^A-Za-z0-9]/g, '') || 'bin'}`;
|
|
3171
|
+
}
|
|
3172
|
+
/** Width and height of a PNG, GIF, JPEG or WebP image, read from its header; `null` for other files. */
|
|
3173
|
+
export function readImageSize(path) {
|
|
3174
|
+
let buffer;
|
|
3175
|
+
try {
|
|
3176
|
+
const fd = openSync(path, 'r');
|
|
3177
|
+
buffer = Buffer.alloc(1024 * 1024);
|
|
3178
|
+
const read = readSync(fd, buffer, 0, buffer.length, 0);
|
|
3179
|
+
closeSync(fd);
|
|
3180
|
+
buffer = buffer.subarray(0, read);
|
|
3181
|
+
}
|
|
3182
|
+
catch {
|
|
3183
|
+
return null;
|
|
3184
|
+
}
|
|
3185
|
+
if (buffer.length >= 24 && buffer.readUInt32BE(0) === 0x89504e47)
|
|
3186
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
3187
|
+
if (buffer.length >= 10 && buffer.toString('ascii', 0, 3) === 'GIF')
|
|
3188
|
+
return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
|
|
3189
|
+
if (buffer.length >= 30 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') {
|
|
3190
|
+
const chunk = buffer.toString('ascii', 12, 16);
|
|
3191
|
+
if (chunk === 'VP8X')
|
|
3192
|
+
return { width: 1 + buffer.readUIntLE(24, 3), height: 1 + buffer.readUIntLE(27, 3) };
|
|
3193
|
+
if (chunk === 'VP8L') {
|
|
3194
|
+
const bits = buffer.readUInt32LE(21);
|
|
3195
|
+
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };
|
|
3196
|
+
}
|
|
3197
|
+
if (chunk === 'VP8 ')
|
|
3198
|
+
return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff };
|
|
3199
|
+
return null;
|
|
3200
|
+
}
|
|
3201
|
+
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
|
|
3202
|
+
let offset = 2;
|
|
3203
|
+
while (offset + 9 < buffer.length) {
|
|
3204
|
+
if (buffer[offset] !== 0xff) {
|
|
3205
|
+
offset++;
|
|
3206
|
+
continue;
|
|
3207
|
+
}
|
|
3208
|
+
const marker = buffer[offset + 1];
|
|
3209
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker))
|
|
3210
|
+
return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5) };
|
|
3211
|
+
offset += 2 + buffer.readUInt16BE(offset + 2);
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
return null;
|
|
3215
|
+
}
|
|
3216
|
+
const ratioOf = (ratio) => { const [width, height] = ratio.split(/[:/]/).map(Number); return width > 0 && height > 0 ? width / height : null; };
|
|
1719
3217
|
export class LocalFileStorage {
|
|
1720
3218
|
root;
|
|
1721
3219
|
baseUrl;
|
|
@@ -1725,13 +3223,24 @@ export class LocalFileStorage {
|
|
|
1725
3223
|
return null;
|
|
1726
3224
|
return nodePath.join(this.root, ...key.split('/'));
|
|
1727
3225
|
}
|
|
1728
|
-
async store(file, directory) {
|
|
1729
|
-
|
|
3226
|
+
async store(file, directory, name = null) {
|
|
3227
|
+
let key = [directory, name === null ? `${randomUUID()}.${file.extension}` : safeStorageName(name, file.extension)].filter(Boolean).join('/');
|
|
3228
|
+
// A chosen name never overwrites another file: a short random suffix is added.
|
|
3229
|
+
if (name !== null && this.resolve(key) && existsSync(this.resolve(key)))
|
|
3230
|
+
key = key.replace(/(\.[A-Za-z0-9]+)$/, `-${randomUUID().slice(0, 8)}$1`);
|
|
1730
3231
|
const target = this.resolve(key);
|
|
1731
3232
|
if (!target)
|
|
1732
3233
|
throw new Error('Invalid upload directory.');
|
|
1733
3234
|
mkdirSync(nodePath.dirname(target), { recursive: true });
|
|
1734
|
-
|
|
3235
|
+
try {
|
|
3236
|
+
copyFileSync(file.path, target, fsConstants.COPYFILE_EXCL);
|
|
3237
|
+
}
|
|
3238
|
+
catch (error) {
|
|
3239
|
+
// Another save took this name meanwhile: random suffix.
|
|
3240
|
+
if (error?.code !== 'EEXIST' || name === null)
|
|
3241
|
+
throw error;
|
|
3242
|
+
return this.store(file, directory, `${safeStorageName(name, file.extension).replace(/\.[^.]*$/, '')}-${randomUUID().slice(0, 8)}`);
|
|
3243
|
+
}
|
|
1735
3244
|
writeFileSync(`${target}.meta.json`, JSON.stringify({ name: file.name, type: file.type }));
|
|
1736
3245
|
return key;
|
|
1737
3246
|
}
|
|
@@ -1764,7 +3273,107 @@ export class FileUpload extends Field {
|
|
|
1764
3273
|
_storeFiles = true;
|
|
1765
3274
|
_storage = null;
|
|
1766
3275
|
_getUploadedFileUsing = null;
|
|
3276
|
+
_minSize = null;
|
|
3277
|
+
_ui = {};
|
|
3278
|
+
_avatar = false;
|
|
3279
|
+
_imageEditor = false;
|
|
3280
|
+
_circleCropper = false;
|
|
3281
|
+
_editorAspectRatios = [];
|
|
3282
|
+
_cropAspectRatio = null;
|
|
3283
|
+
_imageAspectRatios = null;
|
|
3284
|
+
_dimensions = null;
|
|
3285
|
+
_deletable = true;
|
|
3286
|
+
_preserveFilenames = false;
|
|
3287
|
+
_fileNameUsing = null;
|
|
3288
|
+
_fileNamesField = null;
|
|
3289
|
+
_storedNames = {};
|
|
3290
|
+
_alignment = null;
|
|
3291
|
+
_placeholder = null;
|
|
1767
3292
|
static make(name) { return configured(new FileUpload(name)); }
|
|
3293
|
+
/** Minimum file size, in kilobytes. */
|
|
3294
|
+
minSize(kilobytes) { this._minSize = kilobytes; return this; }
|
|
3295
|
+
/** Download button on each saved file. */
|
|
3296
|
+
downloadable(condition = true) { this._ui.isDownloadable = condition; return this; }
|
|
3297
|
+
/** Button opening each saved file in a new tab. */
|
|
3298
|
+
openable(condition = true) { this._ui.isOpenable = condition; return this; }
|
|
3299
|
+
/** `false`: no preview of images. */
|
|
3300
|
+
previewable(condition = true) { this._ui.isPreviewable = condition; return this; }
|
|
3301
|
+
/** `false`: files cannot be pasted from the clipboard. */
|
|
3302
|
+
pasteable(condition = true) { this._ui.isPasteable = condition; return this; }
|
|
3303
|
+
/** New files are added after the existing ones (instead of before). */
|
|
3304
|
+
appendFiles(condition = true) { this._ui.shouldAppendFiles = condition; return this; }
|
|
3305
|
+
/** `false`: saved files cannot be removed (the remove button is hidden, and removing is refused on the server). */
|
|
3306
|
+
deletable(condition = true) { this._deletable = condition; return this; }
|
|
3307
|
+
/** `'grid'` (with `multiple()`), `'compact'`, `'integrated'` or `'circle'`. */
|
|
3308
|
+
panelLayout(layout) { this._ui.panelLayout = layout; return this; }
|
|
3309
|
+
panelAspectRatio(ratio) { this._ui.panelAspectRatio = ratio; return this; }
|
|
3310
|
+
itemPanelAspectRatio(ratio) { this._ui.itemPanelAspectRatio = ratio; return this; }
|
|
3311
|
+
imagePreviewHeight(height) { this._ui.imagePreviewHeight = height; return this; }
|
|
3312
|
+
loadingIndicatorPosition(position) { this._ui.loadingIndicatorPosition = position; return this; }
|
|
3313
|
+
removeUploadedFileButtonPosition(position) { this._ui.removeUploadedFileButtonPosition = position; return this; }
|
|
3314
|
+
uploadButtonPosition(position) { this._ui.uploadButtonPosition = position; return this; }
|
|
3315
|
+
uploadProgressIndicatorPosition(position) { this._ui.uploadProgressIndicatorPosition = position; return this; }
|
|
3316
|
+
/** Message shown while a file uploads. */
|
|
3317
|
+
uploadingMessage(message) { this._ui.uploadingMessage = message; return this; }
|
|
3318
|
+
/** Number of files uploaded at the same time. */
|
|
3319
|
+
maxParallelUploads(count) { this._ui.maxParallelUploads = count; return this; }
|
|
3320
|
+
/** Extension → MIME type map for files the browser cannot type (`{ zip: 'application/zip' }`). */
|
|
3321
|
+
mimeTypeMap(map) { this._ui.mimeTypeMap = map; return this; }
|
|
3322
|
+
/** `false`: images keep their EXIF orientation. */
|
|
3323
|
+
orientImagesFromExif(condition = true) { this._ui.shouldOrientImageFromExif = condition; return this; }
|
|
3324
|
+
alignment(alignment) { this._alignment = alignment; return this; }
|
|
3325
|
+
/** Text of the drop area. */
|
|
3326
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
3327
|
+
/** Round avatar: one image, cropped to a square and shown as a circle. */
|
|
3328
|
+
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; }
|
|
3329
|
+
/** Image editor (crop, rotate, flip, zoom) opened from each image, powered by Cropper.js in the browser. */
|
|
3330
|
+
imageEditor(condition = true) { this._imageEditor = condition; return this; }
|
|
3331
|
+
/** Aspect ratios offered by the image editor (`['16:9', '4:3', '1:1']`; `null` for a free crop). */
|
|
3332
|
+
imageEditorAspectRatioOptions(ratios) { this._editorAspectRatios = ratios; return this; }
|
|
3333
|
+
imageEditorAspectRatios(ratios) { return this.imageEditorAspectRatioOptions(ratios); }
|
|
3334
|
+
/** Cropper.js view mode (1, 2 or 3). */
|
|
3335
|
+
imageEditorMode(mode) { this._ui.imageEditorMode = mode; return this; }
|
|
3336
|
+
imageEditorEmptyFillColor(color) { this._ui.imageEditorEmptyFillColor = color; return this; }
|
|
3337
|
+
imageEditorViewportWidth(width) { this._ui.imageEditorViewportWidth = width === null ? null : String(width); return this; }
|
|
3338
|
+
imageEditorViewportHeight(height) { this._ui.imageEditorViewportHeight = height === null ? null : String(height); return this; }
|
|
3339
|
+
_autoOpenEditor = true;
|
|
3340
|
+
/** With `imageEditor()` and a single aspect ratio, opens the editor when an uploaded image has another ratio (on by default). */
|
|
3341
|
+
automaticallyOpenImageEditorForAspectRatio(condition = true) { this._autoOpenEditor = condition; return this; }
|
|
3342
|
+
autoOpenEditorRatio() {
|
|
3343
|
+
if (!this._imageEditor || !this._autoOpenEditor)
|
|
3344
|
+
return null;
|
|
3345
|
+
const ratio = this._cropAspectRatio ?? (this._imageAspectRatios?.length === 1 ? this._imageAspectRatios[0] : null);
|
|
3346
|
+
return ratio === null ? null : ratioOf(ratio);
|
|
3347
|
+
}
|
|
3348
|
+
/** Circle crop in the image editor (avatars). */
|
|
3349
|
+
circleCropper(condition = true) { this._circleCropper = condition; return this; }
|
|
3350
|
+
/** Crops images to this ratio in the browser before upload (`'16:9'`), without opening the editor. */
|
|
3351
|
+
automaticallyCropImagesToAspectRatio(ratio) { this._cropAspectRatio = ratio; return this; }
|
|
3352
|
+
imageCropAspectRatio(ratio) { return this.automaticallyCropImagesToAspectRatio(ratio); }
|
|
3353
|
+
/** Resizes images in the browser before upload (`'cover'`, `'contain'` or `'force'`). */
|
|
3354
|
+
automaticallyResizeImagesMode(mode) { this._ui.automaticallyResizeImagesMode = mode; return this; }
|
|
3355
|
+
automaticallyResizeImagesToWidth(width) { this._ui.automaticallyResizeImagesWidth = width === null ? null : String(width); return this; }
|
|
3356
|
+
automaticallyResizeImagesToHeight(height) { this._ui.automaticallyResizeImagesHeight = height === null ? null : String(height); return this; }
|
|
3357
|
+
automaticallyUpscaleImagesWhenResizing(condition = true) { this._ui.shouldAutomaticallyUpscaleImagesWhenResizing = condition; return this; }
|
|
3358
|
+
imageResizeMode(mode) { return this.automaticallyResizeImagesMode(mode); }
|
|
3359
|
+
imageResizeTargetWidth(width) { return this.automaticallyResizeImagesToWidth(width); }
|
|
3360
|
+
imageResizeTargetHeight(height) { return this.automaticallyResizeImagesToHeight(height); }
|
|
3361
|
+
/** Accepted aspect ratio(s) of uploaded images (`'16:9'` or a list), checked on the server. */
|
|
3362
|
+
imageAspectRatio(ratio) { this._imageAspectRatios = ratio === null ? null : [ratio].flat(); return this; }
|
|
3363
|
+
/** Width, height and ratio bounds of uploaded images (`{ minWidth: 800, maxHeight: 1080 }`), checked on the server. */
|
|
3364
|
+
imageDimensions(dimensions) { this._dimensions = dimensions; return this; }
|
|
3365
|
+
/** Stores files under their original (sanitized) names. See the security notes in the docs. */
|
|
3366
|
+
preserveFilenames(condition = true) { this._preserveFilenames = condition; return this; }
|
|
3367
|
+
/** Name of the stored file, from the temporary file; sanitized, and its extension is the detected one. */
|
|
3368
|
+
getUploadedFileNameForStorageUsing(callback) { this._fileNameUsing = callback; return this; }
|
|
3369
|
+
/** Saves the original file names (keyed by stored file) in another attribute of the record. */
|
|
3370
|
+
storeFileNamesIn(field) { this._fileNamesField = field; return this; }
|
|
3371
|
+
/** Extra saved values, next to the field (the original file names of `storeFileNamesIn()`). */
|
|
3372
|
+
getExtraDehydratedState() {
|
|
3373
|
+
if (this._fileNamesField === null)
|
|
3374
|
+
return {};
|
|
3375
|
+
return { [this._fileNamesField]: this._multiple ? this._storedNames : (Object.values(this._storedNames)[0] ?? null) };
|
|
3376
|
+
}
|
|
1768
3377
|
multiple(condition = true) { this._multiple = condition; return this; }
|
|
1769
3378
|
maxFiles(count) { this._maxFiles = count; return this; }
|
|
1770
3379
|
/** Maximum file size, in kilobytes. */
|
|
@@ -1799,6 +3408,23 @@ export class FileUpload extends Field {
|
|
|
1799
3408
|
return new Set(Object.values(this.hydrateState(record?.[this.getName()])));
|
|
1800
3409
|
}
|
|
1801
3410
|
isTemporary(value) { return value.startsWith('livewire-file:'); }
|
|
3411
|
+
/** Original names saved by `storeFileNamesIn()`, by stored key. */
|
|
3412
|
+
savedFileNames() {
|
|
3413
|
+
if (this._fileNamesField === null)
|
|
3414
|
+
return {};
|
|
3415
|
+
const record = this.container.getRecord();
|
|
3416
|
+
let names = record?.[this._fileNamesField];
|
|
3417
|
+
if (typeof names === 'string') {
|
|
3418
|
+
try {
|
|
3419
|
+
names = JSON.parse(names);
|
|
3420
|
+
}
|
|
3421
|
+
catch {
|
|
3422
|
+
const key = [...this.originalKeys()][0];
|
|
3423
|
+
return key ? { [key]: names } : {};
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
return names && typeof names === 'object' && !Array.isArray(names) ? Object.fromEntries(Object.entries(names).filter(([, name]) => typeof name === 'string')) : {};
|
|
3427
|
+
}
|
|
1802
3428
|
getExposedMethods() { return ['deleteUploadedFile', 'removeUploadedFile', 'reorderUploadedFiles', 'getUploadedFiles']; }
|
|
1803
3429
|
async getUploadedFiles() {
|
|
1804
3430
|
const original = this.originalKeys();
|
|
@@ -1809,6 +3435,9 @@ export class FileUpload extends Field {
|
|
|
1809
3435
|
continue;
|
|
1810
3436
|
}
|
|
1811
3437
|
const info = this._getUploadedFileUsing ? await this._getUploadedFileUsing(value, this.context()) : await this.getStorage()?.info(value);
|
|
3438
|
+
const savedName = this.savedFileNames()[value];
|
|
3439
|
+
if (info && savedName)
|
|
3440
|
+
info.name = savedName;
|
|
1812
3441
|
// Only local or http(s) URLs are sent to the browser.
|
|
1813
3442
|
files[fileKey] = info && /^(\/(?!\/)|https?:\/\/)/.test(info.url) ? info : null;
|
|
1814
3443
|
}
|
|
@@ -1818,6 +3447,9 @@ export class FileUpload extends Field {
|
|
|
1818
3447
|
/** Removes a file from the field. A stored file is only deleted by the application when the form is saved. */
|
|
1819
3448
|
removeUploadedFile(fileKey) {
|
|
1820
3449
|
const removed = this.entries().find(([key]) => key === fileKey);
|
|
3450
|
+
// `deletable(false)`: a saved file stays (a file just uploaded can still be removed).
|
|
3451
|
+
if (removed && !this._deletable && !this.isTemporary(removed[1]))
|
|
3452
|
+
return;
|
|
1821
3453
|
if (removed && this.isTemporary(removed[1]))
|
|
1822
3454
|
this.uploads?.remove(removed[1].slice('livewire-file:'.length));
|
|
1823
3455
|
this.writeState(this.entries().filter(([key]) => key !== fileKey));
|
|
@@ -1846,6 +3478,23 @@ export class FileUpload extends Field {
|
|
|
1846
3478
|
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
3479
|
if (this._maxSize !== null)
|
|
1848
3480
|
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.` });
|
|
3481
|
+
if (this._minSize !== null)
|
|
3482
|
+
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.` });
|
|
3483
|
+
// Image rules only apply to new uploads, as in Laravel; a file whose size cannot be read fails them.
|
|
3484
|
+
if (this._dimensions) {
|
|
3485
|
+
const d = this._dimensions;
|
|
3486
|
+
const fits = (size) => size !== null
|
|
3487
|
+
&& (d.width === undefined || size.width === d.width) && (d.height === undefined || size.height === d.height)
|
|
3488
|
+
&& (d.minWidth === undefined || size.width >= d.minWidth) && (d.maxWidth === undefined || size.width <= d.maxWidth)
|
|
3489
|
+
&& (d.minHeight === undefined || size.height >= d.minHeight) && (d.maxHeight === undefined || size.height <= d.maxHeight)
|
|
3490
|
+
&& (d.ratio === undefined || Math.abs(size.width / size.height - d.ratio) < 0.01)
|
|
3491
|
+
&& (d.minRatio === undefined || size.width / size.height >= d.minRatio - 1e-9) && (d.maxRatio === undefined || size.width / size.height <= d.maxRatio + 1e-9);
|
|
3492
|
+
rules.push({ name: 'dimensions', passes: () => temporary().every((file) => file !== null && fits(readImageSize(file.path))), message: (a) => `The ${a} field has invalid image dimensions.` });
|
|
3493
|
+
}
|
|
3494
|
+
if (this._imageAspectRatios) {
|
|
3495
|
+
const ratios = this._imageAspectRatios.map(ratioOf).filter((ratio) => ratio !== null);
|
|
3496
|
+
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.` });
|
|
3497
|
+
}
|
|
1849
3498
|
return rules;
|
|
1850
3499
|
}
|
|
1851
3500
|
/** Validates the whole state, even when empty (a required field without files must fail). */
|
|
@@ -1861,6 +3510,8 @@ export class FileUpload extends Field {
|
|
|
1861
3510
|
/** On save: temporary files are stored, unrecognized existing keys are dropped. */
|
|
1862
3511
|
async dehydrateState() {
|
|
1863
3512
|
const original = this.originalKeys();
|
|
3513
|
+
const savedNames = this.savedFileNames();
|
|
3514
|
+
this._storedNames = {};
|
|
1864
3515
|
const out = [];
|
|
1865
3516
|
for (const [, value] of this.entries()) {
|
|
1866
3517
|
if (this.isTemporary(value)) {
|
|
@@ -1874,74 +3525,115 @@ export class FileUpload extends Field {
|
|
|
1874
3525
|
const storage = this.getStorage();
|
|
1875
3526
|
if (!storage)
|
|
1876
3527
|
throw new Error('FileUpload: no storage configured (FileUpload.storage).');
|
|
1877
|
-
|
|
3528
|
+
const name = this._fileNameUsing ? await this._fileNameUsing(file, this.context()) : this._preserveFilenames ? file.name : null;
|
|
3529
|
+
const stored = await storage.store(file, this._directory, name);
|
|
3530
|
+
this._storedNames[stored] = file.name;
|
|
3531
|
+
out.push(stored);
|
|
1878
3532
|
this.uploads?.remove(file.id);
|
|
1879
3533
|
}
|
|
1880
|
-
else if (original.has(value))
|
|
3534
|
+
else if (original.has(value)) {
|
|
1881
3535
|
out.push(this._storeFiles ? value : { key: value });
|
|
3536
|
+
if (savedNames[value])
|
|
3537
|
+
this._storedNames[value] = savedNames[value];
|
|
3538
|
+
}
|
|
1882
3539
|
}
|
|
3540
|
+
// `deletable(false)`: a saved file the browser dropped from the state is kept.
|
|
3541
|
+
if (!this._deletable)
|
|
3542
|
+
for (const key of original)
|
|
3543
|
+
if (!out.some((item) => item === key || (typeof item === 'object' && 'key' in item && item.key === key))) {
|
|
3544
|
+
out.push(this._storeFiles ? key : { key });
|
|
3545
|
+
if (savedNames[key])
|
|
3546
|
+
this._storedNames[key] = savedNames[key];
|
|
3547
|
+
}
|
|
1883
3548
|
return this._multiple ? out : (out[0] ?? null);
|
|
1884
3549
|
}
|
|
3550
|
+
/** Filament's image editor dialog (Cropper.js), rendered once per field. */
|
|
3551
|
+
imageEditorHtml() {
|
|
3552
|
+
const tr = (key, replace = {}) => t(`filament-forms::components.file_upload.editor.${key}`, replace);
|
|
3553
|
+
const explicit = this._imageEditor;
|
|
3554
|
+
const fields = [
|
|
3555
|
+
['x_position', 'xPositionInput', 'editor.setData({...editor.getData(true), x: +$el.value})'], ['y_position', 'yPositionInput', 'editor.setData({...editor.getData(true), y: +$el.value})'],
|
|
3556
|
+
['width', 'widthInput', 'editor.setData({...editor.getData(true), width: +$el.value})'], ['height', 'heightInput', 'editor.setData({...editor.getData(true), height: +$el.value})'],
|
|
3557
|
+
['rotation', 'rotationInput', 'editor.rotateTo(+$el.value)'],
|
|
3558
|
+
].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('');
|
|
3559
|
+
const groups = [
|
|
3560
|
+
[['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)']],
|
|
3561
|
+
[['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)']],
|
|
3562
|
+
[['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)']],
|
|
3563
|
+
].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('');
|
|
3564
|
+
const ratioOptions = [...this._editorAspectRatios, ...(this._cropAspectRatio !== null ? [this._cropAspectRatio] : [])].filter((ratio, index, all) => all.indexOf(ratio) === index)
|
|
3565
|
+
.map((ratio) => [ratio === null ? tr('aspect_ratios.no_fixed.label') : ratio.replace('/', ':'), ratio === null ? 'NaN' : (ratioOf(ratio) ?? 'NaN')]);
|
|
3566
|
+
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>` : '';
|
|
3567
|
+
const success = cls(['fi-btn', ...(colorManager?.getComponentClasses('button', 'success') ?? [])]);
|
|
3568
|
+
const cancel = `<button type="button" x-on:click.prevent="pond.imageEditEditor.oncancel" class="fi-btn">${e(tr('actions.cancel.label'))}</button>`;
|
|
3569
|
+
const save = `<button type="button" x-on:click.prevent="saveEditor" class="${success}">${e(tr('actions.save.label'))}</button>`;
|
|
3570
|
+
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>` : '';
|
|
3571
|
+
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>`;
|
|
3572
|
+
}
|
|
1885
3573
|
async toHtml() {
|
|
1886
3574
|
const isDisabled = await this.isDisabled();
|
|
1887
3575
|
const id = this.getId();
|
|
1888
3576
|
const label = (await this.getLabel()).toLowerCase();
|
|
1889
3577
|
const tr = (key) => t(`filament-forms::components.file_upload.${key}`);
|
|
3578
|
+
const ui = (key, fallback) => (this._ui[key] === undefined ? fallback : this._ui[key]);
|
|
3579
|
+
const hasEditor = (this._imageEditor || this._cropAspectRatio !== null || this._circleCropper) && !isDisabled;
|
|
1890
3580
|
const config = `fileUploadFormComponent({
|
|
1891
3581
|
acceptedFileTypes: ${this._acceptedFileTypes ? js(this._acceptedFileTypes) : 'null'},
|
|
1892
|
-
automaticallyCropImagesAspectRatio:
|
|
1893
|
-
automaticallyOpenImageEditorForAspectRatio:
|
|
1894
|
-
automaticallyResizeImagesMode: null,
|
|
1895
|
-
automaticallyResizeImagesHeight: null,
|
|
1896
|
-
automaticallyResizeImagesWidth: null,
|
|
3582
|
+
automaticallyCropImagesAspectRatio: ${js(this._cropAspectRatio)},
|
|
3583
|
+
automaticallyOpenImageEditorForAspectRatio: ${js(this.autoOpenEditorRatio())},
|
|
3584
|
+
automaticallyResizeImagesMode: ${js(ui('automaticallyResizeImagesMode', null))},
|
|
3585
|
+
automaticallyResizeImagesHeight: ${js(ui('automaticallyResizeImagesHeight', null))},
|
|
3586
|
+
automaticallyResizeImagesWidth: ${js(ui('automaticallyResizeImagesWidth', null))},
|
|
1897
3587
|
canEditSvgs: true,
|
|
1898
3588
|
confirmSvgEditingMessage: ${js(tr('editor.svg.messages.confirmation'))},
|
|
1899
3589
|
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:
|
|
3590
|
+
hasCircleCropper: ${js(this._circleCropper)},
|
|
3591
|
+
hasImageEditor: ${js(hasEditor)},
|
|
3592
|
+
imageEditorEmptyFillColor: ${js(ui('imageEditorEmptyFillColor', null))},
|
|
3593
|
+
imageEditorMode: ${js(ui('imageEditorMode', 1))},
|
|
3594
|
+
imageEditorViewportHeight: ${js(ui('imageEditorViewportHeight', null))},
|
|
3595
|
+
imageEditorViewportWidth: ${js(ui('imageEditorViewportWidth', null))},
|
|
3596
|
+
imagePreviewHeight: ${js(ui('imagePreviewHeight', null))},
|
|
3597
|
+
isAvatar: ${js(this._avatar)},
|
|
3598
|
+
isDeletable: ${js(this._deletable)},
|
|
1909
3599
|
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
1910
|
-
isDownloadable: false,
|
|
1911
|
-
isImageEditorExplicitlyEnabled:
|
|
3600
|
+
isDownloadable: ${js(ui('isDownloadable', false))},
|
|
3601
|
+
isImageEditorExplicitlyEnabled: ${js(this._imageEditor)},
|
|
1912
3602
|
isMultiple: ${this._multiple ? 'true' : 'false'},
|
|
1913
|
-
isOpenable: false,
|
|
1914
|
-
isPasteable: true,
|
|
1915
|
-
isPreviewable: true,
|
|
3603
|
+
isOpenable: ${js(ui('isOpenable', false))},
|
|
3604
|
+
isPasteable: ${js(ui('isPasteable', true))},
|
|
3605
|
+
isPreviewable: ${js(ui('isPreviewable', true))},
|
|
1916
3606
|
isReorderable: ${this._reorderable ? 'true' : 'false'},
|
|
1917
3607
|
isSvgEditingConfirmed: false,
|
|
1918
|
-
itemPanelAspectRatio: null,
|
|
1919
|
-
loadingIndicatorPosition: 'right',
|
|
3608
|
+
itemPanelAspectRatio: ${js(ui('itemPanelAspectRatio', null))},
|
|
3609
|
+
loadingIndicatorPosition: ${js(ui('loadingIndicatorPosition', 'right'))},
|
|
1920
3610
|
locale: 'en',
|
|
1921
3611
|
maxFiles: ${this._maxFiles ?? 'null'},
|
|
1922
3612
|
maxFilesValidationMessage: ${this._maxFiles !== null ? js(`The ${label} field must not have more than ${this._maxFiles} items.`) : 'null'},
|
|
1923
|
-
maxParallelUploads: null,
|
|
3613
|
+
maxParallelUploads: ${js(ui('maxParallelUploads', null))},
|
|
1924
3614
|
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:
|
|
3615
|
+
mimeTypeMap: ${ui('mimeTypeMap', null) ? js(ui('mimeTypeMap', null)) : '[]'},
|
|
3616
|
+
minSize: ${this._minSize !== null ? js(`${this._minSize}KB`) : 'null'},
|
|
3617
|
+
panelAspectRatio: ${js(ui('panelAspectRatio', null))},
|
|
3618
|
+
panelLayout: ${js(ui('panelLayout', 'compact'))},
|
|
3619
|
+
placeholder: ${js(this._placeholder)},
|
|
3620
|
+
removeUploadedFileButtonPosition: ${js(ui('removeUploadedFileButtonPosition', 'left'))},
|
|
3621
|
+
shouldAppendFiles: ${js(ui('shouldAppendFiles', false))},
|
|
3622
|
+
shouldAutomaticallyUpscaleImagesWhenResizing: ${js(ui('shouldAutomaticallyUpscaleImagesWhenResizing', true))},
|
|
3623
|
+
shouldOrientImageFromExif: ${js(ui('shouldOrientImageFromExif', true))},
|
|
3624
|
+
shouldTransformImage: ${js(Boolean(this._cropAspectRatio || ui('automaticallyResizeImagesHeight', null) || ui('automaticallyResizeImagesWidth', null)))},
|
|
1935
3625
|
state: $wire.$entangle(${js(this.getStatePath())}, false),
|
|
1936
3626
|
statePath: ${js(this.getStatePath())},
|
|
1937
|
-
uploadButtonPosition: 'right',
|
|
1938
|
-
uploadingMessage: ${js(t('filament::components/button.messages.uploading_file'))},
|
|
3627
|
+
uploadButtonPosition: ${js(ui('uploadButtonPosition', 'right'))},
|
|
3628
|
+
uploadingMessage: ${js(ui('uploadingMessage', t('filament::components/button.messages.uploading_file')))},
|
|
1939
3629
|
downloadActionLabel: ${js(tr('actions.download.label'))},
|
|
1940
3630
|
openActionLabel: ${js(tr('actions.open.label'))},
|
|
1941
|
-
uploadProgressIndicatorPosition: 'right',
|
|
3631
|
+
uploadProgressIndicatorPosition: ${js(ui('uploadProgressIndicatorPosition', 'right'))},
|
|
1942
3632
|
})`;
|
|
1943
|
-
const key = `${this.getLivewireKey()}.${createHash('md5').update(JSON.stringify([this._acceptedFileTypes, this._maxFiles, this._multiple, isDisabled])).digest('hex')}`;
|
|
3633
|
+
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
3634
|
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
|
|
3635
|
+
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
3636
|
}
|
|
1947
3637
|
}
|
|
3638
|
+
// Modal wizards of actions (`Action.steps()`): the wizard is not contained, the last step submits the action.
|
|
3639
|
+
Action.stepsRenderer = (steps, submit, cancel) => Wizard.make(steps).contained(false).submitAction(submit).cancelAction(cancel);
|