@navette-js/actions 1.0.0-beta.3 → 1.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.d.ts +179 -11
- package/lib/index.js +302 -20
- package/package.json +3 -3
- package/views/modal.ejs +10 -7
package/lib/index.d.ts
CHANGED
|
@@ -15,8 +15,24 @@ export type ActionCallContext = {
|
|
|
15
15
|
livewire: any;
|
|
16
16
|
record: any;
|
|
17
17
|
arguments: Record<string, unknown>;
|
|
18
|
-
data: Record<string, unknown>;
|
|
18
|
+
data: Record<string, unknown>; /** The action being run. */
|
|
19
|
+
action?: Action; /** Form data of the parent action, for an action of its modal footer (changes are kept). */
|
|
20
|
+
parentData?: Record<string, unknown>; /** For an action of a form component (affix action...): the component's state. */
|
|
21
|
+
state?: unknown; /** For an action of a form component: changes a field of the same form (`set('slug', value)`). */
|
|
22
|
+
set?: (name: string, value: unknown) => void;
|
|
23
|
+
get?: (name: string) => unknown; /** The modal's form, once validated (to save its relationships). */
|
|
24
|
+
schema?: any;
|
|
19
25
|
};
|
|
26
|
+
/** Message of a bulk notification, from the number of records concerned and the number of selected records. */
|
|
27
|
+
export type BulkMessage = string | ((count: number, total: number) => string);
|
|
28
|
+
/**
|
|
29
|
+
* Thrown by `action.halt()` (the modal stays open) and `action.cancel()` (the modal closes) to stop an action from one of
|
|
30
|
+
* its hooks. The host component catches it.
|
|
31
|
+
*/
|
|
32
|
+
export declare class ActionHalt extends Error {
|
|
33
|
+
readonly closesModal: boolean;
|
|
34
|
+
constructor(closesModal: boolean);
|
|
35
|
+
}
|
|
20
36
|
export declare class Action implements Renderable {
|
|
21
37
|
/** Default setup for every instance created afterwards by `make()`, e.g. `Action.configureUsing((action) => action.size('sm'))`. */
|
|
22
38
|
static configureUsing<T>(this: abstract new (...args: any[]) => T, configure: (instance: T) => void): void;
|
|
@@ -66,8 +82,6 @@ export declare class Action implements Renderable {
|
|
|
66
82
|
/** Icon used when the action is a table row link. */
|
|
67
83
|
tableIcon(name: string | null): this;
|
|
68
84
|
private _iconButton;
|
|
69
|
-
/** Icon-only row action; the label becomes the tooltip and accessible name. */
|
|
70
|
-
iconButton(condition?: boolean): this;
|
|
71
85
|
/** Icon used when the action is a menu item (ActionGroup). */
|
|
72
86
|
groupedIcon(name: string | null): this;
|
|
73
87
|
requiresConfirmation(condition?: boolean): this;
|
|
@@ -77,8 +91,7 @@ export declare class Action implements Renderable {
|
|
|
77
91
|
modalIcon(name: string | null): this;
|
|
78
92
|
private _badge;
|
|
79
93
|
private _badgeColor;
|
|
80
|
-
/**
|
|
81
|
-
badge(badge: string | number | null | (() => string | number | null | Promise<string | number | null>)): this;
|
|
94
|
+
/** Color of the counter set with `badge(value)`. */
|
|
82
95
|
badgeColor(color: string): this;
|
|
83
96
|
private _slideOver;
|
|
84
97
|
private _hasSubmit;
|
|
@@ -122,8 +135,48 @@ export declare class Action implements Renderable {
|
|
|
122
135
|
schema(components: any[] | ((schema: any) => any), columns?: Record<string, number> | number | null): this;
|
|
123
136
|
/** Applies the action's form to the host's schema; the function returns either components or the configured schema. */
|
|
124
137
|
applySchema(schema: any): any | null;
|
|
125
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* Extra buttons in the modal footer, after the submit button. An action with an `action()` function runs on top of this
|
|
140
|
+
* one (with its own modal, if it has one), and gets this action's form data as `parentData`.
|
|
141
|
+
*/
|
|
126
142
|
extraModalFooterActions(actions: Action[]): this;
|
|
143
|
+
getExtraModalFooterActions(): Action[];
|
|
144
|
+
private _closeByClickingAway;
|
|
145
|
+
private _closeByEscaping;
|
|
146
|
+
private _closeButton;
|
|
147
|
+
private _stickyHeader;
|
|
148
|
+
private _stickyFooter;
|
|
149
|
+
private _modalAlignment;
|
|
150
|
+
private _footerAlignment;
|
|
151
|
+
private _modalHidden;
|
|
152
|
+
private _mountUsing;
|
|
153
|
+
/** `false`: clicking outside the modal does not close it. */
|
|
154
|
+
closeModalByClickingAway(condition?: boolean): this;
|
|
155
|
+
/** `false`: the Escape key does not close the modal. */
|
|
156
|
+
closeModalByEscaping(condition?: boolean): this;
|
|
157
|
+
/** `false`: no close button in the modal header. */
|
|
158
|
+
modalCloseButton(condition?: boolean): this;
|
|
159
|
+
/** Keeps the modal header visible while its content scrolls. */
|
|
160
|
+
stickyModalHeader(condition?: boolean): this;
|
|
161
|
+
/** Keeps the modal footer visible while its content scrolls. */
|
|
162
|
+
stickyModalFooter(condition?: boolean): this;
|
|
163
|
+
modalAlignment(alignment: 'start' | 'center' | null): this;
|
|
164
|
+
modalFooterActionsAlignment(alignment: 'start' | 'center' | 'end' | null): this;
|
|
165
|
+
private _steps;
|
|
166
|
+
/** Builds the wizard of `steps()`: registered by `@navette-js/forms`, which owns the `Wizard` component. */
|
|
167
|
+
static stepsRenderer: ((steps: any[], submit: Action, cancel: Action) => any) | null;
|
|
168
|
+
/**
|
|
169
|
+
* Turns the modal form into a wizard: each step is validated before the next one, and the last step submits the action.
|
|
170
|
+
* `steps` are `Step` components of `@navette-js/forms`.
|
|
171
|
+
*/
|
|
172
|
+
steps(steps: any[] | null): this;
|
|
173
|
+
isWizard(): boolean;
|
|
174
|
+
/** Runs the action without its modal (e.g. no confirmation for some records). */
|
|
175
|
+
modalHidden(condition?: Lazy<boolean, ActionCallContext>): this;
|
|
176
|
+
isModalHidden(ctx: ActionCallContext): Promise<boolean>;
|
|
177
|
+
/** Runs when the action is mounted, after the form is filled: `ctx.data` is the form state, which you may change. */
|
|
178
|
+
mountUsing(callback: ((ctx: ActionCallContext) => unknown) | null): this;
|
|
179
|
+
runMountUsing(ctx: ActionCallContext): Promise<void>;
|
|
127
180
|
/** Initial form state (e.g. from the record). */
|
|
128
181
|
fillForm(resolver: (ctx: ActionCallContext) => Record<string, unknown> | Promise<Record<string, unknown>>): this;
|
|
129
182
|
/** Modal width (`xs` … `7xl`). Defaults to `md` for a confirmation and `4xl` for a form. */
|
|
@@ -140,6 +193,56 @@ export declare class Action implements Renderable {
|
|
|
140
193
|
before(fn: (ctx: ActionCallContext) => unknown | Promise<unknown>): this;
|
|
141
194
|
/** Runs after the handler succeeds (logging, extra notification…). */
|
|
142
195
|
after(fn: (ctx: ActionCallContext) => unknown | Promise<unknown>): this;
|
|
196
|
+
private _individualAuthorization;
|
|
197
|
+
private _chunkSize;
|
|
198
|
+
private _deselectAfterCompletion;
|
|
199
|
+
private _successNotificationTitle;
|
|
200
|
+
private _failureNotificationTitle;
|
|
201
|
+
private _missingAuthorizationMessage;
|
|
202
|
+
private _missingProcessingMessage;
|
|
203
|
+
/**
|
|
204
|
+
* Bulk action: each selected record must pass this policy ability (or function), otherwise it is skipped and counted in the
|
|
205
|
+
* failure notification. Used by `processSelectedRecords()` of the table's page.
|
|
206
|
+
*/
|
|
207
|
+
authorizeIndividualRecords(ability: string | ((record: any, ctx: ActionCallContext) => boolean | Promise<boolean>) | null): this;
|
|
208
|
+
getIndividualRecordsAuthorization(): string | ((record: any, ctx: ActionCallContext) => boolean | Promise<boolean>) | null;
|
|
209
|
+
/** Number of selected records loaded at a time by `processSelectedRecords()` (100 by default). */
|
|
210
|
+
chunkSelectedRecords(size: number): this;
|
|
211
|
+
getSelectedRecordsChunkSize(): number;
|
|
212
|
+
/** Clears the table selection once the bulk action has run. */
|
|
213
|
+
deselectRecordsAfterCompletion(condition?: boolean): this;
|
|
214
|
+
shouldDeselectRecordsAfterCompletion(): boolean;
|
|
215
|
+
/** Notification sent when every selected record was processed. */
|
|
216
|
+
successNotificationTitle(title: string | null): this;
|
|
217
|
+
getSuccessNotificationTitle(): string | null;
|
|
218
|
+
/** Notification title when some records failed: receives the number of processed records and the number selected. */
|
|
219
|
+
failureNotificationTitle(title: BulkMessage | null): this;
|
|
220
|
+
getFailureNotificationTitle(success: number, total: number): string | null;
|
|
221
|
+
/** Body line for records skipped by `authorizeIndividualRecords()`: receives their number and the number selected. */
|
|
222
|
+
missingBulkAuthorizationFailureNotificationMessage(message: BulkMessage | null): this;
|
|
223
|
+
getMissingBulkAuthorizationFailureNotificationMessage(count: number, total: number): string | null;
|
|
224
|
+
/** Body line for records whose processing failed: receives their number and the number selected. */
|
|
225
|
+
missingBulkProcessingFailureNotificationMessage(message: BulkMessage | null): this;
|
|
226
|
+
getMissingBulkProcessingFailureNotificationMessage(count: number, total: number): string | null;
|
|
227
|
+
private _successNotification;
|
|
228
|
+
private _successRedirectUrl;
|
|
229
|
+
private _preserveData;
|
|
230
|
+
/**
|
|
231
|
+
* Customizes the success notification of a prebuilt action: the function receives the notification and returns it (changed)
|
|
232
|
+
* or `null` to send nothing.
|
|
233
|
+
*/
|
|
234
|
+
successNotification(customize: ((notification: any, ctx: ActionCallContext) => any) | null): this;
|
|
235
|
+
getSuccessNotificationCustomizer(): ((notification: any, ctx: ActionCallContext) => any) | null;
|
|
236
|
+
/** Where a prebuilt action redirects after success: a URL, or a function of the context (record, replica...). */
|
|
237
|
+
successRedirectUrl(url: string | ((ctx: ActionCallContext & Record<string, unknown>) => string | null | Promise<string | null>) | null): this;
|
|
238
|
+
getSuccessRedirectUrl(ctx: ActionCallContext & Record<string, unknown>): Promise<string | null>;
|
|
239
|
+
/** "Create & create another": fields kept in the new form (names, or a function returning the data to keep). */
|
|
240
|
+
preserveFormDataWhenCreatingAnother(fields: string[] | ((data: Record<string, unknown>) => Record<string, unknown>) | null): this;
|
|
241
|
+
getDataPreservedForAnother(data: Record<string, unknown>): Record<string, unknown>;
|
|
242
|
+
/** Stops the action from one of its hooks; the modal stays open. */
|
|
243
|
+
halt(): never;
|
|
244
|
+
/** Stops the action from one of its hooks and closes the modal. */
|
|
245
|
+
cancel(): never;
|
|
143
246
|
call(ctx: ActionCallContext): Promise<unknown>;
|
|
144
247
|
/** Menu item of a table bulk action. The click goes through the table's Alpine `mountAction()`, which sends the selection. */
|
|
145
248
|
toGroupedHtml(ctx: ComponentContext, context: Record<string, unknown>): Promise<string>;
|
|
@@ -151,7 +254,7 @@ export declare class Action implements Renderable {
|
|
|
151
254
|
/** Small link (or icon button) for a table row action. */
|
|
152
255
|
toLinkHtml(ctx: ComponentContext, context: Record<string, unknown>, wireKey: string): Promise<string>;
|
|
153
256
|
/** Modal of a mounted action; `content` is the rendered form, if any. */
|
|
154
|
-
toModalHtml(ctx: ActionCallContext, index: number, content?: string | null): Promise<string>;
|
|
257
|
+
toModalHtml(ctx: ActionCallContext, index: number, content?: string | null, mountContext?: Record<string, unknown>): Promise<string>;
|
|
155
258
|
alpineClickHandler(handler: string | null): this;
|
|
156
259
|
private _livewireTarget;
|
|
157
260
|
private _iconAfter;
|
|
@@ -169,15 +272,80 @@ export declare class Action implements Renderable {
|
|
|
169
272
|
/** Shown but unusable; the server also refuses to mount it. */
|
|
170
273
|
disabled(condition?: Lazy<boolean, ComponentContext>): this;
|
|
171
274
|
isDisabled(ctx: ComponentContext): Promise<boolean>;
|
|
275
|
+
private _view;
|
|
276
|
+
private _labeledFrom;
|
|
277
|
+
private _outlined;
|
|
278
|
+
/** Trigger style, instead of the one chosen by the location of the action. */
|
|
279
|
+
button(): this;
|
|
280
|
+
link(): this;
|
|
281
|
+
/** Icon only: the label becomes the tooltip and accessible name. */
|
|
282
|
+
iconButton(condition?: boolean): this;
|
|
283
|
+
badge(badge?: string | number | null | (() => string | number | null | Promise<string | number | null>)): this;
|
|
284
|
+
/** Icon button on small screens, labeled button from this breakpoint up (`sm`, `md`, `lg`, `xl`, `2xl`). */
|
|
285
|
+
labeledFrom(breakpoint: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | null): this;
|
|
286
|
+
/** Outlined button. */
|
|
287
|
+
outlined(condition?: boolean): this;
|
|
288
|
+
getView(): 'button' | 'link' | 'icon-button' | 'badge' | null;
|
|
289
|
+
/** Markup of a link, icon button or badge trigger; `attributes` carries the click (or href) behavior. */
|
|
290
|
+
private styledTriggerHtml;
|
|
172
291
|
/** Renders the action as a button (or as a link when it has a URL). */
|
|
173
292
|
toHtml(ctx: ComponentContext, explicitMountContext?: Record<string, unknown> | null): Promise<string>;
|
|
174
293
|
}
|
|
175
294
|
/** Group of actions collapsed into a dropdown behind a "⋮" icon button. */
|
|
295
|
+
/**
|
|
296
|
+
* Actions in a dropdown menu ("⋮" icon button by default). A nested group with `dropdown(false)` is shown as a section of
|
|
297
|
+
* the menu, separated by a divider. `buttonGroup()` shows the actions side by side instead.
|
|
298
|
+
*/
|
|
176
299
|
export declare class ActionGroup {
|
|
177
300
|
private actions;
|
|
178
|
-
|
|
179
|
-
|
|
301
|
+
private _label;
|
|
302
|
+
private _icon;
|
|
303
|
+
private _color;
|
|
304
|
+
private _size;
|
|
305
|
+
private _tooltip;
|
|
306
|
+
private _view;
|
|
307
|
+
private _dropdown;
|
|
308
|
+
private _buttonGroup;
|
|
309
|
+
private _placement;
|
|
310
|
+
private _width;
|
|
311
|
+
private _offset;
|
|
312
|
+
private _maxHeight;
|
|
313
|
+
private _hidden;
|
|
314
|
+
constructor(actions: (Action | ActionGroup)[]);
|
|
315
|
+
static make(actions: (Action | ActionGroup)[]): ActionGroup;
|
|
316
|
+
/** Every action of the group, nested groups included. */
|
|
180
317
|
getActions(): Action[];
|
|
181
|
-
|
|
182
|
-
|
|
318
|
+
getName(): string;
|
|
319
|
+
/** A group is never mounted: its actions are. */
|
|
320
|
+
isMountable(): boolean;
|
|
321
|
+
label(label: string | null): this;
|
|
322
|
+
icon(icon: string): this;
|
|
323
|
+
color(color: string): this;
|
|
324
|
+
size(size: string): this;
|
|
325
|
+
tooltip(tooltip: string | null): this;
|
|
326
|
+
/** Labeled button trigger. */
|
|
327
|
+
button(): this;
|
|
328
|
+
link(): this;
|
|
329
|
+
iconButton(): this;
|
|
330
|
+
/** `false`: inside another group, the actions form a section of its menu (with a divider). */
|
|
331
|
+
dropdown(condition?: boolean): this;
|
|
332
|
+
hasDropdown(): boolean;
|
|
333
|
+
/** The actions side by side, as a group of buttons. */
|
|
334
|
+
buttonGroup(condition?: boolean): this;
|
|
335
|
+
/** Menu placement: `bottom-start`, `bottom-end`, `top-start`... (`bottom-end` in tables). */
|
|
336
|
+
dropdownPlacement(placement: string | null): this;
|
|
337
|
+
/** Menu width: `xs` to `7xl`. */
|
|
338
|
+
dropdownWidth(width: string | null): this;
|
|
339
|
+
/** Distance between the trigger and the menu, in pixels (8 by default). */
|
|
340
|
+
dropdownOffset(offset: number | null): this;
|
|
341
|
+
/** Maximum height of the menu, as a CSS length; it scrolls beyond. */
|
|
342
|
+
maxHeight(height: string | null): this;
|
|
343
|
+
hidden(condition?: Lazy<boolean, ComponentContext>): this;
|
|
344
|
+
visible(condition?: Lazy<boolean, ComponentContext>): this;
|
|
345
|
+
isVisible(ctx: ComponentContext): Promise<boolean>;
|
|
346
|
+
private triggerHtml;
|
|
347
|
+
/** Menu items of the visible actions, one list per section (nested groups without dropdown). */
|
|
348
|
+
private itemLists;
|
|
349
|
+
/** `keyOf` returns each action's `wire:key` (it depends on the record in a table). */
|
|
350
|
+
toHtml(ctx: ComponentContext, explicitContext?: Record<string, unknown> | null, keyOf?: (action: Action) => string): Promise<string>;
|
|
183
351
|
}
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @navette-js/actions — actions: buttons, links, menu items and modal actions.
|
|
2
|
-
import { Attributes, e, evaluate, icon, js, loadingIndicator, renderHook, views, configured, configureUsing } from '@navette-js/support';
|
|
2
|
+
import { Attributes, cls, e, evaluate, icon, js, loadingIndicator, renderHook, views, configured, configureUsing } from '@navette-js/support';
|
|
3
3
|
import { t } from '@navette-js/support/i18n';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
views.namespace('filament-actions', fileURLToPath(new URL('../views', import.meta.url)));
|
|
@@ -14,6 +14,14 @@ export const ActionsRenderHook = {
|
|
|
14
14
|
};
|
|
15
15
|
let colorManager = null;
|
|
16
16
|
export const useColorManager = (manager) => { colorManager = manager; };
|
|
17
|
+
/**
|
|
18
|
+
* Thrown by `action.halt()` (the modal stays open) and `action.cancel()` (the modal closes) to stop an action from one of
|
|
19
|
+
* its hooks. The host component catches it.
|
|
20
|
+
*/
|
|
21
|
+
export class ActionHalt extends Error {
|
|
22
|
+
closesModal;
|
|
23
|
+
constructor(closesModal) { super(closesModal ? 'Action cancelled.' : 'Action halted.'); this.closesModal = closesModal; }
|
|
24
|
+
}
|
|
17
25
|
export class Action {
|
|
18
26
|
/** Default setup for every instance created afterwards by `make()`, e.g. `Action.configureUsing((action) => action.size('sm'))`. */
|
|
19
27
|
static configureUsing(configure) { configureUsing(this, configure); }
|
|
@@ -88,8 +96,6 @@ export class Action {
|
|
|
88
96
|
/** Icon used when the action is a table row link. */
|
|
89
97
|
tableIcon(name) { this._tableIcon = name; return this; }
|
|
90
98
|
_iconButton = false;
|
|
91
|
-
/** Icon-only row action; the label becomes the tooltip and accessible name. */
|
|
92
|
-
iconButton(condition = true) { this._iconButton = condition; return this; }
|
|
93
99
|
/** Icon used when the action is a menu item (ActionGroup). */
|
|
94
100
|
groupedIcon(name) { this._groupedIcon = name; return this; }
|
|
95
101
|
requiresConfirmation(condition = true) { this._requiresConfirmation = condition; return this; }
|
|
@@ -99,8 +105,7 @@ export class Action {
|
|
|
99
105
|
modalIcon(name) { this._modalIcon = name; return this; }
|
|
100
106
|
_badge = null;
|
|
101
107
|
_badgeColor = 'primary';
|
|
102
|
-
/**
|
|
103
|
-
badge(badge) { this._badge = badge; return this; }
|
|
108
|
+
/** Color of the counter set with `badge(value)`. */
|
|
104
109
|
badgeColor(color) { this._badgeColor = color; return this; }
|
|
105
110
|
_slideOver = false;
|
|
106
111
|
_hasSubmit = true;
|
|
@@ -164,8 +169,60 @@ export class Action {
|
|
|
164
169
|
const result = this._schema(schema);
|
|
165
170
|
return Array.isArray(result) ? schema.columns(this._schemaColumns).components(result) : result;
|
|
166
171
|
}
|
|
167
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* Extra buttons in the modal footer, after the submit button. An action with an `action()` function runs on top of this
|
|
174
|
+
* one (with its own modal, if it has one), and gets this action's form data as `parentData`.
|
|
175
|
+
*/
|
|
168
176
|
extraModalFooterActions(actions) { this._extraModalFooterActions = actions; return this; }
|
|
177
|
+
getExtraModalFooterActions() { return this._extraModalFooterActions; }
|
|
178
|
+
// --- modal behavior
|
|
179
|
+
_closeByClickingAway = true;
|
|
180
|
+
_closeByEscaping = true;
|
|
181
|
+
_closeButton = true;
|
|
182
|
+
_stickyHeader = false;
|
|
183
|
+
_stickyFooter = false;
|
|
184
|
+
_modalAlignment = null;
|
|
185
|
+
_footerAlignment = null;
|
|
186
|
+
_modalHidden = false;
|
|
187
|
+
_mountUsing = null;
|
|
188
|
+
/** `false`: clicking outside the modal does not close it. */
|
|
189
|
+
closeModalByClickingAway(condition = true) { this._closeByClickingAway = condition; return this; }
|
|
190
|
+
/** `false`: the Escape key does not close the modal. */
|
|
191
|
+
closeModalByEscaping(condition = true) { this._closeByEscaping = condition; return this; }
|
|
192
|
+
/** `false`: no close button in the modal header. */
|
|
193
|
+
modalCloseButton(condition = true) { this._closeButton = condition; return this; }
|
|
194
|
+
/** Keeps the modal header visible while its content scrolls. */
|
|
195
|
+
stickyModalHeader(condition = true) { this._stickyHeader = condition; return this; }
|
|
196
|
+
/** Keeps the modal footer visible while its content scrolls. */
|
|
197
|
+
stickyModalFooter(condition = true) { this._stickyFooter = condition; return this; }
|
|
198
|
+
modalAlignment(alignment) { this._modalAlignment = alignment; return this; }
|
|
199
|
+
modalFooterActionsAlignment(alignment) { this._footerAlignment = alignment; return this; }
|
|
200
|
+
_steps = null;
|
|
201
|
+
/** Builds the wizard of `steps()`: registered by `@navette-js/forms`, which owns the `Wizard` component. */
|
|
202
|
+
static stepsRenderer = null;
|
|
203
|
+
/**
|
|
204
|
+
* Turns the modal form into a wizard: each step is validated before the next one, and the last step submits the action.
|
|
205
|
+
* `steps` are `Step` components of `@navette-js/forms`.
|
|
206
|
+
*/
|
|
207
|
+
steps(steps) {
|
|
208
|
+
this._steps = steps;
|
|
209
|
+
this._schema = steps === null ? null : (schema) => {
|
|
210
|
+
if (!Action.stepsRenderer)
|
|
211
|
+
throw new Error('Action.steps() needs @navette-js/forms to be loaded.');
|
|
212
|
+
const submitLabel = this._modalSubmitActionLabel ?? t('filament-actions::modal.actions.submit.label');
|
|
213
|
+
const submit = Action.make('submit').label(submitLabel).color(this._color === 'gray' ? 'primary' : this._color).submit('callMountedAction');
|
|
214
|
+
const cancel = Action.make('cancel').label(this._cancelLabel ?? t('filament-actions::modal.actions.cancel.label')).color('gray').alpineClickHandler('close()');
|
|
215
|
+
return schema.components([Action.stepsRenderer(steps, submit, cancel)]);
|
|
216
|
+
};
|
|
217
|
+
return this;
|
|
218
|
+
}
|
|
219
|
+
isWizard() { return this._steps !== null; }
|
|
220
|
+
/** Runs the action without its modal (e.g. no confirmation for some records). */
|
|
221
|
+
modalHidden(condition = true) { this._modalHidden = condition; return this; }
|
|
222
|
+
async isModalHidden(ctx) { return Boolean(await evaluate(this._modalHidden, ctx)); }
|
|
223
|
+
/** Runs when the action is mounted, after the form is filled: `ctx.data` is the form state, which you may change. */
|
|
224
|
+
mountUsing(callback) { this._mountUsing = callback; return this; }
|
|
225
|
+
async runMountUsing(ctx) { await this._mountUsing?.(ctx); }
|
|
169
226
|
/** Initial form state (e.g. from the record). */
|
|
170
227
|
fillForm(resolver) { this._fillForm = resolver; return this; }
|
|
171
228
|
/** Modal width (`xs` … `7xl`). Defaults to `md` for a confirmation and `4xl` for a form. */
|
|
@@ -182,9 +239,68 @@ export class Action {
|
|
|
182
239
|
before(fn) { this._before = fn; return this; }
|
|
183
240
|
/** Runs after the handler succeeds (logging, extra notification…). */
|
|
184
241
|
after(fn) { this._after = fn; return this; }
|
|
242
|
+
// --- bulk actions
|
|
243
|
+
_individualAuthorization = null;
|
|
244
|
+
_chunkSize = 100;
|
|
245
|
+
_deselectAfterCompletion = false;
|
|
246
|
+
_successNotificationTitle = null;
|
|
247
|
+
_failureNotificationTitle = null;
|
|
248
|
+
_missingAuthorizationMessage = null;
|
|
249
|
+
_missingProcessingMessage = null;
|
|
250
|
+
/**
|
|
251
|
+
* Bulk action: each selected record must pass this policy ability (or function), otherwise it is skipped and counted in the
|
|
252
|
+
* failure notification. Used by `processSelectedRecords()` of the table's page.
|
|
253
|
+
*/
|
|
254
|
+
authorizeIndividualRecords(ability) { this._individualAuthorization = ability; return this; }
|
|
255
|
+
getIndividualRecordsAuthorization() { return this._individualAuthorization; }
|
|
256
|
+
/** Number of selected records loaded at a time by `processSelectedRecords()` (100 by default). */
|
|
257
|
+
chunkSelectedRecords(size) { this._chunkSize = Math.max(1, Math.floor(size)); return this; }
|
|
258
|
+
getSelectedRecordsChunkSize() { return this._chunkSize; }
|
|
259
|
+
/** Clears the table selection once the bulk action has run. */
|
|
260
|
+
deselectRecordsAfterCompletion(condition = true) { this._deselectAfterCompletion = condition; return this; }
|
|
261
|
+
shouldDeselectRecordsAfterCompletion() { return this._deselectAfterCompletion; }
|
|
262
|
+
/** Notification sent when every selected record was processed. */
|
|
263
|
+
successNotificationTitle(title) { this._successNotificationTitle = title; return this; }
|
|
264
|
+
getSuccessNotificationTitle() { return this._successNotificationTitle; }
|
|
265
|
+
/** Notification title when some records failed: receives the number of processed records and the number selected. */
|
|
266
|
+
failureNotificationTitle(title) { this._failureNotificationTitle = title; return this; }
|
|
267
|
+
getFailureNotificationTitle(success, total) { const title = this._failureNotificationTitle; return typeof title === 'function' ? title(success, total) : title; }
|
|
268
|
+
/** Body line for records skipped by `authorizeIndividualRecords()`: receives their number and the number selected. */
|
|
269
|
+
missingBulkAuthorizationFailureNotificationMessage(message) { this._missingAuthorizationMessage = message; return this; }
|
|
270
|
+
getMissingBulkAuthorizationFailureNotificationMessage(count, total) { const message = this._missingAuthorizationMessage; return typeof message === 'function' ? message(count, total) : message; }
|
|
271
|
+
/** Body line for records whose processing failed: receives their number and the number selected. */
|
|
272
|
+
missingBulkProcessingFailureNotificationMessage(message) { this._missingProcessingMessage = message; return this; }
|
|
273
|
+
getMissingBulkProcessingFailureNotificationMessage(count, total) { const message = this._missingProcessingMessage; return typeof message === 'function' ? message(count, total) : message; }
|
|
274
|
+
// --- outcome of single actions
|
|
275
|
+
_successNotification = null;
|
|
276
|
+
_successRedirectUrl = null;
|
|
277
|
+
_preserveData = null;
|
|
278
|
+
/**
|
|
279
|
+
* Customizes the success notification of a prebuilt action: the function receives the notification and returns it (changed)
|
|
280
|
+
* or `null` to send nothing.
|
|
281
|
+
*/
|
|
282
|
+
successNotification(customize) { this._successNotification = customize; return this; }
|
|
283
|
+
getSuccessNotificationCustomizer() { return this._successNotification; }
|
|
284
|
+
/** Where a prebuilt action redirects after success: a URL, or a function of the context (record, replica...). */
|
|
285
|
+
successRedirectUrl(url) { this._successRedirectUrl = url; return this; }
|
|
286
|
+
async getSuccessRedirectUrl(ctx) { return typeof this._successRedirectUrl === 'function' ? this._successRedirectUrl(ctx) : this._successRedirectUrl; }
|
|
287
|
+
/** "Create & create another": fields kept in the new form (names, or a function returning the data to keep). */
|
|
288
|
+
preserveFormDataWhenCreatingAnother(fields) { this._preserveData = fields; return this; }
|
|
289
|
+
getDataPreservedForAnother(data) {
|
|
290
|
+
if (!this._preserveData)
|
|
291
|
+
return {};
|
|
292
|
+
if (typeof this._preserveData === 'function')
|
|
293
|
+
return this._preserveData(data) ?? {};
|
|
294
|
+
return Object.fromEntries(this._preserveData.filter((field) => Object.hasOwn(data, field)).map((field) => [field, data[field]]));
|
|
295
|
+
}
|
|
296
|
+
/** Stops the action from one of its hooks; the modal stays open. */
|
|
297
|
+
halt() { throw new ActionHalt(false); }
|
|
298
|
+
/** Stops the action from one of its hooks and closes the modal. */
|
|
299
|
+
cancel() { throw new ActionHalt(true); }
|
|
185
300
|
async call(ctx) {
|
|
186
301
|
if (!this._handler)
|
|
187
302
|
return null;
|
|
303
|
+
ctx = { ...ctx, action: this };
|
|
188
304
|
if (this._mutateData)
|
|
189
305
|
ctx = { ...ctx, data: await this._mutateData(ctx.data ?? {}, ctx) };
|
|
190
306
|
await this._before?.(ctx);
|
|
@@ -241,6 +357,9 @@ export class Action {
|
|
|
241
357
|
async toLinkHtml(ctx, context, wireKey) {
|
|
242
358
|
if (!colorManager)
|
|
243
359
|
throw new Error('No color manager: call useColorManager() first.');
|
|
360
|
+
// `button()`, `badge()`, `outlined()` or `labeledFrom()` in a table row: rendered like a header action.
|
|
361
|
+
if (this._view === 'button' || this._view === 'badge' || this._outlined || this._labeledFrom)
|
|
362
|
+
return this.toHtml(ctx, context);
|
|
244
363
|
const color = this._lazyColor ? await this._lazyColor(ctx) : this._color;
|
|
245
364
|
const iconName = this._lazyIcon ? await this._lazyIcon(ctx) : (this._tableIcon ?? this._icon);
|
|
246
365
|
const raw = `mountAction(${js(this.name)}, {}, ${js(context)})`;
|
|
@@ -262,7 +381,7 @@ export class Action {
|
|
|
262
381
|
return `<button ${keys}${tooltip}type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${classes}" wire:click="${e(raw)}" wire:key="${e(wireKey)}">${iconHtml}${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': raw }), 'sm')}<span class="fi-link-label">${e(label)}</span></button>`;
|
|
263
382
|
}
|
|
264
383
|
/** Modal of a mounted action; `content` is the rendered form, if any. */
|
|
265
|
-
async toModalHtml(ctx, index, content = null) {
|
|
384
|
+
async toModalHtml(ctx, index, content = null, mountContext = {}) {
|
|
266
385
|
if (!colorManager)
|
|
267
386
|
throw new Error('No color manager: call useColorManager() first.');
|
|
268
387
|
const schemaCtx = { livewire: ctx.livewire, component: null, operation: null, record: ctx.record, get: () => undefined, state: undefined };
|
|
@@ -280,15 +399,21 @@ export class Action {
|
|
|
280
399
|
+ hook(ActionsRenderHook.MODAL_CUSTOM_CONTENT_FOOTER_BEFORE) + hook(ActionsRenderHook.MODAL_CUSTOM_CONTENT_FOOTER_AFTER);
|
|
281
400
|
if (content !== null || hooked !== '')
|
|
282
401
|
content = hooked;
|
|
402
|
+
// Footer actions with a handler are mounted on top of this one, with its context.
|
|
403
|
+
const footerActions = await Promise.all(this._extraModalFooterActions.map((a) => (a.isMountable() ? a.toHtml(schemaCtx, { ...mountContext, parentAction: index }) : a.toHtml(schemaCtx))));
|
|
283
404
|
return views.render('filament-actions::modal', {
|
|
284
|
-
|
|
405
|
+
// A wizard has its own buttons (back, next, submit): the modal has no footer.
|
|
406
|
+
hasFooter: !this.isWizard(),
|
|
407
|
+
closeByClickingAway: this._closeByClickingAway, closeByEscaping: this._closeByEscaping, closeButton: this._closeButton, stickyHeader: this._stickyHeader, stickyFooter: this._stickyFooter,
|
|
408
|
+
footerAlignment: this._footerAlignment ?? (confirmation ? 'center' : 'start'),
|
|
409
|
+
slideOver: this._slideOver, content, confirmation, width, alignment: this._modalAlignment ?? (confirmation || ['xs', 'sm'].includes(width) ? 'center' : 'start'),
|
|
285
410
|
livewireId: ctx.livewire.id, id: `fi-${ctx.livewire.id}-action-${index}`, index, name: this.name,
|
|
286
411
|
heading: (await evaluate(this._modalHeading, ctx)) ?? label,
|
|
287
412
|
description: (await evaluate(this._modalDescription, ctx)) ?? (this._requiresConfirmation ? t('filament-actions::modal.confirmation') : null),
|
|
288
413
|
iconName: this._modalIcon ?? (this._requiresConfirmation ? 'o-exclamation-triangle' : null), color,
|
|
289
414
|
cancel: await Action.make('cancel').label(this._cancelLabel ?? t('filament-actions::modal.actions.cancel.label')).color('gray').alpineClickHandler('close()').toHtml(schemaCtx),
|
|
290
415
|
// The submit button of a gray action uses the primary color.
|
|
291
|
-
submit: !this._hasSubmit ? '' : (await Action.make('submit').label(submitLabel).color(color === 'gray' ? 'primary' : color).submit('callMountedAction').toHtml(schemaCtx)) +
|
|
416
|
+
submit: !this._hasSubmit ? '' : (await Action.make('submit').label(submitLabel).color(color === 'gray' ? 'primary' : color).submit('callMountedAction').toHtml(schemaCtx)) + footerActions.join(''),
|
|
292
417
|
});
|
|
293
418
|
}
|
|
294
419
|
alpineClickHandler(handler) { this._alpineClick = handler; return this; }
|
|
@@ -308,11 +433,72 @@ export class Action {
|
|
|
308
433
|
/** Shown but unusable; the server also refuses to mount it. */
|
|
309
434
|
disabled(condition = true) { this._disabled = condition; return this; }
|
|
310
435
|
async isDisabled(ctx) { return evaluate(this._disabled, ctx); }
|
|
436
|
+
_view = null;
|
|
437
|
+
_labeledFrom = null;
|
|
438
|
+
_outlined = false;
|
|
439
|
+
/** Trigger style, instead of the one chosen by the location of the action. */
|
|
440
|
+
button() { this._view = 'button'; return this; }
|
|
441
|
+
link() { this._view = 'link'; return this; }
|
|
442
|
+
/** Icon only: the label becomes the tooltip and accessible name. */
|
|
443
|
+
iconButton(condition = true) { this._iconButton = condition; this._view = condition ? 'icon-button' : null; return this; }
|
|
444
|
+
badge(badge) {
|
|
445
|
+
// `badge()` without argument: badge trigger style; with a value: a counter on the button.
|
|
446
|
+
if (arguments.length === 0) {
|
|
447
|
+
this._view = 'badge';
|
|
448
|
+
return this;
|
|
449
|
+
}
|
|
450
|
+
this._badge = badge ?? null;
|
|
451
|
+
return this;
|
|
452
|
+
}
|
|
453
|
+
/** Icon button on small screens, labeled button from this breakpoint up (`sm`, `md`, `lg`, `xl`, `2xl`). */
|
|
454
|
+
labeledFrom(breakpoint) { this._labeledFrom = breakpoint !== null && ['sm', 'md', 'lg', 'xl', '2xl'].includes(breakpoint) ? breakpoint : null; return this; }
|
|
455
|
+
/** Outlined button. */
|
|
456
|
+
outlined(condition = true) { this._outlined = condition; return this; }
|
|
457
|
+
getView() { return this._view; }
|
|
458
|
+
/** Markup of a link, icon button or badge trigger; `attributes` carries the click (or href) behavior. */
|
|
459
|
+
async styledTriggerHtml(ctx, view, attributes, tag, wireTarget) {
|
|
460
|
+
const label = (await evaluate(this._label, ctx)) ?? this.getDefaultLabel();
|
|
461
|
+
const iconName = this._lazyIcon ? await this._lazyIcon(ctx) : this._icon;
|
|
462
|
+
const color = this._lazyColor ? await this._lazyColor(ctx) : this._color;
|
|
463
|
+
const colors = (component) => (color !== 'gray' ? colorManager.getComponentClasses(component, color) : []);
|
|
464
|
+
const loader = tag === 'button' && wireTarget ? loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': wireTarget })) : '';
|
|
465
|
+
const glyph = iconName ? icon(iconName, { attributes: tag === 'button' && wireTarget ? new Attributes({ 'wire:loading.remove.delay.default': '1', 'wire:target': wireTarget }) : undefined }) : '';
|
|
466
|
+
const tooltip = await this.tooltipAttribute(ctx);
|
|
467
|
+
if (view === 'icon-button')
|
|
468
|
+
return `<${tag} title="${e(label)}" aria-label="${e(label)}" ${tooltip}${attributes} class="${cls(['fi-ac-icon-btn-action', 'fi-icon-btn', `fi-size-${this._size}`, ...colors('icon-button')])}">${glyph}${loader}</${tag}>`;
|
|
469
|
+
if (view === 'badge')
|
|
470
|
+
return `<${tag} ${tooltip}${attributes} class="${cls(['fi-ac-badge-action', 'fi-badge', `fi-size-${this._size}`, ...colors('badge')])}">${glyph}${loader}<span class="fi-badge-label-ctn"><span class="fi-badge-label">${e(label)}</span></span></${tag}>`;
|
|
471
|
+
return `<${tag} ${tooltip}${attributes} class="${cls(['fi-ac-link-action', 'fi-link', `fi-size-${this._size}`, ...colors('link')])}">${glyph}${loader}<span class="fi-link-label">${e(label)}</span></${tag}>`;
|
|
472
|
+
}
|
|
311
473
|
/** Renders the action as a button (or as a link when it has a URL). */
|
|
312
474
|
async toHtml(ctx, explicitMountContext = null) {
|
|
313
475
|
if (!colorManager)
|
|
314
476
|
throw new Error('No color manager: call useColorManager() first.');
|
|
315
477
|
const linkUrl = this._lazyUrl ? this._lazyUrl(ctx) : this._url;
|
|
478
|
+
// Explicit trigger styles (link, icon button, badge), and the icon button / labeled button pair of `labeledFrom()`.
|
|
479
|
+
if (this._submit === null && (this._view === 'link' || this._view === 'icon-button' || this._view === 'badge' || this._labeledFrom)) {
|
|
480
|
+
const safeUrl = linkUrl !== null && /^(\/(?!\/)|https?:\/\/)/.test(linkUrl) ? linkUrl : null;
|
|
481
|
+
const mountContext = explicitMountContext ?? ctx.livewire?.getActionMountContext?.() ?? {};
|
|
482
|
+
const mountExpression = this._handler ? (Object.keys(mountContext).length ? `mountAction(${js(this.name)}, {}, ${js(mountContext)})` : `mountAction(${js(this.name)})`) : null;
|
|
483
|
+
const click = safeUrl ? `href="${e(safeUrl)}" ${this._newTab ? 'target="_blank" ' : 'wire:navigate '}` : `type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" ${this._alpineClick ? `x-on:click="${e(this._alpineClick)}" ` : ''}${(mountExpression ?? this._action) ? `wire:click="${e(mountExpression ?? this._action)}" ` : ''}`;
|
|
484
|
+
const tag = safeUrl ? 'a' : 'button';
|
|
485
|
+
const target = this._livewireTarget ?? mountExpression ?? this._action;
|
|
486
|
+
if (this._labeledFrom) {
|
|
487
|
+
const iconButton = (await this.styledTriggerHtml(ctx, 'icon-button', click.trim(), tag, target)).replace('class="fi-ac-icon-btn-action', `class="${this._labeledFrom}:fi-hidden fi-ac-icon-btn-action`);
|
|
488
|
+
const previousView = this._view, previousLabeled = this._labeledFrom;
|
|
489
|
+
this._view = null;
|
|
490
|
+
this._labeledFrom = null;
|
|
491
|
+
try {
|
|
492
|
+
const labeled = (await this.toHtml(ctx, explicitMountContext)).replace(/class="([^"]*\bfi-btn\b[^"]*)"/, (_match, classes) => `class="${classes} fi-hidden ${previousLabeled}:fi-inline-grid"`);
|
|
493
|
+
return iconButton + labeled;
|
|
494
|
+
}
|
|
495
|
+
finally {
|
|
496
|
+
this._view = previousView;
|
|
497
|
+
this._labeledFrom = previousLabeled;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return this.styledTriggerHtml(ctx, this._view, click.trim(), tag, target);
|
|
501
|
+
}
|
|
316
502
|
if (linkUrl !== null && /^(\/(?!\/)|https?:\/\/)/.test(linkUrl)) {
|
|
317
503
|
const link = new Attributes().class(['fi-ac-btn-action', 'fi-btn', `fi-size-${this._size}`, ...(this._color !== 'gray' ? colorManager.getComponentClasses('button', this._color) : [])]);
|
|
318
504
|
const linkIcon = this._icon ? icon(this._icon) : '';
|
|
@@ -333,7 +519,7 @@ export class Action {
|
|
|
333
519
|
'wire:target': this._livewireTarget ?? this._submit,
|
|
334
520
|
'x-bind:disabled': isSubmit ? 'isProcessing' : null,
|
|
335
521
|
})
|
|
336
|
-
.class(['fi-ac-btn-action', 'fi-btn', `fi-size-${this._size}`, ...colorManager.getComponentClasses('button', this._color)])
|
|
522
|
+
.class(['fi-ac-btn-action', 'fi-btn', `fi-size-${this._size}`, ...(this._outlined ? ['fi-outlined', ...colorManager.getComponentClasses('button-outlined', this._color)] : colorManager.getComponentClasses('button', this._color))])
|
|
337
523
|
.when(this._action, (a) => a.set('wire:click', this._action))
|
|
338
524
|
.when(mountExpression, (a) => a.set('wire:click', mountExpression))
|
|
339
525
|
.when(this._alpineClick, (a) => a.set('x-on:click', this._alpineClick));
|
|
@@ -349,22 +535,118 @@ export class Action {
|
|
|
349
535
|
}
|
|
350
536
|
}
|
|
351
537
|
/** Group of actions collapsed into a dropdown behind a "⋮" icon button. */
|
|
538
|
+
/**
|
|
539
|
+
* Actions in a dropdown menu ("⋮" icon button by default). A nested group with `dropdown(false)` is shown as a section of
|
|
540
|
+
* the menu, separated by a divider. `buttonGroup()` shows the actions side by side instead.
|
|
541
|
+
*/
|
|
352
542
|
export class ActionGroup {
|
|
353
543
|
actions;
|
|
544
|
+
_label = null;
|
|
545
|
+
_icon = 'm-ellipsis-vertical';
|
|
546
|
+
_color = 'primary';
|
|
547
|
+
_size = 'md';
|
|
548
|
+
_tooltip = null;
|
|
549
|
+
_view = 'icon-button';
|
|
550
|
+
_dropdown = true;
|
|
551
|
+
_buttonGroup = false;
|
|
552
|
+
_placement = null;
|
|
553
|
+
_width = null;
|
|
554
|
+
_offset = null;
|
|
555
|
+
_maxHeight = null;
|
|
556
|
+
_hidden = false;
|
|
354
557
|
constructor(actions) { this.actions = actions; }
|
|
355
558
|
static make(actions) { return configured(new ActionGroup(actions)); }
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
559
|
+
/** Every action of the group, nested groups included. */
|
|
560
|
+
getActions() { return this.actions.flatMap((action) => (action instanceof ActionGroup ? action.getActions() : [action])); }
|
|
561
|
+
getName() { return 'group'; }
|
|
562
|
+
/** A group is never mounted: its actions are. */
|
|
563
|
+
isMountable() { return false; }
|
|
564
|
+
label(label) { this._label = label; return this; }
|
|
565
|
+
icon(icon) { this._icon = icon; return this; }
|
|
566
|
+
color(color) { this._color = color; return this; }
|
|
567
|
+
size(size) { this._size = size; return this; }
|
|
568
|
+
tooltip(tooltip) { this._tooltip = tooltip; return this; }
|
|
569
|
+
/** Labeled button trigger. */
|
|
570
|
+
button() { this._view = 'button'; return this; }
|
|
571
|
+
link() { this._view = 'link'; return this; }
|
|
572
|
+
iconButton() { this._view = 'icon-button'; return this; }
|
|
573
|
+
/** `false`: inside another group, the actions form a section of its menu (with a divider). */
|
|
574
|
+
dropdown(condition = true) { this._dropdown = condition; return this; }
|
|
575
|
+
hasDropdown() { return this._dropdown; }
|
|
576
|
+
/** The actions side by side, as a group of buttons. */
|
|
577
|
+
buttonGroup(condition = true) { this._buttonGroup = condition; return this; }
|
|
578
|
+
/** Menu placement: `bottom-start`, `bottom-end`, `top-start`... (`bottom-end` in tables). */
|
|
579
|
+
dropdownPlacement(placement) { this._placement = placement !== null && /^(top|bottom|left|right)(-(start|end))?$/.test(placement) ? placement : null; return this; }
|
|
580
|
+
/** Menu width: `xs` to `7xl`. */
|
|
581
|
+
dropdownWidth(width) { this._width = width !== null && /^(xs|sm|md|lg|xl|[2-7]xl)$/.test(width) ? width : null; return this; }
|
|
582
|
+
/** Distance between the trigger and the menu, in pixels (8 by default). */
|
|
583
|
+
dropdownOffset(offset) { this._offset = offset !== null && Number.isFinite(offset) ? Math.max(0, Math.round(offset)) : null; return this; }
|
|
584
|
+
/** Maximum height of the menu, as a CSS length; it scrolls beyond. */
|
|
585
|
+
maxHeight(height) { this._maxHeight = height !== null && /^[0-9.]+(px|rem|em|vh|%)$/.test(height) ? height : null; return this; }
|
|
586
|
+
hidden(condition = true) { this._hidden = condition; return this; }
|
|
587
|
+
visible(condition = true) { return this.hidden(typeof condition === 'function' ? async (ctx) => !(await condition(ctx)) : !condition); }
|
|
588
|
+
async isVisible(ctx) {
|
|
589
|
+
if (await evaluate(this._hidden, ctx))
|
|
590
|
+
return false;
|
|
362
591
|
for (const action of this.actions)
|
|
363
592
|
if (await action.isVisible(ctx))
|
|
364
|
-
|
|
365
|
-
|
|
593
|
+
return true;
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
triggerHtml() {
|
|
597
|
+
if (!colorManager)
|
|
598
|
+
throw new Error('No color manager: call useColorManager() first.');
|
|
599
|
+
const label = this._label ?? t('filament-actions::group.trigger.label');
|
|
600
|
+
const tooltip = this._tooltip ? ` x-tooltip="${e(`{ content: ${js(this._tooltip)}, theme: $store.theme, allowHTML: false }`)}"` : '';
|
|
601
|
+
const colors = (component) => (this._color !== 'gray' || component === 'icon-button' ? colorManager.getComponentClasses(component, this._color) : []);
|
|
602
|
+
if (this._view === 'button')
|
|
603
|
+
return `<button${tooltip} type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${cls(['fi-ac-btn-group', 'fi-btn', `fi-size-${this._size}`, ...colors('button')])}">${icon(this._icon)}${e(label)}</button>`;
|
|
604
|
+
if (this._view === 'link')
|
|
605
|
+
return `<button${tooltip} type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${cls(['fi-ac-link-group', 'fi-link', `fi-size-${this._size}`, ...colors('link')])}">${icon(this._icon)}<span class="fi-link-label">${e(label)}</span></button>`;
|
|
606
|
+
return `<button title="${e(label)}" aria-label="${e(label)}"${tooltip} type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${['fi-ac-icon-btn-group', 'fi-icon-btn', `fi-size-${this._size}`, ...colors('icon-button')].join(' ')}">${icon(this._icon)}</button>`;
|
|
607
|
+
}
|
|
608
|
+
/** Menu items of the visible actions, one list per section (nested groups without dropdown). */
|
|
609
|
+
async itemLists(ctx, context, keyOf) {
|
|
610
|
+
const lists = [];
|
|
611
|
+
let current = [];
|
|
612
|
+
for (const action of this.actions) {
|
|
613
|
+
if (action instanceof ActionGroup && !action.hasDropdown()) {
|
|
614
|
+
if (!(await action.isVisible(ctx)))
|
|
615
|
+
continue;
|
|
616
|
+
if (current.length) {
|
|
617
|
+
lists.push(current);
|
|
618
|
+
current = [];
|
|
619
|
+
}
|
|
620
|
+
lists.push(...(await action.itemLists(ctx, context, keyOf)));
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
if (!(await action.isVisible(ctx)))
|
|
624
|
+
continue;
|
|
625
|
+
current.push(action instanceof ActionGroup ? await action.toHtml(ctx, context, keyOf) : await action.toRecordGroupedHtml(ctx, context, keyOf(action)));
|
|
626
|
+
}
|
|
627
|
+
if (current.length)
|
|
628
|
+
lists.push(current);
|
|
629
|
+
return lists.filter((list) => list.length);
|
|
630
|
+
}
|
|
631
|
+
/** `keyOf` returns each action's `wire:key` (it depends on the record in a table). */
|
|
632
|
+
async toHtml(ctx, explicitContext = null, keyOf = (action) => `actions.${action.getName()}`) {
|
|
633
|
+
if (!colorManager)
|
|
634
|
+
throw new Error('No color manager: call useColorManager() first.');
|
|
635
|
+
if (await evaluate(this._hidden, ctx))
|
|
636
|
+
return '';
|
|
637
|
+
const context = explicitContext ?? ctx.livewire?.getActionMountContext?.() ?? {};
|
|
638
|
+
if (this._buttonGroup) {
|
|
639
|
+
const buttons = [];
|
|
640
|
+
for (const action of this.getActions())
|
|
641
|
+
if (await action.isVisible(ctx))
|
|
642
|
+
buttons.push(await action.toHtml(ctx, explicitContext));
|
|
643
|
+
return buttons.length ? `<div class="fi-btn-group">${buttons.join('')}</div>` : '';
|
|
644
|
+
}
|
|
645
|
+
const lists = await this.itemLists(ctx, context, keyOf);
|
|
646
|
+
if (!lists.length)
|
|
366
647
|
return '';
|
|
367
|
-
const
|
|
368
|
-
|
|
648
|
+
const panelClasses = cls(['fi-dropdown-panel', this._width ? `fi-width-${this._width}` : null, { 'fi-scrollable': this._maxHeight !== null }]);
|
|
649
|
+
const style = this._maxHeight ? ` style="max-height: ${this._maxHeight}"` : '';
|
|
650
|
+
return `<div x-data="filamentDropdown" class="fi-dropdown"><div x-on:mousedown="if ($event.button === 0) toggle($event)" class="fi-dropdown-trigger">${this.triggerHtml()}</div><div x-cloak x-float.placement.${this._placement ?? 'bottom-end'}.flip.teleport.offset="{ offset: ${this._offset ?? 8} }" x-ref="panel" x-transition:enter-start="fi-opacity-0" x-transition:leave-end="fi-opacity-0" role="menu" class="${panelClasses}"${style}>${lists.map((items) => `<div class="fi-dropdown-list" role="group">${items.join(' ')}</div>`).join('')}</div></div>`;
|
|
369
651
|
}
|
|
370
652
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@navette-js/actions",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.4",
|
|
4
4
|
"description": "Actions and modals for Navette admin panels.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"node"
|
|
39
39
|
],
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@navette-js/support": "1.0.0-beta.
|
|
42
|
-
"@navette-js/schemas": "1.0.0-beta.
|
|
41
|
+
"@navette-js/support": "1.0.0-beta.4",
|
|
42
|
+
"@navette-js/schemas": "1.0.0-beta.4"
|
|
43
43
|
},
|
|
44
44
|
"publishConfig": {
|
|
45
45
|
"access": "public",
|
package/views/modal.ejs
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
<%# Modal of a mounted action (confirmation or form). Closing it unmounts the action. %>
|
|
2
|
+
<% const trapOnWindow = locals.slideOver || stickyHeader || stickyFooter; const close = '$dispatch(' + js('close-modal') + ', { id: ' + js(id) + ' })'; %>
|
|
2
3
|
<div wire:partial="action-modals.<%- index %>"<% if (description) { %> aria-describedby="<%= id %>.description"<% } %> aria-labelledby="<%= id %>.heading" aria-modal="true" id="<%= id %>" role="<%- confirmation ? 'alertdialog' : 'dialog' %>" tabindex="-1" x-data="filamentModal({
|
|
3
4
|
id: <%- js(id) %>,
|
|
4
5
|
isScrollLocked: true,
|
|
5
6
|
shouldRestoreFocus: false,
|
|
6
7
|
})" data-fi-modal-id="<%= id %>" x-on:close-modal.window="if (($event.detail.id === <%- js(id) %>) && isOpen) close()" x-on:close-modal-quietly.window="if (($event.detail.id === <%- js(id) %>) && isOpen) closeQuietly()" x-on:open-modal.window="if (($event.detail.id === <%- js(id) %>) && (! isOpen)) open()" x-bind:class="{
|
|
7
8
|
'fi-modal-open': isOpen,
|
|
8
|
-
}" x-cloak x-show="isOpen" class="<%- locals.slideOver ? 'fi-modal
|
|
9
|
+
}" x-cloak x-show="isOpen" class="<%- cls(['fi-modal', locals.slideOver ? 'fi-modal-slide-over' : null, 'fi-absolute-positioning-context', { 'fi-modal-has-sticky-header': stickyHeader, 'fi-modal-has-sticky-footer': stickyFooter }]) %>" wire:key="<%= livewireId %>.actions.<%= name %>.modal" x-on:modal-closed="if ($event.detail.id === <%= js(id) %>) $wire.unmountAction(false)">
|
|
9
10
|
<div aria-hidden="true" x-show="isOpen" x-transition.duration.300ms.opacity class="fi-modal-close-overlay"></div>
|
|
10
11
|
|
|
11
|
-
<div tabindex="-1" x-on:click.self="
|
|
12
|
-
<form x-on:keydown.window.escape="if (isTopmost())
|
|
12
|
+
<div<% if (!trapOnWindow) { %> tabindex="-1"<% } %><% if (closeByClickingAway) { %> x-on:click.self="<%= close %>"<% } %><% if (!trapOnWindow) { %> x-trap.noreturn="isTrapActive"<% } %> class="<%- cls(['fi-modal-window-ctn', { 'fi-clickable': closeByClickingAway }]) %>">
|
|
13
|
+
<form<% if (closeByEscaping) { %> x-on:keydown.window.escape="if (isTopmost()) <%= close %>"<% } %><% if (trapOnWindow) { %> x-trap.noreturn="isTrapActive" tabindex="0"<% } %> x-show="isWindowVisible" x-transition:enter="fi-transition-enter" x-transition:leave="fi-transition-leave" x-transition:enter-start="fi-transition-enter-start" x-transition:enter-end="fi-transition-enter-end" x-transition:leave-start="fi-transition-leave-start" x-transition:leave-end="fi-transition-leave-end" wire:submit.prevent="callMountedAction" wire:key="<%= livewireId %>.modal.<%= id %>.window" class="<%- cls(['fi-modal-window', { 'fi-modal-window-has-close-btn': closeButton }, { 'fi-modal-window-has-content': content !== null }, { 'fi-modal-window-has-footer': locals.hasFooter !== false }, { 'fi-modal-window-has-icon': iconName }, 'fi-align-' + alignment, 'fi-width-' + width]) %>">
|
|
13
14
|
<div wire:key="<%= livewireId %>.modal.<%= id %>.header" class="fi-modal-header">
|
|
14
|
-
|
|
15
|
+
<% if (closeButton) { %><%- view('filament::components.icon-button', {
|
|
15
16
|
iconName: 'o-x-mark', iconSize: 'lg', label: t('filament::components/modal.actions.close.label'), className: 'fi-modal-close-btn',
|
|
16
|
-
extra: attrs({ tabindex: '-1' }), after: attrs({ 'x-on:click': "$dispatch('close-modal', { id: " + js(id) + ' })' }),
|
|
17
|
-
}) %>
|
|
17
|
+
extra: attrs({ tabindex: closeByEscaping ? '-1' : null }), after: attrs({ 'x-on:click': "$dispatch('close-modal', { id: " + js(id) + ' })' }),
|
|
18
|
+
}) %><% } %>
|
|
18
19
|
|
|
19
20
|
<% if (iconName) { %>
|
|
20
21
|
<div class="fi-modal-icon-ctn">
|
|
@@ -44,12 +45,14 @@
|
|
|
44
45
|
</div>
|
|
45
46
|
<% } %>
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
<% if (locals.hasFooter !== false) { %>
|
|
49
|
+
<div wire:key="<%= livewireId %>.modal.<%= id %>.footer" class="fi-modal-footer fi-align-<%- footerAlignment %>">
|
|
48
50
|
<div class="fi-modal-footer-actions">
|
|
49
51
|
<%# Confirmation: cancel then confirm; form: submit then cancel. %>
|
|
50
52
|
<%- confirmation ? cancel + submit : submit + cancel %>
|
|
51
53
|
</div>
|
|
52
54
|
</div>
|
|
55
|
+
<% } %>
|
|
53
56
|
</form>
|
|
54
57
|
</div>
|
|
55
58
|
</div>
|