@navette-js/forms 0.0.0-stage → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +8 -2
- package/lib/builder.d.ts +128 -0
- package/lib/builder.js +309 -0
- package/lib/index.d.ts +871 -0
- package/lib/index.js +1957 -0
- package/package.json +62 -4
- package/views/asset.ejs +1 -0
- package/views/components/field-wrapper.ejs +39 -0
- package/views/components/rich-editor.ejs +472 -0
- package/views/components/text-input.ejs +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NavetteJS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @navette-js/forms
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Form fields for Navette, the Node.js port of Filament.
|
|
4
|
+
|
|
5
|
+
Part of [Navette](https://github.com/NavetteJS/navette), a Node.js port of [Filament](https://filamentphp.com). Install the whole framework with `npm install @navette-js/panels`.
|
|
6
|
+
|
|
7
|
+
**Beta:** the API may still change between beta releases.
|
|
8
|
+
|
|
9
|
+
License: MIT. Third-party notices: see `THIRD-PARTY-NOTICES.md` where present.
|
package/lib/builder.d.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { type ColorManager } from '@navette-js/support';
|
|
2
|
+
import { Action } from '@navette-js/actions';
|
|
3
|
+
import { Schema, type Component } from '@navette-js/schemas';
|
|
4
|
+
import { Field } from './index.ts';
|
|
5
|
+
/** Clé d'élément acceptable : les clés viennent du navigateur. */
|
|
6
|
+
export declare const isItemKey: (key: string) => boolean;
|
|
7
|
+
/** Clé courte à la manière de `Str::random(4)`. */
|
|
8
|
+
export declare const shortKey: () => string;
|
|
9
|
+
/** Écrit une valeur à un chemin d'état du composant hôte. */
|
|
10
|
+
export declare function writeState(target: any, segments: string[], value: unknown): void;
|
|
11
|
+
/** Bouton-icône d'action de composant (même balisage que Filament : icône, puis indicateur de chargement). */
|
|
12
|
+
export declare function iconActionButton(options: {
|
|
13
|
+
label: string;
|
|
14
|
+
icon: string;
|
|
15
|
+
expression: string;
|
|
16
|
+
colorClasses?: string[];
|
|
17
|
+
attributes?: Record<string, string | null>;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
inHeader?: boolean;
|
|
20
|
+
}): string;
|
|
21
|
+
/** Gestionnaire de couleurs du panel de l'hôte (les pages et widgets l'exposent par `panel`). */
|
|
22
|
+
export declare const colorsOf: (livewire: unknown) => ColorManager | null;
|
|
23
|
+
type BlockContext = {
|
|
24
|
+
builder: Builder;
|
|
25
|
+
itemKey: string;
|
|
26
|
+
data: Record<string, unknown>;
|
|
27
|
+
};
|
|
28
|
+
/** Type d'élément d'un Builder (port de Builder\Block). */
|
|
29
|
+
export declare class Block {
|
|
30
|
+
readonly name: string;
|
|
31
|
+
private _label;
|
|
32
|
+
private _icon;
|
|
33
|
+
private _schema;
|
|
34
|
+
private _defaults;
|
|
35
|
+
constructor(name: string);
|
|
36
|
+
static make(name: string): Block;
|
|
37
|
+
label(label: string | null): this;
|
|
38
|
+
icon(name: string | null): this;
|
|
39
|
+
/** Composants d'un élément : une fonction, appelée pour chaque élément (ses données courantes sont fournies). */
|
|
40
|
+
schema(factory: (ctx: BlockContext) => Component[]): this;
|
|
41
|
+
/** Données d'un nouvel élément quand elles ne se déduisent pas des valeurs par défaut des champs (champs dépendant d'un autre). */
|
|
42
|
+
defaultData(factory: (builder: Builder) => Record<string, unknown>): this;
|
|
43
|
+
getLabel(): string;
|
|
44
|
+
getIcon(): string | null;
|
|
45
|
+
getComponents(ctx: BlockContext): Component[];
|
|
46
|
+
getDefaults(builder: Builder): Record<string, unknown> | null;
|
|
47
|
+
}
|
|
48
|
+
type Item = {
|
|
49
|
+
type: string;
|
|
50
|
+
data: Record<string, unknown>;
|
|
51
|
+
};
|
|
52
|
+
export declare class Builder extends Field {
|
|
53
|
+
private _blocks;
|
|
54
|
+
private _resolvedBlocks;
|
|
55
|
+
private _pickerColumns;
|
|
56
|
+
private _pickerWidth;
|
|
57
|
+
private _labelBetweenItems;
|
|
58
|
+
private _blockHeaders;
|
|
59
|
+
private _cloneable;
|
|
60
|
+
private _blockNumbers;
|
|
61
|
+
private _blockIcons;
|
|
62
|
+
private _headingTag;
|
|
63
|
+
private _addBetween;
|
|
64
|
+
private _addBetweenLabel;
|
|
65
|
+
private _addLabel;
|
|
66
|
+
private _addIcon;
|
|
67
|
+
private _addAlignment;
|
|
68
|
+
private _maxItems;
|
|
69
|
+
private _extraClass;
|
|
70
|
+
private _uuid;
|
|
71
|
+
static make(name: string): Builder;
|
|
72
|
+
blocks(blocks: Block[] | (() => Block[])): this;
|
|
73
|
+
blockPickerColumns(columns?: number | null): this;
|
|
74
|
+
blockPickerWidth(width: string | null): this;
|
|
75
|
+
labelBetweenItems(label: string | null): this;
|
|
76
|
+
/** Sans en-tête, l'élément n'affiche que son contenu : ses actions sont alors à la charge du bloc. */
|
|
77
|
+
blockHeaders(condition?: boolean): this;
|
|
78
|
+
cloneable(condition?: boolean): this;
|
|
79
|
+
/** Numéro de l'élément après le libellé du bloc (actif par défaut, comme Filament). */
|
|
80
|
+
blockNumbers(condition?: boolean): this;
|
|
81
|
+
/** Icône du bloc dans l'en-tête de l'élément (désactivée par défaut, comme Filament). */
|
|
82
|
+
blockIcons(condition?: boolean): this;
|
|
83
|
+
/** Balise du libellé d'en-tête (`h2` par défaut ; Filament la déduit du niveau de titre du schéma). */
|
|
84
|
+
blockLabelHeadingTag(tag: 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p'): this;
|
|
85
|
+
/** Sélecteur « Insérer entre les blocs » entre deux éléments (actif par défaut, comme Filament). */
|
|
86
|
+
addBetweenAction(condition?: boolean): this;
|
|
87
|
+
addBetweenActionLabel(label: string | null): this;
|
|
88
|
+
addActionLabel(label: string | null): this;
|
|
89
|
+
addActionIcon(name: string | null): this;
|
|
90
|
+
addActionAlignment(alignment: 'start' | 'center' | 'end' | null): this;
|
|
91
|
+
maxItems(count: number): this;
|
|
92
|
+
/** Classe ajoutée à la racine (ex. `fi-fo-builder-not-contained`). */
|
|
93
|
+
extraClass(name: string | null): this;
|
|
94
|
+
generateUuidUsing(generator: () => string): this;
|
|
95
|
+
getBlocks(): Block[];
|
|
96
|
+
getBlock(name: unknown): Block | undefined;
|
|
97
|
+
/** Blocs proposés dans le sélecteur (une sous-classe peut en retirer). */
|
|
98
|
+
getBlockPickerBlocks(): Block[];
|
|
99
|
+
/** Éléments bien formés : clé simple, type déclaré, données en objet. Le reste de ce qu'envoie le navigateur est ignoré. */
|
|
100
|
+
getItems(): Record<string, Item>;
|
|
101
|
+
protected setItems(items: Record<string, Item>): void;
|
|
102
|
+
getDefaultState(): unknown;
|
|
103
|
+
/**
|
|
104
|
+
* Valeur enregistrée (liste `[{ type, data }]`, comme Filament, éventuellement en JSON) vers l'état du formulaire (table par clé).
|
|
105
|
+
* Les champs de chaque bloc hydratent leur propre valeur.
|
|
106
|
+
*/
|
|
107
|
+
hydrateState(value: unknown): unknown;
|
|
108
|
+
private _storedAsList;
|
|
109
|
+
/** `false` : la valeur enregistrée reste la table par clé de l'état (filtres), au lieu de la liste `[{ type, data }]`. */
|
|
110
|
+
storedAsList(condition?: boolean): this;
|
|
111
|
+
finalizeDehydratedState(state: unknown): unknown;
|
|
112
|
+
dehydrateState(): unknown;
|
|
113
|
+
/** Schéma d'un élément : clé `<builder>.<élément>.data`, état au même chemin (comme Filament). */
|
|
114
|
+
protected itemSchema(itemKey: string, item: Item): Schema;
|
|
115
|
+
getChildSchemas(): Schema[];
|
|
116
|
+
protected newItem(block: Block): Item;
|
|
117
|
+
/** Une sous-classe peut refuser l'ajout (plafond de règles, par exemple). */
|
|
118
|
+
protected canAdd(_block: Block): boolean;
|
|
119
|
+
getActions(): Action[];
|
|
120
|
+
/** Copie d'un élément ; une sous-classe renouvelle les clés des éléments imbriqués. */
|
|
121
|
+
protected cloneItem(item: Item): Item;
|
|
122
|
+
/** Expression `mountAction(...)` d'une action de ce composant. */
|
|
123
|
+
actionExpression(action: string, args: Record<string, unknown> | null): string;
|
|
124
|
+
private blockPickerHtml;
|
|
125
|
+
private _labelText;
|
|
126
|
+
toHtml(): Promise<string>;
|
|
127
|
+
}
|
|
128
|
+
export {};
|
package/lib/builder.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// @navette-js/forms/builder — port (partiel) de Filament\Forms\Components\Builder : une liste d'éléments dont chacun est un « bloc »
|
|
2
|
+
// choisi dans un menu. L'état est une table `clé d'élément → { type, data }` ; l'ordre des clés est l'ordre d'affichage.
|
|
3
|
+
// Portés : blocs, sélecteur de bloc, ajout / suppression / duplication / déplacement / réordonnancement, libellé entre éléments,
|
|
4
|
+
// en-têtes de bloc (libellé et actions). Non portés : aperçus de bloc, repli, recherche dans le sélecteur, ajout entre éléments.
|
|
5
|
+
import { Attributes, cls, e, icon, js, loadingIndicator, views, configured } from '@navette-js/support';
|
|
6
|
+
import { t } from '@navette-js/support/i18n';
|
|
7
|
+
import { Action } from '@navette-js/actions';
|
|
8
|
+
import { Schema } from '@navette-js/schemas';
|
|
9
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
10
|
+
import { Field, fillSchema } from "./index.js";
|
|
11
|
+
const asset = (name) => views.render('filament-forms::asset', { path: name }).trim();
|
|
12
|
+
const ITEM_KEY = /^[A-Za-z0-9_-]{1,64}$/;
|
|
13
|
+
/** Clé d'élément acceptable : les clés viennent du navigateur. */
|
|
14
|
+
export const isItemKey = (key) => ITEM_KEY.test(key) && key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
|
|
15
|
+
/** Clé courte à la manière de `Str::random(4)`. */
|
|
16
|
+
export const shortKey = () => Array.from(randomBytes(4), (b) => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'[b % 62]).join('');
|
|
17
|
+
/** Écrit une valeur à un chemin d'état du composant hôte. */
|
|
18
|
+
export function writeState(target, segments, value) {
|
|
19
|
+
let node = target;
|
|
20
|
+
for (const key of segments.slice(0, -1)) {
|
|
21
|
+
if (node[key] === null || typeof node[key] !== 'object')
|
|
22
|
+
node[key] = {};
|
|
23
|
+
node = node[key];
|
|
24
|
+
}
|
|
25
|
+
node[segments[segments.length - 1]] = value;
|
|
26
|
+
}
|
|
27
|
+
/** Bouton-icône d'action de composant (même balisage que Filament : icône, puis indicateur de chargement). */
|
|
28
|
+
export function iconActionButton(options) {
|
|
29
|
+
// En-tête d'élément : Filament place `tabindex` avant `type` et la classe avant les attributs ajoutés.
|
|
30
|
+
const { tabindex = null, ...extra } = options.attributes ?? {};
|
|
31
|
+
const attributes = new Attributes(options.inHeader ? {
|
|
32
|
+
title: options.label, 'aria-disabled': options.disabled ? 'true' : null, 'aria-label': options.label, disabled: options.disabled ? 'disabled' : null, tabindex, type: 'button',
|
|
33
|
+
'wire:loading.attr': 'aria-disabled', 'wire:loading.class': 'fi-disabled', class: '', ...extra, 'wire:click': options.expression,
|
|
34
|
+
} : {
|
|
35
|
+
title: options.label, 'aria-disabled': options.disabled ? 'true' : null, 'aria-label': options.label, disabled: options.disabled ? 'disabled' : null, type: 'button',
|
|
36
|
+
'wire:loading.attr': 'aria-disabled', 'wire:loading.class': 'fi-disabled', ...(options.attributes ?? {}), 'wire:click': options.expression,
|
|
37
|
+
}).class(['fi-ac-icon-btn-action', 'fi-icon-btn', { 'fi-disabled': options.disabled === true }, 'fi-size-sm', ...(options.colorClasses ?? [])]);
|
|
38
|
+
return `<button ${attributes.toHtml()}>${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 }))}</button>`;
|
|
39
|
+
}
|
|
40
|
+
/** Gestionnaire de couleurs du panel de l'hôte (les pages et widgets l'exposent par `panel`). */
|
|
41
|
+
export const colorsOf = (livewire) => livewire?.panel?.colors ?? null;
|
|
42
|
+
/** Type d'élément d'un Builder (port de Builder\Block). */
|
|
43
|
+
export class Block {
|
|
44
|
+
name;
|
|
45
|
+
_label = null;
|
|
46
|
+
_icon = null;
|
|
47
|
+
_schema = () => [];
|
|
48
|
+
_defaults = null;
|
|
49
|
+
constructor(name) { this.name = name; }
|
|
50
|
+
static make(name) { return configured(new Block(name)); }
|
|
51
|
+
label(label) { this._label = label; return this; }
|
|
52
|
+
icon(name) { this._icon = name; return this; }
|
|
53
|
+
/** Composants d'un élément : une fonction, appelée pour chaque élément (ses données courantes sont fournies). */
|
|
54
|
+
schema(factory) { this._schema = factory; return this; }
|
|
55
|
+
/** Données d'un nouvel élément quand elles ne se déduisent pas des valeurs par défaut des champs (champs dépendant d'un autre). */
|
|
56
|
+
defaultData(factory) { this._defaults = factory; return this; }
|
|
57
|
+
getLabel() { return this._label ?? this.name.replace(/[_-]+/g, ' ').replace(/^./, (c) => c.toUpperCase()); }
|
|
58
|
+
getIcon() { return this._icon; }
|
|
59
|
+
getComponents(ctx) { return this._schema(ctx); }
|
|
60
|
+
getDefaults(builder) { return this._defaults ? this._defaults(builder) : null; }
|
|
61
|
+
}
|
|
62
|
+
/** Empreinte du sélecteur de bloc : Filament y met `md5(serialize([largeur, placement, a des blocs, recherche]))`. */
|
|
63
|
+
const PICKER_HASHES = { 'fi-width-md|bottom-start|1|0': '8b2521a32ddb82abb3a5f4fd214c90b1', '||1|0': '9c288f69c73367423cc1eb48b3e9a82b' };
|
|
64
|
+
export class Builder extends Field {
|
|
65
|
+
_blocks = [];
|
|
66
|
+
_resolvedBlocks = null;
|
|
67
|
+
_pickerColumns = null;
|
|
68
|
+
_pickerWidth = null;
|
|
69
|
+
_labelBetweenItems = null;
|
|
70
|
+
_blockHeaders = true;
|
|
71
|
+
_cloneable = false;
|
|
72
|
+
_blockNumbers = true;
|
|
73
|
+
_blockIcons = false;
|
|
74
|
+
_headingTag = 'h2';
|
|
75
|
+
_addBetween = true;
|
|
76
|
+
_addBetweenLabel = null;
|
|
77
|
+
_addLabel = null;
|
|
78
|
+
_addIcon = null;
|
|
79
|
+
_addAlignment = null;
|
|
80
|
+
_maxItems = 100;
|
|
81
|
+
_extraClass = null;
|
|
82
|
+
_uuid = randomUUID;
|
|
83
|
+
static make(name) { return configured(new Builder(name)); }
|
|
84
|
+
blocks(blocks) { this._blocks = blocks; this._resolvedBlocks = null; return this; }
|
|
85
|
+
blockPickerColumns(columns = 2) { this._pickerColumns = columns; return this; }
|
|
86
|
+
blockPickerWidth(width) { this._pickerWidth = width; return this; }
|
|
87
|
+
labelBetweenItems(label) { this._labelBetweenItems = label; return this; }
|
|
88
|
+
/** Sans en-tête, l'élément n'affiche que son contenu : ses actions sont alors à la charge du bloc. */
|
|
89
|
+
blockHeaders(condition = true) { this._blockHeaders = condition; return this; }
|
|
90
|
+
cloneable(condition = true) { this._cloneable = condition; return this; }
|
|
91
|
+
/** Numéro de l'élément après le libellé du bloc (actif par défaut, comme Filament). */
|
|
92
|
+
blockNumbers(condition = true) { this._blockNumbers = condition; return this; }
|
|
93
|
+
/** Icône du bloc dans l'en-tête de l'élément (désactivée par défaut, comme Filament). */
|
|
94
|
+
blockIcons(condition = true) { this._blockIcons = condition; return this; }
|
|
95
|
+
/** Balise du libellé d'en-tête (`h2` par défaut ; Filament la déduit du niveau de titre du schéma). */
|
|
96
|
+
blockLabelHeadingTag(tag) { this._headingTag = tag; return this; }
|
|
97
|
+
/** Sélecteur « Insérer entre les blocs » entre deux éléments (actif par défaut, comme Filament). */
|
|
98
|
+
addBetweenAction(condition = true) { this._addBetween = condition; return this; }
|
|
99
|
+
addBetweenActionLabel(label) { this._addBetweenLabel = label; return this; }
|
|
100
|
+
addActionLabel(label) { this._addLabel = label; return this; }
|
|
101
|
+
addActionIcon(name) { this._addIcon = name; return this; }
|
|
102
|
+
addActionAlignment(alignment) { this._addAlignment = alignment; return this; }
|
|
103
|
+
maxItems(count) { this._maxItems = count; return this; }
|
|
104
|
+
/** Classe ajoutée à la racine (ex. `fi-fo-builder-not-contained`). */
|
|
105
|
+
extraClass(name) { this._extraClass = name; return this; }
|
|
106
|
+
generateUuidUsing(generator) { this._uuid = generator; return this; }
|
|
107
|
+
getBlocks() { return (this._resolvedBlocks ??= typeof this._blocks === 'function' ? this._blocks() : this._blocks); }
|
|
108
|
+
getBlock(name) { return typeof name === 'string' ? this.getBlocks().find((block) => block.name === name) : undefined; }
|
|
109
|
+
/** Blocs proposés dans le sélecteur (une sous-classe peut en retirer). */
|
|
110
|
+
getBlockPickerBlocks() { return this.getBlocks(); }
|
|
111
|
+
// --- état
|
|
112
|
+
/** Éléments bien formés : clé simple, type déclaré, données en objet. Le reste de ce qu'envoie le navigateur est ignoré. */
|
|
113
|
+
getItems() {
|
|
114
|
+
const state = this.context().state;
|
|
115
|
+
if (!state || typeof state !== 'object' || Array.isArray(state))
|
|
116
|
+
return {};
|
|
117
|
+
const items = {};
|
|
118
|
+
for (const [key, raw] of Object.entries(state).slice(0, this._maxItems)) {
|
|
119
|
+
if (!isItemKey(key) || !raw || typeof raw !== 'object' || !this.getBlock(raw.type))
|
|
120
|
+
continue;
|
|
121
|
+
items[key] = { type: raw.type, data: raw.data && typeof raw.data === 'object' && !Array.isArray(raw.data) ? raw.data : {} };
|
|
122
|
+
}
|
|
123
|
+
return items;
|
|
124
|
+
}
|
|
125
|
+
setItems(items) { writeState(this.livewire, this.getStatePath().split('.'), items); }
|
|
126
|
+
getDefaultState() { return {}; }
|
|
127
|
+
/**
|
|
128
|
+
* Valeur enregistrée (liste `[{ type, data }]`, comme Filament, éventuellement en JSON) vers l'état du formulaire (table par clé).
|
|
129
|
+
* Les champs de chaque bloc hydratent leur propre valeur.
|
|
130
|
+
*/
|
|
131
|
+
hydrateState(value) {
|
|
132
|
+
if (typeof value === 'string') {
|
|
133
|
+
try {
|
|
134
|
+
value = JSON.parse(value);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
value = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!value || typeof value !== 'object')
|
|
141
|
+
return {};
|
|
142
|
+
const entries = Array.isArray(value) ? value.map((raw) => [this._uuid(), raw]) : Object.entries(value);
|
|
143
|
+
const items = {};
|
|
144
|
+
for (const [key, raw] of entries.slice(0, this._maxItems)) {
|
|
145
|
+
const block = raw && typeof raw === 'object' ? this.getBlock(raw.type) : undefined;
|
|
146
|
+
if (!block || !isItemKey(key))
|
|
147
|
+
continue;
|
|
148
|
+
const source = raw.data && typeof raw.data === 'object' && !Array.isArray(raw.data) ? raw.data : {};
|
|
149
|
+
const data = { ...source };
|
|
150
|
+
for (const component of block.getComponents({ builder: this, itemKey: key, data: source })) {
|
|
151
|
+
if (component instanceof Field)
|
|
152
|
+
data[component.getName()] = component.hydrateState(source[component.getName()] ?? null);
|
|
153
|
+
}
|
|
154
|
+
items[key] = { type: block.name, data };
|
|
155
|
+
}
|
|
156
|
+
return items;
|
|
157
|
+
}
|
|
158
|
+
_storedAsList = true;
|
|
159
|
+
/** `false` : la valeur enregistrée reste la table par clé de l'état (filtres), au lieu de la liste `[{ type, data }]`. */
|
|
160
|
+
storedAsList(condition = true) { this._storedAsList = condition; return this; }
|
|
161
|
+
finalizeDehydratedState(state) {
|
|
162
|
+
if (!this._storedAsList || !state || typeof state !== 'object' || Array.isArray(state))
|
|
163
|
+
return state;
|
|
164
|
+
return Object.values(state);
|
|
165
|
+
}
|
|
166
|
+
dehydrateState() { return Object.fromEntries(Object.entries(this.getItems()).map(([key, item]) => [key, { type: item.type, data: {} }])); }
|
|
167
|
+
/** Schéma d'un élément : clé `<builder>.<élément>.data`, état au même chemin (comme Filament). */
|
|
168
|
+
itemSchema(itemKey, item) {
|
|
169
|
+
const container = this.container;
|
|
170
|
+
return new Schema(container.livewire).key(`${this.getKey()}.${itemKey}.data`).statePath(`${this.getStatePath()}.${itemKey}.data`).operation(container.getOperation()).record(container.getRecord()).root(false)
|
|
171
|
+
.components(this.getBlock(item.type).getComponents({ builder: this, itemKey, data: item.data }));
|
|
172
|
+
}
|
|
173
|
+
getChildSchemas() { return Object.entries(this.getItems()).map(([key, item]) => this.itemSchema(key, item)); }
|
|
174
|
+
newItem(block) {
|
|
175
|
+
const defaults = block.getDefaults(this);
|
|
176
|
+
if (defaults)
|
|
177
|
+
return { type: block.name, data: defaults };
|
|
178
|
+
const data = {};
|
|
179
|
+
const item = { type: block.name, data };
|
|
180
|
+
fillSchema(this.itemSchema('new', item), data);
|
|
181
|
+
return item;
|
|
182
|
+
}
|
|
183
|
+
/** Une sous-classe peut refuser l'ajout (plafond de règles, par exemple). */
|
|
184
|
+
canAdd(_block) { return Object.keys(this.getItems()).length < this._maxItems; }
|
|
185
|
+
// --- actions du composant
|
|
186
|
+
getActions() {
|
|
187
|
+
const insert = (items, after, key, item) => {
|
|
188
|
+
if (after === null || !Object.hasOwn(items, after))
|
|
189
|
+
return { ...items, [key]: item };
|
|
190
|
+
return Object.fromEntries(Object.entries(items).flatMap(([k, v]) => (k === after ? [[k, v], [key, item]] : [[k, v]])));
|
|
191
|
+
};
|
|
192
|
+
const move = (offset) => ({ arguments: args }) => {
|
|
193
|
+
const items = this.getItems();
|
|
194
|
+
const keys = Object.keys(items);
|
|
195
|
+
const from = keys.indexOf(String(args.item));
|
|
196
|
+
const to = from + offset;
|
|
197
|
+
if (from < 0 || to < 0 || to >= keys.length)
|
|
198
|
+
return;
|
|
199
|
+
[keys[from], keys[to]] = [keys[to], keys[from]];
|
|
200
|
+
this.setItems(Object.fromEntries(keys.map((key) => [key, items[key]])));
|
|
201
|
+
};
|
|
202
|
+
return [
|
|
203
|
+
...this.getHintActions(),
|
|
204
|
+
Action.make('add').action(({ arguments: args }) => {
|
|
205
|
+
const block = this.getBlockPickerBlocks().find((candidate) => candidate.name === args.block);
|
|
206
|
+
if (!block || !this.canAdd(block))
|
|
207
|
+
return;
|
|
208
|
+
this.setItems(insert(this.getItems(), typeof args.afterItem === 'string' ? args.afterItem : null, this._uuid(), this.newItem(block)));
|
|
209
|
+
}),
|
|
210
|
+
Action.make('addBetween').action(({ arguments: args }) => {
|
|
211
|
+
const block = this.getBlockPickerBlocks().find((candidate) => candidate.name === args.block);
|
|
212
|
+
const items = this.getItems();
|
|
213
|
+
if (!this._addBetween || !block || !this.canAdd(block) || typeof args.afterItem !== 'string' || !Object.hasOwn(items, args.afterItem))
|
|
214
|
+
return;
|
|
215
|
+
this.setItems(insert(items, args.afterItem, this._uuid(), this.newItem(block)));
|
|
216
|
+
}),
|
|
217
|
+
Action.make('delete').action(({ arguments: args }) => { const items = this.getItems(); const key = String(args.item); if (!Object.hasOwn(items, key))
|
|
218
|
+
return; delete items[key]; this.setItems(items); }),
|
|
219
|
+
Action.make('clone').action(({ arguments: args }) => {
|
|
220
|
+
const items = this.getItems();
|
|
221
|
+
const key = String(args.item);
|
|
222
|
+
if (!this._cloneable || !Object.hasOwn(items, key) || !this.canAdd(this.getBlock(items[key].type)))
|
|
223
|
+
return;
|
|
224
|
+
this.setItems(insert(items, key, this._uuid(), this.cloneItem(items[key])));
|
|
225
|
+
}),
|
|
226
|
+
Action.make('moveUp').action(move(-1)),
|
|
227
|
+
Action.make('moveDown').action(move(1)),
|
|
228
|
+
// Glisser-déposer : le nouvel ordre n'est accepté que si c'est une permutation des clés existantes.
|
|
229
|
+
Action.make('reorder').action(({ arguments: args }) => {
|
|
230
|
+
const items = this.getItems();
|
|
231
|
+
const keys = Object.keys(items);
|
|
232
|
+
const order = Array.isArray(args.items) ? args.items.map(String) : [];
|
|
233
|
+
if (order.length !== keys.length || new Set(order).size !== keys.length || !order.every((key) => Object.hasOwn(items, key)))
|
|
234
|
+
return;
|
|
235
|
+
this.setItems(Object.fromEntries(order.map((key) => [key, items[key]])));
|
|
236
|
+
}),
|
|
237
|
+
];
|
|
238
|
+
}
|
|
239
|
+
/** Copie d'un élément ; une sous-classe renouvelle les clés des éléments imbriqués. */
|
|
240
|
+
cloneItem(item) { return JSON.parse(JSON.stringify(item)); }
|
|
241
|
+
/** Expression `mountAction(...)` d'une action de ce composant. */
|
|
242
|
+
actionExpression(action, args) {
|
|
243
|
+
const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: this.getKey() };
|
|
244
|
+
return `mountAction(${js(action)}, ${args ? js(args) : '{}'}, ${js(context)})`;
|
|
245
|
+
}
|
|
246
|
+
blockPickerHtml(afterItem = null) {
|
|
247
|
+
const key = this.getKey();
|
|
248
|
+
const blocks = this.getBlockPickerBlocks();
|
|
249
|
+
const tr = (name) => t(`filament-forms::components.builder.${name}`);
|
|
250
|
+
const between = afterItem !== null;
|
|
251
|
+
const actionName = between ? 'addBetween' : 'add';
|
|
252
|
+
const alignment = between ? null : this._addAlignment;
|
|
253
|
+
const placement = alignment === 'start' ? 'bottom-start' : alignment === 'end' ? 'bottom-end' : null;
|
|
254
|
+
const widthClass = this._pickerWidth ? `fi-width-${this._pickerWidth}` : null;
|
|
255
|
+
const signature = `${widthClass ?? ''}|${placement ?? ''}|${blocks.length ? 1 : 0}|0`;
|
|
256
|
+
const wireKey = `${key}.${actionName}.${afterItem ?? ''}.block-picker.${PICKER_HASHES[signature] ?? createHash('md5').update(signature).digest('hex')}`;
|
|
257
|
+
const items = blocks.map((block) => {
|
|
258
|
+
const expression = `mountAction('${actionName}', ${js(between ? { block: block.name, afterItem } : { block: block.name })}, { schemaComponent: ${js(key)} })`;
|
|
259
|
+
const blockIcon = block.getIcon();
|
|
260
|
+
return `<button x-on:click="close" wire:click="${e(expression)}" type="button" wire:loading.attr="disabled" wire:target="${e(expression)}" class="fi-dropdown-list-item">${blockIcon ? icon(blockIcon, { attributes: new Attributes({ 'wire:loading.remove.delay.default': '1', 'wire:target': expression }) }) + loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': expression })) : ''}<span class="fi-dropdown-list-item-label">${e(block.getLabel())}</span></button>`;
|
|
261
|
+
});
|
|
262
|
+
const grid = this._pickerColumns ? ` class="fi-grid fi-grid-direction-col lg:fi-grid-cols" style="--cols-lg: ${this._pickerColumns}; --cols-default: 1;"` : ' class="fi-grid fi-grid-direction-col" style="--cols-default: 1;"';
|
|
263
|
+
const list = blocks.length ? `<div class="fi-dropdown-list"><div><div${grid}>${items.join('')}</div></div></div>` : '';
|
|
264
|
+
const addIcon = !between && this._addIcon ? icon(this._addIcon, { size: 'sm' }) : '';
|
|
265
|
+
const triggerLabel = between
|
|
266
|
+
? (this._addBetweenLabel ?? tr('actions.add_between.label'))
|
|
267
|
+
: (this._addLabel ?? tr('actions.add.label').replace(':label', this._labelText.replace(/^./, (c) => c.toLowerCase())).trim());
|
|
268
|
+
return `<div x-data="filamentDropdown({ isMenu: true })" wire:key="${e(wireKey)}" class="${cls(['fi-dropdown', 'fi-fo-builder-block-picker', alignment ? `fi-align-${alignment}` : null])}"><div x-on:mousedown="if ($event.button === 0) toggle($event)" class="fi-dropdown-trigger"><button type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="fi-ac-btn-action fi-btn fi-size-sm">${addIcon}${e(triggerLabel)}</button></div><div x-cloak="x-cloak" x-ref="panel" wire:key="${e(wireKey)}.panel" wire:ignore.self="" x-transition:enter-start="fi-opacity-0" x-transition:leave-end="fi-opacity-0" x-float${placement ? `.placement.${placement}` : ''}.flip.shift.offset="{ offset: 8 }" class="${cls(['fi-dropdown-panel', widthClass])}">${list}</div></div>`;
|
|
269
|
+
}
|
|
270
|
+
_labelText = '';
|
|
271
|
+
async toHtml() {
|
|
272
|
+
const id = this.getId();
|
|
273
|
+
const key = this.getKey();
|
|
274
|
+
this._labelText = String((await this.getLabel()) ?? '');
|
|
275
|
+
const livewireKey = this.getLivewireKey() ?? '';
|
|
276
|
+
const items = this.getItems();
|
|
277
|
+
const keys = Object.keys(items);
|
|
278
|
+
const tr = (name, replace = {}) => t(`filament-forms::components.builder.${name}`, replace);
|
|
279
|
+
const descriptionId = (itemKey) => `${id}-item-${createHash('md5').update(itemKey).digest('hex')}`;
|
|
280
|
+
const danger = colorsOf(this.livewire)?.getComponentClasses('icon-button', 'danger') ?? [];
|
|
281
|
+
const rows = [];
|
|
282
|
+
for (const [index, itemKey] of keys.entries()) {
|
|
283
|
+
const item = items[itemKey];
|
|
284
|
+
const block = this.getBlock(item.type);
|
|
285
|
+
let header = '';
|
|
286
|
+
if (this._blockHeaders) {
|
|
287
|
+
const moveButton = (action, label, iconName, direction, disabled) => {
|
|
288
|
+
const expression = this.actionExpression(action, { item: itemKey });
|
|
289
|
+
return `<li x-on:click.stop class="fi-sr-only">${iconActionButton({ inHeader: true, label, icon: iconName, expression, disabled, attributes: { tabindex: '-1', 'data-reorder-hidden': 'data-reorder-hidden', 'data-reorder-direction': direction, 'data-reorder-action': expression, 'aria-describedby': descriptionId(itemKey) } })}</li>`;
|
|
290
|
+
};
|
|
291
|
+
const blockIcon = block.getIcon();
|
|
292
|
+
header = `<div class="fi-fo-builder-item-header"><ul class="fi-fo-builder-item-header-start-actions"><li 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="fi-ac-icon-btn-action fi-icon-btn fi-size-sm" x-sortable-handle="x-sortable-handle">${icon('m-arrows-up-down')}</button></li>${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)}</ul>${this._blockIcons && blockIcon ? icon(blockIcon, { attributes: new Attributes().class(['fi-fo-builder-item-header-icon']) }) : ''}<${this._headingTag} class="fi-fo-builder-item-header-label fi-truncated">${e(block.getLabel())}${this._blockNumbers ? ` ${index + 1}` : ''}</${this._headingTag}><ul class="fi-fo-builder-item-header-end-actions">${this._cloneable ? `<li x-on:click.stop>${iconActionButton({ inHeader: true, label: tr('actions.clone.label'), icon: 'm-square-2-stack', expression: this.actionExpression('clone', { item: itemKey }) })}</li>` : ''}<li x-on:click.stop>${iconActionButton({ inHeader: true, label: tr('actions.delete.label'), icon: 'm-trash', expression: this.actionExpression('delete', { item: itemKey }), colorClasses: danger })}</li></ul></div>`;
|
|
293
|
+
}
|
|
294
|
+
rows.push(`<li wire:ignore.self wire:key="${e(livewireKey)}.${e(itemKey)}.data.item" x-data="item(${e(js({ isCollapsed: false, collapseKey: null }))})" x-on:builder-expand.window="expandFromEvent" x-on:builder-collapse.window="collapseFromEvent" x-on:expand="expand" x-sortable-item="${e(itemKey)}" class="${cls(['fi-fo-builder-item', { 'fi-fo-builder-item-has-header': this._blockHeaders }])}" x-bind:class="itemClasses">${header}<div x-show="! isCollapsed" class="fi-fo-builder-item-content">${await this.itemSchema(itemKey, item).toHtml()}</div></li>`);
|
|
295
|
+
if (index < keys.length - 1 && this._addBetween && this.canAdd(block))
|
|
296
|
+
rows.push(`<li class="fi-fo-builder-add-between-items-ctn"><div class="fi-fo-builder-add-between-items"><div class="fi-fo-builder-block-picker-ctn">${this.blockPickerHtml(itemKey)}</div></div></li>`);
|
|
297
|
+
else if (index < keys.length - 1 && this._labelBetweenItems)
|
|
298
|
+
rows.push(`<li class="fi-fo-builder-label-between-items-ctn"><div class="fi-fo-builder-label-between-items-divider-before"></div><span class="fi-fo-builder-label-between-items">${e(this._labelBetweenItems)}</span><div class="fi-fo-builder-label-between-items-divider-after"></div></li>`);
|
|
299
|
+
}
|
|
300
|
+
const config = { statePath: this.getStatePath(), schemaComponent: key, moveUpAction: 'moveUp', moveDownAction: 'moveDown', reorderAction: 'reorder', message: tr('reordering.moved'), hasCustomReorderAction: false };
|
|
301
|
+
const descriptions = keys.map((itemKey, index) => {
|
|
302
|
+
const label = this.getBlock(items[itemKey].type).getLabel().trim() || tr('reordering.item');
|
|
303
|
+
return `<span class="fi-sr-only" data-reorder-description="${e(itemKey)}" data-reorder-label="${e(label)}" id="${e(descriptionId(itemKey))}">${e(tr('reordering.position', { label, position: String(index + 1), count: String(keys.length) }))}</span>`;
|
|
304
|
+
}).join('');
|
|
305
|
+
const list = keys.length ? `<ul x-sortable x-ref="reorderItems" data-sortable-animation-duration="300" x-on:end.stop="reorder" class="fi-fo-builder-items">${rows.join('')}</ul>` : '';
|
|
306
|
+
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('builder'))}" x-data="builderFormComponent(${e(js(config))})" class="${cls([this._extraClass, 'fi-fo-builder'])}"><span wire:ignore x-ref="reorderStatus" class="fi-sr-only" role="status" aria-live="polite" aria-atomic="true"></span>${list}${this.blockPickerHtml()}${descriptions}</div>`;
|
|
307
|
+
return this.wrap(html, { labelAsDiv: true });
|
|
308
|
+
}
|
|
309
|
+
}
|