@navette-js/panels 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/THIRD-PARTY-NOTICES.md +1 -1
- package/lib/import-export.d.ts +142 -0
- package/lib/import-export.js +370 -0
- package/lib/index.d.ts +188 -11
- package/lib/index.js +691 -92
- package/lib/notifications.d.ts +20 -2
- package/lib/notifications.js +34 -4
- package/lib/session-store.d.ts +21 -0
- package/lib/session-store.js +35 -0
- package/lib/xlsx.d.ts +2 -0
- package/lib/xlsx.js +69 -0
- package/package.json +9 -9
- package/views/components/user-menu.ejs +3 -2
- package/views/layout/base.ejs +7 -1
- package/views/livewire/sidebar.ejs +6 -5
package/lib/index.js
CHANGED
|
@@ -15,13 +15,17 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
17
|
import express from 'express';
|
|
18
|
-
import { Action, useColorManager, ActionGroup } from '@navette-js/actions';
|
|
19
|
-
import { Checkbox, Field, Select, TextInput, Wizard, richDocumentToHtml, richHtmlToDocument, fillSchema, fillSchemaFromRecord, getSchemaState, validateSchema, useColorManager as useFormsColorManager } from '@navette-js/forms';
|
|
18
|
+
import { Action, ActionHalt, useColorManager, ActionGroup } from '@navette-js/actions';
|
|
19
|
+
import { Checkbox, Field, Select, TextInput, Wizard, richDocumentToHtml, richHtmlToDocument, fillSchema, fillSchemaFromRecord, getSchemaState, loadSchemaRelationships, saveSchemaRelationships, validateSchema, useColorManager as useFormsColorManager } from '@navette-js/forms';
|
|
20
20
|
import { Actions, Component as SchemaComponent, EmbeddedSchema, Form, Group, Html, RenderHook, Schema, StatefulGroup } from '@navette-js/schemas';
|
|
21
21
|
import { WireError } from '@navette-js/wire';
|
|
22
22
|
import { useColorManager as useInfolistsColorManager, useHtmlSanitizer } from '@navette-js/infolists';
|
|
23
23
|
import { Notification, Notifications, publish, runInRequest } from "./notifications.js";
|
|
24
24
|
export { Notification, Notifications } from "./notifications.js";
|
|
25
|
+
export { MemorySessionStore } from "./session-store.js";
|
|
26
|
+
export { csvCell, csvLine, DownloadStore, ExportAction, ExportBulkAction, ExportColumn, Exporter, ImportAction, ImportColumn, Importer, parseCsv } from "./import-export.js";
|
|
27
|
+
import { DownloadStore } from "./import-export.js";
|
|
28
|
+
import { MemorySessionStore } from "./session-store.js";
|
|
25
29
|
import { addDays, addMonths, CALENDAR_LABELS, CALENDAR_VIEWS, isDate, renderCalendar, visibleRange } from "./calendar.js";
|
|
26
30
|
export { addDays, addMonths, CALENDAR_LABELS, dayOf, daysBetween, eventsOn, renderCalendar, renderCalendarEvent, timeOf, visibleRange } from "./calendar.js";
|
|
27
31
|
import { DatabaseNotifications, GlobalSearch, keyBindingSuffix } from "./topbar.js";
|
|
@@ -29,7 +33,7 @@ export { DatabaseNotifications, GlobalSearch, knexNotificationStore, timeAgo } f
|
|
|
29
33
|
import { Attributes, ColorManager, HtmlString, RenderHookRegistry, cls, convertToRgb, e, icon, js, loadingIndicator, renderHook, runWithRenderHooks, views } from '@navette-js/support';
|
|
30
34
|
import { t, tc } from '@navette-js/support/i18n';
|
|
31
35
|
import { emptyState } from '@navette-js/support/ui';
|
|
32
|
-
import { REORDER_LIMIT, Table, initializeTableGrouping, updateTableColumnState as updateEditableColumnState, getTableQuery, renderTable, sanitizeColumnState, sortTable, useColorManager as useTableColorManager } from '@navette-js/tables';
|
|
36
|
+
import { REORDER_LIMIT, Table, TrashedFilter, filterSelectableKeys, getTableRecords, initializeTableGrouping, isTableFilterRemovable, updateTableColumnState as updateEditableColumnState, getTableQuery, renderTable, sanitizeColumnState, sortTable, useColorManager as useTableColorManager } from '@navette-js/tables';
|
|
33
37
|
import { Component } from '@navette-js/wire';
|
|
34
38
|
import { csrfToken, verifyCsrf, wireRouter, wireScripts, wireStyles } from '@navette-js/wire/express';
|
|
35
39
|
const dir = (p) => fileURLToPath(new URL(p, import.meta.url));
|
|
@@ -37,6 +41,19 @@ views.namespace('filament-panels', dir('../views'));
|
|
|
37
41
|
views.namespace('filament', fileURLToPath(new URL('../views', import.meta.resolve('@navette-js/support'))));
|
|
38
42
|
views.share({ t, tc });
|
|
39
43
|
export const ASSET_VERSION = '5.11.2.0';
|
|
44
|
+
/** Navigation group: label, icon (then its items have none, as in Filament) and whether it can be collapsed. */
|
|
45
|
+
export class NavigationGroup {
|
|
46
|
+
_label;
|
|
47
|
+
_icon = null;
|
|
48
|
+
_collapsible = true;
|
|
49
|
+
constructor(label) { this._label = label; }
|
|
50
|
+
static make(label) { return new NavigationGroup(label); }
|
|
51
|
+
icon(name) { this._icon = name; return this; }
|
|
52
|
+
collapsible(condition = true) { this._collapsible = condition; return this; }
|
|
53
|
+
getLabel() { return this._label; }
|
|
54
|
+
getIcon() { return this._icon; }
|
|
55
|
+
isCollapsible() { return this._collapsible; }
|
|
56
|
+
}
|
|
40
57
|
export class NavigationItem {
|
|
41
58
|
label;
|
|
42
59
|
_url = '#';
|
|
@@ -73,10 +90,20 @@ export class Panel {
|
|
|
73
90
|
_login = null;
|
|
74
91
|
_auth = null;
|
|
75
92
|
_key = 'cle-de-developpement-a-remplacer';
|
|
93
|
+
_sessionStore = null;
|
|
94
|
+
/** Where per-browser data is kept (persisted table filters, search and sort): in memory by default. */
|
|
95
|
+
sessionStore(store) { this._sessionStore = store; return this; }
|
|
96
|
+
getSessionStore() { return (this._sessionStore ??= new MemorySessionStore()); }
|
|
97
|
+
_downloads = null;
|
|
98
|
+
/** Where files offered for download (exports, failed import rows) are kept: in memory by default. */
|
|
99
|
+
downloads(store) { this._downloads = store; return this; }
|
|
100
|
+
getDownloads() { return (this._downloads ??= new DownloadStore()); }
|
|
101
|
+
getDownloadUrl(token) { return this.getBaseUrl(`downloads/${encodeURIComponent(token)}`); }
|
|
76
102
|
_pages = [];
|
|
77
103
|
_resources = [];
|
|
78
104
|
_navigationItems = [];
|
|
79
105
|
_navigationGroups = [];
|
|
106
|
+
_navigationGroupOptions = new Map();
|
|
80
107
|
_maxContentWidth = '7xl';
|
|
81
108
|
colors = new ColorManager();
|
|
82
109
|
static make() { return new Panel(); }
|
|
@@ -136,6 +163,33 @@ export class Panel {
|
|
|
136
163
|
login(page = Login) { this._login = page; return this; }
|
|
137
164
|
auth(provider) { this._auth = provider; return this; }
|
|
138
165
|
key(key) { this._key = key; return this; }
|
|
166
|
+
_authGuard = 'web';
|
|
167
|
+
_canAccess = null;
|
|
168
|
+
/**
|
|
169
|
+
* Session shared with the other panels that use the same guard (`'web'` by default): signing into one signs into all of
|
|
170
|
+
* them, and `canAccess()` decides who may enter each one. `null` gives this panel a session of its own.
|
|
171
|
+
* Panels sharing a guard must use the same key and the same auth provider.
|
|
172
|
+
*/
|
|
173
|
+
authGuard(guard) {
|
|
174
|
+
if (guard !== null && !/^[a-z0-9_-]{1,40}$/.test(guard))
|
|
175
|
+
throw new Error(`Invalid auth guard name: ${guard}`);
|
|
176
|
+
this._authGuard = guard;
|
|
177
|
+
return this;
|
|
178
|
+
}
|
|
179
|
+
getAuthGuard() { return this._authGuard; }
|
|
180
|
+
/** Who may use this panel, e.g. `panel.canAccess((user) => user.role === 'admin')`. Checked on every request and at login. */
|
|
181
|
+
canAccess(rule) { this._canAccess = rule; return this; }
|
|
182
|
+
/** True if this user may use this panel: the panel's rule and the provider's `canAccessPanel()`, when set, must both allow it. */
|
|
183
|
+
async userCanAccess(user) {
|
|
184
|
+
if (this._canAccess && !(await this._canAccess(user, this)))
|
|
185
|
+
return false;
|
|
186
|
+
const provider = this.getAuth();
|
|
187
|
+
if (provider.canAccessPanel && !(await provider.canAccessPanel(user, this)))
|
|
188
|
+
return false;
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
/** Name of the session cookie: per guard (shared), or per panel. */
|
|
192
|
+
getSessionCookieName() { return this._authGuard !== null ? `navette_${this._authGuard}_session` : `navette_${this._id}_session`; }
|
|
139
193
|
pages(pages) { this._pages.push(...pages); return this; }
|
|
140
194
|
/** Registers resources: their pages and navigation items. */
|
|
141
195
|
resources(resources) {
|
|
@@ -148,7 +202,23 @@ export class Panel {
|
|
|
148
202
|
}
|
|
149
203
|
navigationItems(items) { this._navigationItems.push(...items); return this; }
|
|
150
204
|
/** Order of navigation groups. */
|
|
151
|
-
|
|
205
|
+
/** Order of the navigation groups: labels, or `NavigationGroup.make('Shop').icon('o-shopping-cart').collapsible(false)`. */
|
|
206
|
+
_darkMode = true;
|
|
207
|
+
_darkModeForced = false;
|
|
208
|
+
_defaultThemeMode = 'system';
|
|
209
|
+
/** `false`: light mode only (no switcher); `darkMode(true, true)`: dark mode only. */
|
|
210
|
+
darkMode(condition = true, isForced = false) { this._darkMode = condition; this._darkModeForced = condition && isForced; return this; }
|
|
211
|
+
/** Mode used until the user picks one in the switcher: `'system'` (default), `'light'` or `'dark'`. */
|
|
212
|
+
defaultThemeMode(mode) { if (!['light', 'dark', 'system'].includes(mode))
|
|
213
|
+
throw new Error(`Invalid theme mode: ${mode}`); this._defaultThemeMode = mode; return this; }
|
|
214
|
+
hasDarkMode() { return this._darkMode; }
|
|
215
|
+
hasDarkModeForced() { return this._darkModeForced; }
|
|
216
|
+
getDefaultThemeMode() { return this._defaultThemeMode; }
|
|
217
|
+
navigationGroups(groups) {
|
|
218
|
+
this._navigationGroups = groups.map((group) => (typeof group === 'string' ? group : group.getLabel()));
|
|
219
|
+
this._navigationGroupOptions = new Map(groups.filter((group) => typeof group !== 'string').map((group) => [group.getLabel(), { icon: group.getIcon(), collapsible: group.isCollapsible() }]));
|
|
220
|
+
return this;
|
|
221
|
+
}
|
|
152
222
|
_plugins = [];
|
|
153
223
|
/** Applies a plugin: an object that configures the panel (resources, pages, render hooks, themes…). */
|
|
154
224
|
plugin(plugin) {
|
|
@@ -310,11 +380,29 @@ export class Panel {
|
|
|
310
380
|
mailer(mailer) { this._mailer = mailer; return this; }
|
|
311
381
|
async sendMail(message) { await this._mailer.send(message); }
|
|
312
382
|
getAuthKey() { return this._key; }
|
|
313
|
-
|
|
314
|
-
|
|
383
|
+
_authSlugs = { login: 'login', registration: 'register', passwordResetPrefix: 'password-reset', passwordResetRequest: 'request', passwordReset: 'reset' };
|
|
384
|
+
authSlug(slug) { if (!/^[a-z0-9][a-z0-9-]{0,60}$/.test(slug))
|
|
385
|
+
throw new Error(`Invalid route slug: ${slug}`); return slug; }
|
|
386
|
+
/** URL segment of the login page (`login` by default). */
|
|
387
|
+
loginRouteSlug(slug) { this._authSlugs.login = this.authSlug(slug); return this; }
|
|
388
|
+
/** URL segment of the registration page (`register` by default). */
|
|
389
|
+
registrationRouteSlug(slug) { this._authSlugs.registration = this.authSlug(slug); return this; }
|
|
390
|
+
/** URL segments of the password reset pages: `<prefix>/<request>` and `<prefix>/<reset>` (`password-reset/request` and `password-reset/reset` by default). */
|
|
391
|
+
passwordResetRoutePrefix(prefix) { this._authSlugs.passwordResetPrefix = this.authSlug(prefix); return this; }
|
|
392
|
+
passwordResetRequestRouteSlug(slug) { this._authSlugs.passwordResetRequest = this.authSlug(slug); return this; }
|
|
393
|
+
passwordResetRouteSlug(slug) { this._authSlugs.passwordReset = this.authSlug(slug); return this; }
|
|
394
|
+
/** Paths of the authentication pages, relative to the panel. */
|
|
395
|
+
getAuthPaths() {
|
|
396
|
+
const slugs = this._authSlugs;
|
|
397
|
+
return { login: slugs.login, registration: slugs.registration, passwordResetRequest: `${slugs.passwordResetPrefix}/${slugs.passwordResetRequest}`, passwordReset: `${slugs.passwordResetPrefix}/${slugs.passwordReset}` };
|
|
398
|
+
}
|
|
399
|
+
/** First URL segments used by the panel itself (never page slugs or tenant keys). */
|
|
400
|
+
getReservedSegments() { return new Set([...RESERVED_SEGMENTS, this._authSlugs.login, this._authSlugs.registration, this._authSlugs.passwordResetPrefix]); }
|
|
401
|
+
getRegistrationUrl() { return this.getBaseUrl(this.getAuthPaths().registration); }
|
|
402
|
+
getRequestPasswordResetUrl() { return this.getBaseUrl(this.getAuthPaths().passwordResetRequest); }
|
|
315
403
|
getResetPasswordUrl(user, fingerprint) {
|
|
316
404
|
const token = signToken(this._key, `${this._id}.password-reset`, { uid: user.id, fp: fingerprint }, 60 * 60);
|
|
317
|
-
return `${this.getBaseUrl(
|
|
405
|
+
return `${this.getBaseUrl(this.getAuthPaths().passwordReset)}?email=${encodeURIComponent(user.email)}&token=${encodeURIComponent(token)}`;
|
|
318
406
|
}
|
|
319
407
|
/** Payload of a password reset token valid for this panel, otherwise `null`. */
|
|
320
408
|
readResetPasswordToken(token) { return readToken(this._key, `${this._id}.password-reset`, token); }
|
|
@@ -386,7 +474,7 @@ export class Panel {
|
|
|
386
474
|
}
|
|
387
475
|
/** URL outside any tenant (login, registration, profile, tenant registration). */
|
|
388
476
|
getBaseUrl(sub = '') { return '/' + [this._path, sub.replace(/^\/|\/$/g, '')].filter(Boolean).join('/'); }
|
|
389
|
-
getLoginUrl() { return this.getBaseUrl(
|
|
477
|
+
getLoginUrl() { return this.getBaseUrl(this.getAuthPaths().login); }
|
|
390
478
|
getLogoutUrl() { return this.getBaseUrl('logout'); }
|
|
391
479
|
/** Prefix of this panel's Livewire endpoint (`/livewire` or `/<path>/livewire`): each panel has its own. */
|
|
392
480
|
getLivewirePrefix() { return this._path ? `/${this._path}/livewire` : '/livewire'; }
|
|
@@ -435,18 +523,19 @@ export class Panel {
|
|
|
435
523
|
const order = [null, ...this._navigationGroups, ...items.map((i) => i._group).filter((g) => g !== null && !this._navigationGroups.includes(g))];
|
|
436
524
|
return [...new Set(order)].map((label) => {
|
|
437
525
|
const own = items.filter((i) => i._group === label).map((i) => ({ label: i.label, url: i._url, icon: i._icon, isActive: i.isActive(path), badge: i._badge, badgeColorClasses: this.colors.getComponentClasses('badge', i._badgeColor) }));
|
|
438
|
-
|
|
526
|
+
const options = label ? this._navigationGroupOptions.get(label) : undefined;
|
|
527
|
+
return { label, itemsId: label ? `fi-sidebar-group-items-${slug(label)}-${createHash('md5').update(label).digest('hex').slice(0, 8)}` : '', isActive: own.some((i) => i.isActive), items: options?.icon ? own.map((i) => ({ ...i, icon: null })) : own, icon: options?.icon ?? null, collapsible: options?.collapsible ?? true };
|
|
439
528
|
}).filter((g) => g.items.length);
|
|
440
529
|
}
|
|
441
530
|
// --- session: an HMAC-signed cookie holding the user id
|
|
442
531
|
sign(value) { return createHmac('sha256', this._key).update(value).digest('base64url'); }
|
|
443
532
|
logIn(res, user, remember) {
|
|
444
533
|
const value = Buffer.from(JSON.stringify({ uid: user.id })).toString('base64url');
|
|
445
|
-
res.cookie(
|
|
534
|
+
res.cookie(this.getSessionCookieName(), `${value}.${this.sign(value)}`, { httpOnly: true, sameSite: 'lax', secure: res.req?.secure === true, ...(remember ? { maxAge: 1000 * 60 * 60 * 24 * 30 } : {}) });
|
|
446
535
|
}
|
|
447
|
-
logOut(res) { res.clearCookie(
|
|
536
|
+
logOut(res) { res.clearCookie(this.getSessionCookieName()); }
|
|
448
537
|
async user(req) {
|
|
449
|
-
const raw = (req.headers.cookie ?? '').split(';').map((c) => c.trim()).find((c) => c.startsWith(
|
|
538
|
+
const raw = (req.headers.cookie ?? '').split(';').map((c) => c.trim()).find((c) => c.startsWith(`${this.getSessionCookieName()}=`))?.split('=')[1];
|
|
450
539
|
if (!raw)
|
|
451
540
|
return null;
|
|
452
541
|
const [value, mac] = raw.split('.');
|
|
@@ -674,18 +763,29 @@ export class Page extends BasePage {
|
|
|
674
763
|
async renderContent() { return (await this.getSchema('content')).toHtml(); }
|
|
675
764
|
// --- mounted actions: the stack lives in the component state, modals are rendered server-side
|
|
676
765
|
mountedActions = [];
|
|
766
|
+
/** Files uploaded by rich editors (`componentFileAttachments.<state path>.<id>`), stored by the editor when it asks for their URL. */
|
|
767
|
+
componentFileAttachments = {};
|
|
677
768
|
getActionMountContext() { return {}; }
|
|
678
769
|
getActionRecord() { return null; }
|
|
679
770
|
/** Record targeted by an action mounted in a given context (table row…). */
|
|
680
771
|
async resolveActionRecord(_context) { return this.getActionRecord(); }
|
|
681
772
|
/** Actions that can be mounted in a given context (page header by default). */
|
|
682
773
|
getMountableActions(context) {
|
|
683
|
-
return typeof context.schemaComponent === 'string' ? this.getSchemaComponentActions(context.schemaComponent) : this.getHeaderActions();
|
|
774
|
+
return typeof context.schemaComponent === 'string' ? this.getSchemaComponentActions(context.schemaComponent) : flattenActions(this.getHeaderActions());
|
|
684
775
|
}
|
|
685
776
|
/**
|
|
686
777
|
* Actions owned by a schema component (Repeater: add, delete, move; Section: header actions).
|
|
687
778
|
* The key comes from the browser: it must match a real component of the schema, or the schema itself.
|
|
688
779
|
*/
|
|
780
|
+
/** Visible component of a schema of this page (or of a mounted action's form) with this key. */
|
|
781
|
+
async findSchemaComponent(key) {
|
|
782
|
+
const name = key.split('.')[0];
|
|
783
|
+
const mountedIndex = /^mountedActionSchema(\d+)$/.exec(name);
|
|
784
|
+
if (!mountedIndex && (!/^[A-Za-z]+$/.test(name) || typeof this[`${name}Schema`] !== 'function'))
|
|
785
|
+
return null;
|
|
786
|
+
const schema = mountedIndex ? await this.getMountedActionSchema(Number(mountedIndex[1])) : this.getSchema(name);
|
|
787
|
+
return schema ? (await schema.getVisibleFlatComponents()).find((c) => c.getKey() === key) ?? null : null;
|
|
788
|
+
}
|
|
689
789
|
async getSchemaComponentActions(key) {
|
|
690
790
|
const name = key.split('.')[0];
|
|
691
791
|
// The component may belong to the form of an already mounted action (rich editor in a modal).
|
|
@@ -708,12 +808,42 @@ export class Page extends BasePage {
|
|
|
708
808
|
return typeof component === 'string' && component.length <= 200 ? { ...this.getActionMountContext(), schemaComponent: component } : this.getActionMountContext();
|
|
709
809
|
}
|
|
710
810
|
async findMountableAction(name, context = this.getActionMountContext()) {
|
|
811
|
+
// An action of a modal footer: found among the extra footer actions of its (mounted) parent action.
|
|
812
|
+
if (typeof context.parentAction === 'number') {
|
|
813
|
+
const parent = this.mountedActions[context.parentAction];
|
|
814
|
+
const owner = parent ? await this.findMountableAction(parent.name, parent.context) : null;
|
|
815
|
+
const child = owner?.getExtraModalFooterActions().find((a) => a.getName() === name && a.isMountable());
|
|
816
|
+
if (!child)
|
|
817
|
+
throw new WireError(404, `Action not found: ${String(name)}`);
|
|
818
|
+
return child;
|
|
819
|
+
}
|
|
711
820
|
const action = (await this.getMountableActions(context)).find((a) => a.getName() === name && a.isMountable());
|
|
712
821
|
if (!action)
|
|
713
822
|
throw new WireError(404, `Action not found: ${String(name)}`);
|
|
714
823
|
return action;
|
|
715
824
|
}
|
|
716
|
-
async actionContext(mounted) {
|
|
825
|
+
async actionContext(mounted) {
|
|
826
|
+
const parentIndex = mounted.context.parentAction;
|
|
827
|
+
const parent = typeof parentIndex === 'number' ? this.mountedActions[parentIndex] : undefined;
|
|
828
|
+
const parentData = parent ? (parent.data ??= {}) : undefined;
|
|
829
|
+
// An action of a form field (affix action): its state, and access to the other fields of the same form.
|
|
830
|
+
let fieldAccess = {};
|
|
831
|
+
if (typeof mounted.context.schemaComponent === 'string') {
|
|
832
|
+
const component = await this.findSchemaComponent(mounted.context.schemaComponent);
|
|
833
|
+
const path = typeof component?.getStatePath === 'function' && component.hasStatePath() ? String(component.getStatePath()) : null;
|
|
834
|
+
if (path) {
|
|
835
|
+
const container = path.split('.').slice(0, -1);
|
|
836
|
+
const nodeOf = () => container.reduce((node, segment) => (node && typeof node === 'object' ? node[segment] : undefined), this);
|
|
837
|
+
fieldAccess = {
|
|
838
|
+
state: path.split('.').reduce((node, segment) => (node && typeof node === 'object' ? node[segment] : undefined), this),
|
|
839
|
+
get: (name) => nodeOf()?.[name],
|
|
840
|
+
set: (name, value) => { const node = nodeOf(); if (node && typeof node === 'object' && /^[A-Za-z0-9_]+$/.test(name))
|
|
841
|
+
node[name] = value; },
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return { livewire: this, record: await this.resolveActionRecord(mounted.context), arguments: mounted.arguments, data: emptyToNull(mounted.data ?? {}), ...(parentData ? { parentData } : {}), ...fieldAccess };
|
|
846
|
+
}
|
|
717
847
|
/** Form schema of a mounted action: key `mountedActionSchema<n>`, state under `mountedActions.<n>.data`. */
|
|
718
848
|
async getMountedActionSchema(index) {
|
|
719
849
|
const mounted = this.mountedActions[index];
|
|
@@ -724,7 +854,7 @@ export class Page extends BasePage {
|
|
|
724
854
|
}
|
|
725
855
|
async renderActionModal(index) {
|
|
726
856
|
const mounted = this.mountedActions[index];
|
|
727
|
-
return (await this.findMountableAction(mounted.name, mounted.context)).toModalHtml(await this.actionContext(mounted), index, (await (await this.getMountedActionSchema(index))?.toHtml()) ?? null);
|
|
857
|
+
return (await this.findMountableAction(mounted.name, mounted.context)).toModalHtml(await this.actionContext(mounted), index, (await (await this.getMountedActionSchema(index))?.toHtml()) ?? null, mounted.context);
|
|
728
858
|
}
|
|
729
859
|
/** Cancels a partial modal render prepared by a live field when the same request then runs an action. */
|
|
730
860
|
cancelModalPartialRender() {
|
|
@@ -797,12 +927,23 @@ export class Page extends BasePage {
|
|
|
797
927
|
/** Mounts an action: without a modal it runs immediately; with one, only the modals fragment is returned. */
|
|
798
928
|
async mountAction(name, args = {}, _context = {}) {
|
|
799
929
|
this.cancelModalPartialRender();
|
|
800
|
-
|
|
930
|
+
// A modal footer action: its context is its parent's (kept by the server), plus the parent's index.
|
|
931
|
+
const parentIndex = _context?.parentAction;
|
|
932
|
+
const context = Number.isInteger(parentIndex) && parentIndex >= 0 && parentIndex < this.mountedActions.length
|
|
933
|
+
? { ...this.mountedActions[parentIndex].context, parentAction: parentIndex }
|
|
934
|
+
: this.resolveMountContext(_context);
|
|
801
935
|
const action = await this.findMountableAction(name, context);
|
|
802
936
|
await this.assertActionVisible(action, context);
|
|
803
937
|
const mounted = { name: String(name), arguments: args && typeof args === 'object' ? args : {}, context };
|
|
804
|
-
if (!action.hasModal()) {
|
|
805
|
-
|
|
938
|
+
if (!action.hasModal() || (await action.isModalHidden(await this.actionContext(mounted)))) {
|
|
939
|
+
// Without a modal, `halt()` and `cancel()` simply stop the action.
|
|
940
|
+
try {
|
|
941
|
+
await action.call(await this.actionContext(mounted));
|
|
942
|
+
}
|
|
943
|
+
catch (error) {
|
|
944
|
+
if (!(error instanceof ActionHalt))
|
|
945
|
+
throw error;
|
|
946
|
+
}
|
|
806
947
|
return;
|
|
807
948
|
}
|
|
808
949
|
this.mountedActions.push(mounted);
|
|
@@ -812,10 +953,14 @@ export class Page extends BasePage {
|
|
|
812
953
|
const data = {};
|
|
813
954
|
mounted.data = data;
|
|
814
955
|
fillSchema(schema, data);
|
|
815
|
-
const
|
|
816
|
-
|
|
956
|
+
const fillContext = await this.actionContext(mounted);
|
|
957
|
+
const fill = await action.getFormFill(fillContext);
|
|
958
|
+
if (fill) {
|
|
817
959
|
fillSchemaFromRecord(schema, data, fill);
|
|
960
|
+
await loadSchemaRelationships(schema, data, fillContext.record);
|
|
961
|
+
}
|
|
818
962
|
}
|
|
963
|
+
await action.runMountUsing({ ...(await this.actionContext(mounted)), data: (mounted.data ??= {}), action });
|
|
819
964
|
this.renderPartial('action-modals', await this.renderActionModals());
|
|
820
965
|
this.syncActionModals();
|
|
821
966
|
}
|
|
@@ -841,14 +986,30 @@ export class Page extends BasePage {
|
|
|
841
986
|
}
|
|
842
987
|
}
|
|
843
988
|
const callContext = await this.actionContext(mounted);
|
|
844
|
-
if (schema)
|
|
989
|
+
if (schema) {
|
|
845
990
|
callContext.data = await getSchemaState(schema);
|
|
846
|
-
|
|
847
|
-
|
|
991
|
+
callContext.schema = schema;
|
|
992
|
+
}
|
|
993
|
+
try {
|
|
994
|
+
await action.call(callContext);
|
|
995
|
+
}
|
|
996
|
+
catch (error) {
|
|
997
|
+
// `action.halt()`: the modal stays open; `action.cancel()`: it closes.
|
|
998
|
+
if (!(error instanceof ActionHalt))
|
|
999
|
+
throw error;
|
|
1000
|
+
if (error.closesModal) {
|
|
1001
|
+
this.mountedActions.pop();
|
|
1002
|
+
this.syncActionModals();
|
|
1003
|
+
}
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
// "… & create another": the modal stays open with a reset form (except the preserved fields).
|
|
848
1007
|
if (schema && _args && typeof _args === 'object' && _args.another === true) {
|
|
1008
|
+
const preserved = JSON.parse(JSON.stringify(action.getDataPreservedForAnother(callContext.data ?? {})));
|
|
849
1009
|
const data = {};
|
|
850
1010
|
mounted.data = data;
|
|
851
1011
|
fillSchema(schema, data);
|
|
1012
|
+
Object.assign(data, preserved);
|
|
852
1013
|
return;
|
|
853
1014
|
}
|
|
854
1015
|
this.mountedActions.pop();
|
|
@@ -880,7 +1041,8 @@ export class Dashboard extends Page {
|
|
|
880
1041
|
/** Filters form state. Stays `null` while the page has no filters. */
|
|
881
1042
|
filters = null;
|
|
882
1043
|
/** Dashboard widgets. Defaults to the panel's (`panel.widgets([...])`). */
|
|
883
|
-
|
|
1044
|
+
/** The panel's widgets, ordered by their `sort` (stable). */
|
|
1045
|
+
getWidgets() { return [...this.panel.getWidgets()].map((widget, index) => ({ widget, index })).sort((a, b) => (a.widget.sort ?? Infinity) - (b.widget.sort ?? Infinity) || a.index - b.index).map(({ widget }) => widget); }
|
|
884
1046
|
/** Grid columns: a number (at the `lg` breakpoint), or one number per breakpoint. */
|
|
885
1047
|
getColumns() { return 2; }
|
|
886
1048
|
hasFiltersForm() { return typeof this.filtersFormSchema === 'function'; }
|
|
@@ -1341,7 +1503,8 @@ export class Login extends SimplePage {
|
|
|
1341
1503
|
return;
|
|
1342
1504
|
}
|
|
1343
1505
|
const user = await panel.getAuth().attempt(String(this.data.email), String(this.data.password));
|
|
1344
|
-
|
|
1506
|
+
// As in Filament: a valid account that may not use this panel gets the same message as wrong credentials.
|
|
1507
|
+
if (!user || !(await panel.userCanAccess(user))) {
|
|
1345
1508
|
this.addError('data.email', t('filament-panels::auth/pages/login.messages.failed'));
|
|
1346
1509
|
return;
|
|
1347
1510
|
}
|
|
@@ -1434,7 +1597,11 @@ export class Resource {
|
|
|
1434
1597
|
/** Columns searched by global search; defaults to the title attribute. Empty array: the resource is not searchable. */
|
|
1435
1598
|
static globallySearchableAttributes = null;
|
|
1436
1599
|
static getGloballySearchableAttributes() { return this.globallySearchableAttributes ?? (this.recordTitleAttribute ? [this.recordTitleAttribute] : []); }
|
|
1437
|
-
|
|
1600
|
+
/** `false`: the resource is left out of global search. */
|
|
1601
|
+
static isGloballySearchable = true;
|
|
1602
|
+
/** `false`: the search term is matched as a whole instead of word by word. */
|
|
1603
|
+
static shouldSplitGlobalSearchTerms = true;
|
|
1604
|
+
static canGloballySearch() { return this.isGloballySearchable && this.getGloballySearchableAttributes().length > 0; }
|
|
1438
1605
|
/** Position of this resource's group in the results; without one, after the others in registration order. */
|
|
1439
1606
|
static globalSearchSort = null;
|
|
1440
1607
|
/** Maximum results rendered per resource. */
|
|
@@ -1455,7 +1622,7 @@ export class Resource {
|
|
|
1455
1622
|
return action ? `${this.getUrl('index')}?tableAction=${action}&tableActionRecord=${encodeURIComponent(key)}` : null;
|
|
1456
1623
|
}
|
|
1457
1624
|
static async getGlobalSearchResults(search, user) {
|
|
1458
|
-
const page = await this.adapter.paginate({ search, searchColumns: this.getGloballySearchableAttributes(), page: 1, perPage: Math.min(Math.max(this.globalSearchResultsLimit, 1), 200) });
|
|
1625
|
+
const page = await this.adapter.paginate({ search, searchColumns: this.getGloballySearchableAttributes(), splitSearchTerms: this.shouldSplitGlobalSearchTerms, page: 1, perPage: Math.min(Math.max(this.globalSearchResultsLimit, 1), 200) });
|
|
1459
1626
|
const results = [];
|
|
1460
1627
|
for (const record of page.records) {
|
|
1461
1628
|
const url = await this.getGlobalSearchResultUrl(record, user);
|
|
@@ -1527,8 +1694,10 @@ export class Resource {
|
|
|
1527
1694
|
/** Widget: a component displayed in a page's widget grid. */
|
|
1528
1695
|
export class Widget extends Component {
|
|
1529
1696
|
static componentName;
|
|
1530
|
-
/** Width in the widget grid: a number of columns
|
|
1697
|
+
/** Width in the widget grid: a number of columns or `'full'` (from `lg`), or a span per breakpoint (`{ md: 2, xl: 3 }`). */
|
|
1531
1698
|
static columnSpan = 1;
|
|
1699
|
+
/** Position on the dashboard (lower first); widgets without a sort keep their order, after the sorted ones. */
|
|
1700
|
+
static sort = null;
|
|
1532
1701
|
/** Loaded when it becomes visible. */
|
|
1533
1702
|
static isLazy = true;
|
|
1534
1703
|
get panel() { return this._ctx.panel; }
|
|
@@ -1542,9 +1711,16 @@ export class Widget extends Component {
|
|
|
1542
1711
|
if (!(await ctx.panel.authorize(ctx.user, 'view', { widget: WidgetClass, rule: () => WidgetClass.canView(ctx.user) })))
|
|
1543
1712
|
throw new WireError(403, 'Forbidden.');
|
|
1544
1713
|
}
|
|
1545
|
-
/** Grid column classes and style
|
|
1714
|
+
/** Grid column classes and style: at the `lg` breakpoint, or per breakpoint. */
|
|
1546
1715
|
gridColumn() {
|
|
1547
1716
|
const span = this.constructor.columnSpan;
|
|
1717
|
+
if (typeof span === 'object') {
|
|
1718
|
+
const entries = Object.entries(span).filter(([breakpoint, value]) => /^(default|sm|md|lg|xl|2xl)$/.test(breakpoint) && (value === 'full' || (Number.isInteger(value) && value > 0)));
|
|
1719
|
+
return {
|
|
1720
|
+
classes: ['fi-grid-col', ...entries.map(([breakpoint]) => (breakpoint === 'default' ? 'fi-grid-col-span' : `${breakpoint}:fi-grid-col-span`))],
|
|
1721
|
+
style: entries.map(([breakpoint, value]) => `--col-span-${breakpoint}: ${value === 'full' ? '1 / -1' : `span ${value} / span ${value}`};`).join(' '),
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1548
1724
|
// The span is always written, even for a single column.
|
|
1549
1725
|
return { classes: ['fi-grid-col', 'lg:fi-grid-col-span'], style: `--col-span-lg: ${span === 'full' ? '1 / -1' : `span ${Math.floor(span)} / span ${Math.floor(span)}`};` };
|
|
1550
1726
|
}
|
|
@@ -1842,6 +2018,10 @@ export class StatsOverviewWidget extends Widget {
|
|
|
1842
2018
|
static columnSpan = 'full';
|
|
1843
2019
|
/** Polling interval, or `null` to disable polling. */
|
|
1844
2020
|
static pollingInterval = '5s';
|
|
2021
|
+
/** Heading shown above the stats. */
|
|
2022
|
+
getHeading() { return null; }
|
|
2023
|
+
/** Description under the heading. */
|
|
2024
|
+
getDescription() { return null; }
|
|
1845
2025
|
/** Row columns: three, or four when the number of stats fits better. */
|
|
1846
2026
|
getColumns(count) { return count < 3 ? 3 : count % 3 !== 1 ? 3 : 4; }
|
|
1847
2027
|
async render() {
|
|
@@ -1855,11 +2035,13 @@ export class StatsOverviewWidget extends Widget {
|
|
|
1855
2035
|
const id = `stats-overview-stat-${index}`, key = `content.${id}`;
|
|
1856
2036
|
return `<div wire:partial="schema-component::${key}" x-data="filamentSchemaComponent(${e(js({ key, exposedMethods: [], path: '', containerPath: '' }))})" wire:key="${e(`${this.id}.${key}`)}" class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component">${stat.toHtml(this.panel, id)}</div></div>`;
|
|
1857
2037
|
}).join('');
|
|
2038
|
+
const heading = this.getHeading(), description = this.getDescription();
|
|
2039
|
+
const header = heading !== null || description !== null ? `<header class="fi-section-header"><div class="fi-section-header-content fi-section-header-content-has-heading"><div class="fi-section-header-text-ctn">${heading !== null ? `<h3 class="fi-section-header-heading">${e(heading)}</h3>` : ''}${description !== null ? `<p class="fi-section-header-description">${e(description)}</p>` : ''}</div></div></header>` : '';
|
|
1858
2040
|
const interval = this.constructor.pollingInterval;
|
|
1859
2041
|
const poll = interval && /^[0-9]+(ms|s)$/.test(interval) ? ` wire:poll.${interval}` : '';
|
|
1860
2042
|
return this.wrap(`<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'content' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));"><div ${anonymous} class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component fi-grid-ctn"><div${poll} class="fi-sc-section"><section x-data="{
|
|
1861
2043
|
isCollapsed: false,
|
|
1862
|
-
}" class="fi-section fi-section-not-contained"
|
|
2044
|
+
}" class="fi-section fi-section-not-contained${header ? ' fi-section-has-header' : ''}">${header}<div class="fi-section-content-ctn"><div class="fi-section-content fi-grid @xl:fi-grid-cols !@lg:fi-grid-cols fi-sc fi-sc-has-gap" style="--cols-cxl: repeat(${columns}, minmax(0, 1fr)); --cols-nclg: repeat(${columns}, minmax(0, 1fr)); --cols-default: repeat(1, minmax(0, 1fr));">${cells}</div></div></section></div></div></div></div>`, ['fi-wi-stats-overview']);
|
|
1863
2045
|
}
|
|
1864
2046
|
}
|
|
1865
2047
|
/**
|
|
@@ -1868,6 +2050,10 @@ export class StatsOverviewWidget extends Widget {
|
|
|
1868
2050
|
* has changed (checksum).
|
|
1869
2051
|
*/
|
|
1870
2052
|
export class ChartWidget extends Widget {
|
|
2053
|
+
/** Empty state shown when `getData()` returns nothing. */
|
|
2054
|
+
getEmptyStateHeading() { return t('filament-widgets::chart.empty.heading'); }
|
|
2055
|
+
getEmptyStateDescription() { return null; }
|
|
2056
|
+
getEmptyStateIcon() { return 'o-x-mark'; }
|
|
1871
2057
|
static callable = ['updateChartData'];
|
|
1872
2058
|
static locked = ['dataChecksum'];
|
|
1873
2059
|
/** Polling interval, or `null` to disable polling. */
|
|
@@ -1933,7 +2119,7 @@ export class ChartWidget extends Widget {
|
|
|
1933
2119
|
type: ${e(js(type))},
|
|
1934
2120
|
})" class="${cls([...this.panel.colors.getComponentClasses('stat-chart', this.getColor()), 'fi-wi-chart-frame', 'fi-wi-chart-canvas-ctn', { 'fi-wi-chart-frame-no-aspect-ratio': hasMaxHeight }])}"><canvas x-ref="canvas" aria-hidden="true" style="width: 100%; ${hasMaxHeight ? `max-height: ${e(maxHeight)}` : 'height: 100%; max-height: 100%'}"></canvas>${colors.map((name, index) => `<span aria-hidden="true" x-ref="${refs[index]}ColorElement" class="fi-wi-chart-${name}-color"></span>`).join('')}</div>`;
|
|
1935
2121
|
const empty = isEmpty
|
|
1936
|
-
? `<div class="${cls(['fi-wi-chart-frame', { 'fi-wi-chart-frame-no-aspect-ratio': hasMaxHeight }])}"${hasMaxHeight ? ` style="min-height: ${e(maxHeight)}"` : ''}>${emptyState({ heading:
|
|
2122
|
+
? `<div class="${cls(['fi-wi-chart-frame', { 'fi-wi-chart-frame-no-aspect-ratio': hasMaxHeight }])}"${hasMaxHeight ? ` style="min-height: ${e(maxHeight)}"` : ''}>${emptyState({ heading: this.getEmptyStateHeading(), description: this.getEmptyStateDescription() ?? undefined, icon: this.getEmptyStateIcon() ?? undefined, iconColor: 'gray', contained: false })}</div>`
|
|
1937
2123
|
: '';
|
|
1938
2124
|
const body = `<div${poll}${isEmpty ? ' style="display: none"' : ''}><div class="fi-wi-chart-image"${label ? ` role="img" aria-label="${e(label)}"` : ''}>${frame}</div>${assistive ? `<div class="fi-wi-chart-assistive-content fi-sr-only"><p>${e(assistive)}</p></div>` : ''}</div>${empty}`;
|
|
1939
2125
|
return this.wrap(`<section x-data="{
|
|
@@ -2078,6 +2264,13 @@ export class Tab {
|
|
|
2078
2264
|
badge(resolver) { this._badge = resolver; return this; }
|
|
2079
2265
|
badgeColor(color) { this._badgeColor = color; this._hasBadgeColor = true; return this; }
|
|
2080
2266
|
query(modifier) { this._query = modifier; return this; }
|
|
2267
|
+
_icon = null;
|
|
2268
|
+
_iconPosition = 'before';
|
|
2269
|
+
/** Icon of the tab, before its label (or after, with `iconPosition('after')`). */
|
|
2270
|
+
icon(name) { this._icon = name; return this; }
|
|
2271
|
+
iconPosition(position) { this._iconPosition = position; return this; }
|
|
2272
|
+
getIcon() { return this._icon; }
|
|
2273
|
+
getIconPosition() { return this._iconPosition; }
|
|
2081
2274
|
getLabel(key) { return this._label ?? ucfirst(key.replace(/[_-]+/g, ' ')); }
|
|
2082
2275
|
hasBadge() { return this._badge !== null; }
|
|
2083
2276
|
async getBadge() { return this._badge ? this._badge() : null; }
|
|
@@ -2132,7 +2325,7 @@ class ResourceTabs extends SchemaComponent {
|
|
|
2132
2325
|
${d}?.badgeIconHtml &&
|
|
2133
2326
|
${d}?.badgeIconPosition === 'after'
|
|
2134
2327
|
"><span x-html="${d}.badgeIconHtml"></span></template></span></template>` : '';
|
|
2135
|
-
return `<button id="fi-sc-tab-${createHash('md5').update(`${key}.${k}`).digest('hex')}" aria-pressed="${k === active ? 'true' : 'false'}" x-bind:aria-pressed="isTabSelected(${q(k)})" tabindex="0" x-bind:tabindex="getTabIndex(${q(k)})" data-tab-key="${e(k)}" x-bind:data-tab-available="Boolean(true).toString()" x-show="true" x-on:click.capture="if (! selectTab(${q(k)}, $event)) { $event.preventDefault(); $event.stopImmediatePropagation() }" x-bind:class="{ 'fi-active': isTabSelected(${q(k)}) }" type="button" wire:click="$set('activeTab', ${k === '' ? 'null' : q(k)})" class="${cls(['fi-tabs-item', { 'fi-active': k === active }])}"
|
|
2328
|
+
return `<button id="fi-sc-tab-${createHash('md5').update(`${key}.${k}`).digest('hex')}" aria-pressed="${k === active ? 'true' : 'false'}" x-bind:aria-pressed="isTabSelected(${q(k)})" tabindex="0" x-bind:tabindex="getTabIndex(${q(k)})" data-tab-key="${e(k)}" x-bind:data-tab-available="Boolean(true).toString()" x-show="true" x-on:click.capture="if (! selectTab(${q(k)}, $event)) { $event.preventDefault(); $event.stopImmediatePropagation() }" x-bind:class="{ 'fi-active': isTabSelected(${q(k)}) }" type="button" wire:click="$set('activeTab', ${k === '' ? 'null' : q(k)})" class="${cls(['fi-tabs-item', { 'fi-active': k === active }])}">${tab.getIcon() && tab.getIconPosition() === 'before' ? icon(tab.getIcon(), { attributes: new Attributes().class(['fi-tabs-item-icon']) }) : ''}<span class="fi-tabs-item-label">${e(tab.getLabel(k))}</span>${tab.getIcon() && tab.getIconPosition() === 'after' ? icon(tab.getIcon(), { attributes: new Attributes().class(['fi-tabs-item-icon']) }) : ''}${badge}</button>`;
|
|
2136
2329
|
});
|
|
2137
2330
|
return `<div x-data="tabsSchemaComponent({
|
|
2138
2331
|
activeTab: ${Math.max(tabs.findIndex(([k]) => k === active), 0) + 1},
|
|
@@ -2194,10 +2387,73 @@ export class ListRecords extends Page {
|
|
|
2194
2387
|
if (allowed.delete(key))
|
|
2195
2388
|
await adapter.update(key, { [column]: ++rank });
|
|
2196
2389
|
}
|
|
2390
|
+
/** Key of this table's persisted state: per page class (and resource or relationship). */
|
|
2391
|
+
tableSessionKey(part) {
|
|
2392
|
+
const page = this.constructor;
|
|
2393
|
+
return ['tables', page.name, page.resource?.slug ?? '', page.relationship ?? '', part].join(':');
|
|
2394
|
+
}
|
|
2395
|
+
/** Session of the persisted table state: the browser (its CSRF token) and the signed-in user. */
|
|
2396
|
+
tableSession() {
|
|
2397
|
+
const ctx = this._ctx;
|
|
2398
|
+
return ctx?.mailbox ? `${ctx.panel.getId()}:${ctx.user?.id ?? ''}:${ctx.mailbox}` : null;
|
|
2399
|
+
}
|
|
2400
|
+
async dehydrate() {
|
|
2401
|
+
await super.dehydrate?.();
|
|
2402
|
+
const table = this.getTable(), session = this.tableSession();
|
|
2403
|
+
if (!session)
|
|
2404
|
+
return;
|
|
2405
|
+
const store = this._ctx.panel.getSessionStore();
|
|
2406
|
+
if (table.persistsFiltersInSession())
|
|
2407
|
+
await store.set(session, this.tableSessionKey('filters'), this.tableFilters);
|
|
2408
|
+
if (table.persistsSearchInSession())
|
|
2409
|
+
await store.set(session, this.tableSessionKey('search'), this.tableSearch);
|
|
2410
|
+
if (table.persistsColumnSearchesInSession())
|
|
2411
|
+
await store.set(session, this.tableSessionKey('column-searches'), this.tableColumnSearches);
|
|
2412
|
+
if (table.persistsSortInSession())
|
|
2413
|
+
await store.set(session, this.tableSessionKey('sort'), this.tableSort);
|
|
2414
|
+
if (table.persistsGroupingInSession())
|
|
2415
|
+
await store.set(session, this.tableSessionKey('grouping'), this.tableGrouping);
|
|
2416
|
+
}
|
|
2417
|
+
/** Restores the persisted table state; every value is sanitized like browser input. */
|
|
2418
|
+
async restoreTableSession(table) {
|
|
2419
|
+
const session = this.tableSession();
|
|
2420
|
+
if (!session)
|
|
2421
|
+
return;
|
|
2422
|
+
const store = this._ctx.panel.getSessionStore();
|
|
2423
|
+
if (table.persistsFiltersInSession()) {
|
|
2424
|
+
const saved = await store.get(session, this.tableSessionKey('filters'));
|
|
2425
|
+
if (saved && typeof saved === 'object') {
|
|
2426
|
+
this.tableFilters = this.sanitizeTableFilters(saved);
|
|
2427
|
+
this.tableDeferredFilters = JSON.parse(JSON.stringify(this.tableFilters));
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
if (table.persistsSearchInSession()) {
|
|
2431
|
+
const saved = await store.get(session, this.tableSessionKey('search'));
|
|
2432
|
+
if (typeof saved === 'string')
|
|
2433
|
+
this.tableSearch = saved.slice(0, 1000);
|
|
2434
|
+
}
|
|
2435
|
+
if (table.persistsColumnSearchesInSession()) {
|
|
2436
|
+
const saved = await store.get(session, this.tableSessionKey('column-searches'));
|
|
2437
|
+
if (saved && typeof saved === 'object')
|
|
2438
|
+
this.tableColumnSearches = Object.fromEntries(Object.entries(saved).filter(([name, value]) => typeof value === 'string' && table.getColumn(name)?.isIndividuallySearchable()).map(([name, value]) => [name, value.slice(0, 1000)]));
|
|
2439
|
+
}
|
|
2440
|
+
if (table.persistsSortInSession()) {
|
|
2441
|
+
const saved = await store.get(session, this.tableSessionKey('sort'));
|
|
2442
|
+
if (typeof saved === 'string' || saved === null)
|
|
2443
|
+
this.tableSort = saved;
|
|
2444
|
+
}
|
|
2445
|
+
// Only a group the table declares is kept: `getTableGrouping()` ignores anything else.
|
|
2446
|
+
if (table.persistsGroupingInSession()) {
|
|
2447
|
+
const saved = await store.get(session, this.tableSessionKey('grouping'));
|
|
2448
|
+
if (typeof saved === 'string' && saved.length <= 200)
|
|
2449
|
+
this.tableGrouping = saved;
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2197
2452
|
async mount(_params = {}) {
|
|
2198
2453
|
const table = this.getTable();
|
|
2199
2454
|
this.tableFilters = table.getDefaultFilterState();
|
|
2200
2455
|
this.tableDeferredFilters = table.getDefaultFilterState();
|
|
2456
|
+
await this.restoreTableSession(table);
|
|
2201
2457
|
this.tableColumns = table.getDefaultColumnState();
|
|
2202
2458
|
// Preselect the default group.
|
|
2203
2459
|
initializeTableGrouping(table, this);
|
|
@@ -2216,43 +2472,71 @@ export class ListRecords extends Page {
|
|
|
2216
2472
|
/** Filters form: one group per filter, bound to `tableDeferredFilters.<filter>`. */
|
|
2217
2473
|
tableFiltersFormSchema(schema) {
|
|
2218
2474
|
const table = this.getTable();
|
|
2219
|
-
//
|
|
2220
|
-
const
|
|
2221
|
-
|
|
2475
|
+
// Deferred filters edit a copy applied by the "Apply" button; otherwise the form edits the applied state, field by field.
|
|
2476
|
+
const deferred = table.hasDeferredFilters();
|
|
2477
|
+
const groups = table.getFilters().map((filter) => {
|
|
2222
2478
|
// A filter may provide its own form (query builder); otherwise it is a select.
|
|
2223
2479
|
const custom = filter.getFormComponents?.();
|
|
2224
2480
|
if (custom)
|
|
2225
2481
|
return StatefulGroup.make(filter.name, custom).columnSpan(filter.getColumnSpan?.() ?? { default: 1, lg: 1 });
|
|
2226
2482
|
const definition = filter.getFormDefinition();
|
|
2227
2483
|
return StatefulGroup.make(filter.name, [Select.make('value').label(filter.getLabel()).placeholder(definition.placeholder).options(filter.getFormOptions ? () => filter.getFormOptions() : definition.options)]).columnSpan({ default: 1, lg: 1 });
|
|
2484
|
+
});
|
|
2485
|
+
// `filtersFormSchema()`: the application arranges the filter groups (sections, columns...).
|
|
2486
|
+
const arrange = table.getFiltersFormSchema();
|
|
2487
|
+
const components = arrange ? arrange(Object.fromEntries(table.getFilters().map((filter, index) => [filter.name, groups[index]]))) : groups;
|
|
2488
|
+
const built = schema.statePath(deferred ? 'tableDeferredFilters' : 'tableFilters').columns(table.getFiltersFormColumns()).partiallyRendered().components(components);
|
|
2489
|
+
if (!deferred)
|
|
2490
|
+
for (const component of built.getFlatComponents())
|
|
2491
|
+
if (component instanceof Field)
|
|
2492
|
+
component.live();
|
|
2493
|
+
return built;
|
|
2494
|
+
}
|
|
2495
|
+
/** Applied state of every declared filter, sanitized from the given (browser) state. */
|
|
2496
|
+
sanitizeTableFilters(source) {
|
|
2497
|
+
return Object.fromEntries(this.getTable().getFilters().map((filter) => {
|
|
2498
|
+
const received = source?.[filter.name];
|
|
2499
|
+
if (filter.sanitizeState)
|
|
2500
|
+
return [filter.name, filter.sanitizeState(received)];
|
|
2501
|
+
return [filter.name, Object.fromEntries(Object.keys(filter.getDefaultState()).map((key) => [key, received?.[key] ?? null]))];
|
|
2228
2502
|
}));
|
|
2229
2503
|
}
|
|
2504
|
+
/** Live filters (`deferFilters(false)`): a changed field applies at once. */
|
|
2505
|
+
async updatedTableFilters(value, key) {
|
|
2506
|
+
if (this.getTable().hasDeferredFilters()) {
|
|
2507
|
+
this.tableFilters = this.sanitizeTableFilters(this.tableFilters);
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
await this.runFilterFieldHook('tableFilters', key);
|
|
2511
|
+
this.tableFilters = this.sanitizeTableFilters(this.tableFilters);
|
|
2512
|
+
this.tableDeferredFilters = JSON.parse(JSON.stringify(this.tableFilters));
|
|
2513
|
+
this.paginators.page = 1;
|
|
2514
|
+
this.dispatch('deselectAllTableRecords', [], { self: true });
|
|
2515
|
+
}
|
|
2230
2516
|
applyTableFilters() {
|
|
2231
2517
|
// Only filters declared by the table reach the applied state: each sanitizes its own state, or else only its known keys are kept.
|
|
2232
|
-
this.tableFilters =
|
|
2233
|
-
const deferred = this.tableDeferredFilters?.[filter.name];
|
|
2234
|
-
if (filter.sanitizeState)
|
|
2235
|
-
return [filter.name, filter.sanitizeState(deferred)];
|
|
2236
|
-
return [filter.name, Object.fromEntries(Object.keys(filter.getDefaultState()).map((key) => [key, deferred?.[key] ?? null]))];
|
|
2237
|
-
}));
|
|
2518
|
+
this.tableFilters = this.sanitizeTableFilters(this.tableDeferredFilters);
|
|
2238
2519
|
this.paginators.page = 1;
|
|
2239
2520
|
}
|
|
2240
2521
|
/** Removes an active filter indicator: the field is cleared from both the applied state and the form. */
|
|
2241
|
-
removeTableFilter(filterName, field = null) {
|
|
2242
|
-
const
|
|
2243
|
-
|
|
2522
|
+
async removeTableFilter(filterName, field = null) {
|
|
2523
|
+
const table = this.getTable();
|
|
2524
|
+
const filter = table.getFilters().find((candidate) => candidate.name === filterName);
|
|
2525
|
+
// A filter showing a non-removable indicator cannot be removed by a forged call either.
|
|
2526
|
+
if (!filter || !(await isTableFilterRemovable(table, this, filter.name)))
|
|
2244
2527
|
return;
|
|
2245
2528
|
const current = this.tableFilters?.[filter.name];
|
|
2246
|
-
const next = filter.removeIndicator && typeof field === 'string' && current && typeof current === 'object' ? filter.removeIndicator(current, field) : filter.getDefaultState();
|
|
2529
|
+
const next = filter.removeIndicator && typeof field === 'string' && current && typeof current === 'object' ? filter.removeIndicator(current, field) : (filter.getResetState?.() ?? filter.getDefaultState());
|
|
2247
2530
|
this.tableFilters = { ...this.tableFilters, [filter.name]: next };
|
|
2248
2531
|
this.tableDeferredFilters = { ...this.tableDeferredFilters, [filter.name]: JSON.parse(JSON.stringify(next)) };
|
|
2249
2532
|
this.paginators.page = 1;
|
|
2250
2533
|
}
|
|
2251
2534
|
/** Live field in the filters form (e.g. a rule's operator changed): runs its `afterStateUpdated`. */
|
|
2252
|
-
async updatedTableDeferredFilters(_value, key) {
|
|
2535
|
+
async updatedTableDeferredFilters(_value, key) { await this.runFilterFieldHook('tableDeferredFilters', key); }
|
|
2536
|
+
async runFilterFieldHook(property, key) {
|
|
2253
2537
|
if (!key)
|
|
2254
2538
|
return;
|
|
2255
|
-
const path =
|
|
2539
|
+
const path = `${property}.${key}`;
|
|
2256
2540
|
const field = this.getSchema('tableFiltersForm').getFlatComponents().find((c) => c instanceof Field && c.getStatePath() === path);
|
|
2257
2541
|
if (!field?.hasAfterStateUpdated())
|
|
2258
2542
|
return;
|
|
@@ -2273,7 +2557,21 @@ export class ListRecords extends Page {
|
|
|
2273
2557
|
this.paginators.page = 1;
|
|
2274
2558
|
this.dispatch('deselectAllTableRecords', [], { self: true });
|
|
2275
2559
|
}
|
|
2276
|
-
|
|
2560
|
+
/** "Remove all": resets every removable filter (to its empty state, not its default), the search and the column searches. */
|
|
2561
|
+
async removeTableFilters() {
|
|
2562
|
+
const table = this.getTable();
|
|
2563
|
+
for (const filter of table.getFilters()) {
|
|
2564
|
+
if (!(await isTableFilterRemovable(table, this, filter.name)))
|
|
2565
|
+
continue;
|
|
2566
|
+
const reset = filter.getResetState?.() ?? filter.getDefaultState();
|
|
2567
|
+
this.tableFilters = { ...this.tableFilters, [filter.name]: reset };
|
|
2568
|
+
this.tableDeferredFilters = { ...this.tableDeferredFilters, [filter.name]: JSON.parse(JSON.stringify(reset)) };
|
|
2569
|
+
}
|
|
2570
|
+
this.tableSearch = '';
|
|
2571
|
+
this.tableColumnSearches = {};
|
|
2572
|
+
this.paginators.page = 1;
|
|
2573
|
+
this.dispatch('deselectAllTableRecords', [], { self: true });
|
|
2574
|
+
}
|
|
2277
2575
|
applyTableColumnManager(state = null, _wasReordered = false) { if (state)
|
|
2278
2576
|
this.tableColumns = sanitizeColumnState(this.getTable(), state); }
|
|
2279
2577
|
resetTableColumnManager() { this.tableColumns = this.getTable().getDefaultColumnState(); }
|
|
@@ -2300,7 +2598,11 @@ export class ListRecords extends Page {
|
|
|
2300
2598
|
rendersActionModalsInPage() { return false; }
|
|
2301
2599
|
getBulkActions() { return this.getTable().getBulkActions() ?? [DeleteBulkAction.make()]; }
|
|
2302
2600
|
/** Row actions declared by the table, with groups flattened. */
|
|
2303
|
-
getRecordActions() {
|
|
2601
|
+
getRecordActions() {
|
|
2602
|
+
const table = this.getTable();
|
|
2603
|
+
// Column actions are record actions too: mounted with the record key, and checked the same way.
|
|
2604
|
+
return [...table.getRecordActions().flatMap((a) => (a instanceof ActionGroup ? a.getActions() : [a])), ...table.getColumnActions()];
|
|
2605
|
+
}
|
|
2304
2606
|
getMountableActions(context) {
|
|
2305
2607
|
if (typeof context.schemaComponent === 'string')
|
|
2306
2608
|
return this.getSchemaComponentActions(context.schemaComponent);
|
|
@@ -2309,8 +2611,8 @@ export class ListRecords extends Page {
|
|
|
2309
2611
|
if (context.table === true && typeof context.recordKey === 'string')
|
|
2310
2612
|
return this.getRecordActions();
|
|
2311
2613
|
if (context.table === true)
|
|
2312
|
-
return this.getTable().getHeaderActions();
|
|
2313
|
-
return this.getHeaderActions();
|
|
2614
|
+
return flattenActions([...this.getTable().getHeaderActions(), ...this.getTable().getToolbarActions(), ...this.getTable().getEmptyStateActions()]);
|
|
2615
|
+
return flattenActions(this.getHeaderActions());
|
|
2314
2616
|
}
|
|
2315
2617
|
/** Only known context shapes are kept; the record key is checked again in the database on mount. */
|
|
2316
2618
|
resolveMountContext(clientContext) {
|
|
@@ -2339,11 +2641,73 @@ export class ListRecords extends Page {
|
|
|
2339
2641
|
/** Selected keys: the sent list, or, in "select all" mode, every record of the current query except deselected ones. */
|
|
2340
2642
|
async getSelectedRecordKeys() {
|
|
2341
2643
|
const clean = (list) => (Array.isArray(list) ? list.filter((k) => typeof k === 'string' || typeof k === 'number').map(String) : []);
|
|
2644
|
+
const table = this.getTable(), adapter = this.getDataAdapterForTable();
|
|
2645
|
+
let keys;
|
|
2342
2646
|
// The selection comes from the browser: it is narrowed to records this table can actually display.
|
|
2343
2647
|
if (!this.isTrackingDeselectedTableRecords)
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2648
|
+
keys = await adapter.keysInScope(clean(this.selectedTableRecords));
|
|
2649
|
+
else {
|
|
2650
|
+
const excluded = new Set(clean(this.deselectedTableRecords));
|
|
2651
|
+
// "Select all" covers the whole query, or only the current page with `selectCurrentPageOnly()`.
|
|
2652
|
+
const all = table.selectsCurrentPageOnly() ? (await getTableRecords(table, this, adapter)).records.map((record) => adapter.getKey(record)) : await adapter.listKeys(getTableQuery(table, this));
|
|
2653
|
+
keys = all.filter((k) => !excluded.has(k));
|
|
2654
|
+
}
|
|
2655
|
+
// Records that cannot be selected, and anything beyond `maxSelectableRecords()`, are dropped whatever the browser sent.
|
|
2656
|
+
keys = await filterSelectableKeys(table, adapter, keys);
|
|
2657
|
+
const max = table.getMaxSelectableRecords();
|
|
2658
|
+
return max !== null ? keys.slice(0, max) : keys;
|
|
2659
|
+
}
|
|
2660
|
+
/**
|
|
2661
|
+
* Runs `process` for each selected record, loaded in chunks of `chunkSelectedRecords()`. Records failing the action's
|
|
2662
|
+
* `authorizeIndividualRecords()` are skipped; `process` reports a failure by returning `false` or throwing. Then sends the
|
|
2663
|
+
* action's success notification, or its failure notification with the number of records skipped and failed.
|
|
2664
|
+
*/
|
|
2665
|
+
async processSelectedRecords(action, process) {
|
|
2666
|
+
const keys = await this.getSelectedRecordKeys();
|
|
2667
|
+
const adapter = this.getDataAdapterForTable();
|
|
2668
|
+
const authorization = action.getIndividualRecordsAuthorization();
|
|
2669
|
+
const ctx = { livewire: this, record: null, arguments: {}, data: {}, action };
|
|
2670
|
+
let success = 0, unauthorized = 0, failed = 0;
|
|
2671
|
+
const size = action.getSelectedRecordsChunkSize();
|
|
2672
|
+
for (let index = 0; index < keys.length; index += size) {
|
|
2673
|
+
const records = await Promise.all(keys.slice(index, index + size).map((key) => adapter.find(key)));
|
|
2674
|
+
for (const record of records) {
|
|
2675
|
+
if (!record) {
|
|
2676
|
+
failed++;
|
|
2677
|
+
continue;
|
|
2678
|
+
}
|
|
2679
|
+
const allowed = authorization === null ? true : typeof authorization === 'string' ? await canOn(this, authorization, record) : await authorization(record, { ...ctx, record });
|
|
2680
|
+
if (!allowed) {
|
|
2681
|
+
unauthorized++;
|
|
2682
|
+
continue;
|
|
2683
|
+
}
|
|
2684
|
+
try {
|
|
2685
|
+
if ((await process(record)) === false)
|
|
2686
|
+
failed++;
|
|
2687
|
+
else
|
|
2688
|
+
success++;
|
|
2689
|
+
}
|
|
2690
|
+
catch (error) {
|
|
2691
|
+
failed++;
|
|
2692
|
+
if (failed === 1)
|
|
2693
|
+
console.error(error);
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
const total = keys.length;
|
|
2698
|
+
if (unauthorized + failed === 0) {
|
|
2699
|
+
const title = action.getSuccessNotificationTitle();
|
|
2700
|
+
if (title)
|
|
2701
|
+
Notification.make().success().title(title).send();
|
|
2702
|
+
}
|
|
2703
|
+
else {
|
|
2704
|
+
const body = [action.getMissingBulkAuthorizationFailureNotificationMessage(unauthorized, total) && unauthorized ? action.getMissingBulkAuthorizationFailureNotificationMessage(unauthorized, total) : null, failed ? action.getMissingBulkProcessingFailureNotificationMessage(failed, total) : null].filter(Boolean).join(' ');
|
|
2705
|
+
const notification = Notification.make().title(action.getFailureNotificationTitle(success, total) ?? action.getSuccessNotificationTitle() ?? '');
|
|
2706
|
+
(success ? notification.warning() : notification.danger()).body(body || null).send();
|
|
2707
|
+
}
|
|
2708
|
+
if (action.shouldDeselectRecordsAfterCompletion())
|
|
2709
|
+
this.deselectAllTableRecords();
|
|
2710
|
+
return { total, success, unauthorized, failed };
|
|
2347
2711
|
}
|
|
2348
2712
|
deselectAllTableRecords() {
|
|
2349
2713
|
this.selectedTableRecords = [];
|
|
@@ -2366,7 +2730,7 @@ export class ListRecords extends Page {
|
|
|
2366
2730
|
bulkActions.push(await action.toGroupedHtml(ctx, { table: true, bulk: true }));
|
|
2367
2731
|
const table = this.getTable();
|
|
2368
2732
|
return renderTable(table, this, resource.adapter, {
|
|
2369
|
-
bulkActions, headerActions: await this.renderTableHeaderActions(ctx), after: await this.renderActionModals(),
|
|
2733
|
+
bulkActions, headerActions: await this.renderTableHeaderActions(ctx), toolbarActions: await this.renderTableToolbarActions(ctx), emptyStateActions: await this.renderTableEmptyStateActions(ctx), after: await this.renderActionModals(),
|
|
2370
2734
|
filtersForm: table.getFilters().length ? await this.getSchema('tableFiltersForm').toHtml() : null,
|
|
2371
2735
|
rowActionsHtml: table.getRecordActions().length ? async (record) => {
|
|
2372
2736
|
const key = resource.adapter.getKey(record);
|
|
@@ -2385,7 +2749,21 @@ export class ListRecords extends Page {
|
|
|
2385
2749
|
rowActions: async (record) => resource.isSimple || table.getRecordActions().length || !(await canOn(this, 'update', record)) ? [] : [{ label: t('filament-actions::edit.single.label'), url: resource.getUrl('edit', resource.adapter.getKey(record)), icon: 'c-pencil-square', key: `edit.${createHash('md5').update(resource.adapter.getKey(record)).digest('hex')}` }],
|
|
2386
2750
|
});
|
|
2387
2751
|
}
|
|
2752
|
+
async renderTableEmptyStateActions(ctx) {
|
|
2753
|
+
const html = [];
|
|
2754
|
+
for (const action of this.getTable().getEmptyStateActions())
|
|
2755
|
+
if (await action.isVisible(ctx))
|
|
2756
|
+
html.push(await action.toHtml(ctx, { table: true }));
|
|
2757
|
+
return html;
|
|
2758
|
+
}
|
|
2388
2759
|
/** Table header actions (none on a list page: they go in the page header). */
|
|
2760
|
+
async renderTableToolbarActions(ctx) {
|
|
2761
|
+
const html = [];
|
|
2762
|
+
for (const action of this.getTable().getToolbarActions())
|
|
2763
|
+
if (await action.isVisible(ctx))
|
|
2764
|
+
html.push(await action.toHtml(ctx, { table: true }));
|
|
2765
|
+
return html;
|
|
2766
|
+
}
|
|
2389
2767
|
async renderTableHeaderActions(ctx) {
|
|
2390
2768
|
const html = [];
|
|
2391
2769
|
for (const action of this.getTable().getHeaderActions())
|
|
@@ -2468,6 +2846,15 @@ export class RelationManager extends ListRecords {
|
|
|
2468
2846
|
static searchAttachable(_ownerKey, _search) { throw new Error('RelationManager.searchAttachable() must be defined.'); }
|
|
2469
2847
|
/** Label of an attachable record, or `null` if it is not attachable (also used as validation). */
|
|
2470
2848
|
static attachableLabel(_ownerKey, _recordKey) { throw new Error('RelationManager.attachableLabel() must be defined.'); }
|
|
2849
|
+
// --- one-to-many: to be defined by managers offering Associate / Dissociate
|
|
2850
|
+
/** Sets the owner of an existing record (its foreign key). */
|
|
2851
|
+
static associate(_ownerKey, _recordKey) { throw new Error('RelationManager.associate() must be defined.'); }
|
|
2852
|
+
/** Clears the owner of records (their foreign key), without deleting them. */
|
|
2853
|
+
static dissociate(_ownerKey, _recordKeys) { throw new Error('RelationManager.dissociate() must be defined.'); }
|
|
2854
|
+
/** Associable records matching the search: [key, label]. */
|
|
2855
|
+
static searchAssociable(_ownerKey, _search) { throw new Error('RelationManager.searchAssociable() must be defined.'); }
|
|
2856
|
+
/** Label of an associable record, or `null` if it is not associable (also used as validation). */
|
|
2857
|
+
static associableLabel(_ownerKey, _recordKey) { throw new Error('RelationManager.associableLabel() must be defined.'); }
|
|
2471
2858
|
getHeaderActions() { return []; }
|
|
2472
2859
|
placeholder() {
|
|
2473
2860
|
return `<div role="status" aria-busy="true" style="height: 8rem; ;" class="fi-section fi-loading-section fi-grid-col"><span class="fi-sr-only">${e(t('filament::components/loading-section.label'))}</span></div>`;
|
|
@@ -2640,11 +3027,23 @@ class ResourceFormPage extends Page {
|
|
|
2640
3027
|
return Action.make('cancel').label(t(`filament-panels::resources/pages/${this.getOperation()}-record.form.actions.cancel.label`)).color('gray')
|
|
2641
3028
|
.alpineClickHandler(`document.referrer ? window.history.back() : Livewire.navigate(${js(this.getCancelUrl())})`);
|
|
2642
3029
|
}
|
|
3030
|
+
// --- lifecycle hooks around filling and validation (to override; none can be called from the browser)
|
|
3031
|
+
/** Before the form is filled (with defaults, or with the record on the edit page). */
|
|
3032
|
+
beforeFill() { }
|
|
3033
|
+
/** After the form is filled. */
|
|
3034
|
+
afterFill() { }
|
|
3035
|
+
/** Before the form is validated, when it is submitted. */
|
|
3036
|
+
beforeValidate() { }
|
|
3037
|
+
/** After the form is validated successfully, before the data is transformed and saved. */
|
|
3038
|
+
afterValidate() { }
|
|
2643
3039
|
/** Validates the form; on error, sets the errors on the component and notifies the browser. */
|
|
2644
3040
|
async validateForm() {
|
|
3041
|
+
await this.beforeValidate();
|
|
2645
3042
|
this._errors = await validateSchema(this.getSchema('form'));
|
|
2646
|
-
if (!Object.keys(this._errors).length)
|
|
3043
|
+
if (!Object.keys(this._errors).length) {
|
|
3044
|
+
await this.afterValidate();
|
|
2647
3045
|
return true;
|
|
3046
|
+
}
|
|
2648
3047
|
this.dispatch('form-validation-error', { livewireId: this.id });
|
|
2649
3048
|
return false;
|
|
2650
3049
|
}
|
|
@@ -2652,7 +3051,7 @@ class ResourceFormPage extends Page {
|
|
|
2652
3051
|
/** Record creation page. */
|
|
2653
3052
|
export class CreateRecord extends ResourceFormPage {
|
|
2654
3053
|
static callable = ['create', 'createAnother'];
|
|
2655
|
-
mount() { fillSchema(this.getSchema('form'), this.data); }
|
|
3054
|
+
async mount() { await this.beforeFill(); fillSchema(this.getSchema('form'), this.data); await this.afterFill(); }
|
|
2656
3055
|
getOperation() { return 'create'; }
|
|
2657
3056
|
getSubmitMethod() { return 'create'; }
|
|
2658
3057
|
getTitle() { return t('filament-panels::resources/pages/create-record.title', { label: this.resource.modelLabel.split(' ').map(ucfirst).join(' ') }); }
|
|
@@ -2686,6 +3085,7 @@ export class CreateRecord extends ResourceFormPage {
|
|
|
2686
3085
|
const data = await this.mutateFormDataBeforeCreate(await getSchemaState(this.getSchema('form')));
|
|
2687
3086
|
await this.beforeCreate(data);
|
|
2688
3087
|
const record = await this.handleRecordCreation(data);
|
|
3088
|
+
await saveSchemaRelationships(this.getSchema('form'), record);
|
|
2689
3089
|
await this.afterCreate(record);
|
|
2690
3090
|
const title = this.getCreatedNotificationTitle();
|
|
2691
3091
|
if (title !== null)
|
|
@@ -2710,34 +3110,62 @@ export class DeleteAction {
|
|
|
2710
3110
|
.hidden(({ livewire, record }) => { const target = record ?? livewire._record; return target ? livewire.getDataAdapter().isTrashed(target) : false; })
|
|
2711
3111
|
.modalHeading(({ livewire, record }) => t('filament-actions::delete.single.modal.heading', { label: livewire.resource.getRecordTitle(record ?? livewire._record) }))
|
|
2712
3112
|
.modalSubmitActionLabel(t('filament-actions::delete.single.modal.actions.delete.label'))
|
|
2713
|
-
.action(async (
|
|
3113
|
+
.action(async (ctx) => {
|
|
3114
|
+
const { livewire, record } = ctx;
|
|
2714
3115
|
const adapter = livewire.getDataAdapter();
|
|
2715
3116
|
const inTable = typeof livewire.getTable === 'function';
|
|
2716
3117
|
await adapter.delete(inTable ? adapter.getKey(record) : livewire.recordKey);
|
|
2717
|
-
|
|
2718
|
-
if (!inTable)
|
|
2719
|
-
livewire.redirect(livewire.resource.getUrl(), true);
|
|
3118
|
+
await finishAction(ctx.action, ctx, t('filament-actions::delete.single.notifications.deleted.title'), inTable ? null : livewire.resource.getUrl());
|
|
2720
3119
|
});
|
|
2721
3120
|
}
|
|
2722
3121
|
}
|
|
3122
|
+
/**
|
|
3123
|
+
* Success outcome of a prebuilt action: its notification (`successNotificationTitle()`, `successNotification()`), then the
|
|
3124
|
+
* redirect (`successRedirectUrl()`, or `fallbackUrl`). Only local or http(s) URLs are followed.
|
|
3125
|
+
*/
|
|
3126
|
+
async function finishAction(action, ctx, defaultTitle, fallbackUrl = null) {
|
|
3127
|
+
let notification = Notification.make().success().title(action.getSuccessNotificationTitle() ?? defaultTitle);
|
|
3128
|
+
const customize = action.getSuccessNotificationCustomizer();
|
|
3129
|
+
if (customize)
|
|
3130
|
+
notification = customize(notification, ctx);
|
|
3131
|
+
notification?.send();
|
|
3132
|
+
const url = (await action.getSuccessRedirectUrl(ctx)) ?? fallbackUrl;
|
|
3133
|
+
if (url && /^(\/(?!\/)|https?:\/\/)/.test(url))
|
|
3134
|
+
ctx.livewire.redirect(url, true);
|
|
3135
|
+
}
|
|
2723
3136
|
/** Duplicates a table row: the copy takes the record's attributes, except its key, timestamps and excluded ones. */
|
|
2724
3137
|
export class ReplicateAction {
|
|
2725
3138
|
static make() {
|
|
2726
3139
|
let excluded = [];
|
|
3140
|
+
let beforeSaved = null, afterSaved = null;
|
|
2727
3141
|
const action = Action.make('replicate').authorize(({ livewire, record }) => canOn(livewire, 'replicate', record))
|
|
2728
3142
|
.label(t('filament-actions::replicate.single.label')).tableIcon('c-square-2-stack').groupedIcon('m-square-2-stack')
|
|
2729
3143
|
.modalHeading(({ livewire, record }) => t('filament-actions::replicate.single.modal.heading', { label: livewire.resource.getRecordTitle(record) }))
|
|
2730
3144
|
.modalSubmitActionLabel(t('filament-actions::replicate.single.modal.actions.replicate.label'))
|
|
2731
|
-
|
|
3145
|
+
// With a modal form (`schema()`), it is filled with the record's attributes; its data overrides them in the copy.
|
|
3146
|
+
.fillForm(({ record }) => {
|
|
3147
|
+
const source = typeof record?.toJSON === 'function' ? record.toJSON() : { ...record };
|
|
3148
|
+
for (const attribute of ['id', 'created_at', 'updated_at', ...excluded])
|
|
3149
|
+
delete source[attribute];
|
|
3150
|
+
return source;
|
|
3151
|
+
})
|
|
3152
|
+
.action(async (ctx) => {
|
|
3153
|
+
const { livewire, record } = ctx;
|
|
2732
3154
|
const adapter = livewire.getDataAdapter();
|
|
2733
3155
|
const source = typeof record?.toJSON === 'function' ? record.toJSON() : { ...record };
|
|
2734
3156
|
// No primary key, no created/updated timestamps.
|
|
2735
3157
|
for (const attribute of ['id', 'created_at', 'updated_at', ...excluded])
|
|
2736
3158
|
delete source[attribute];
|
|
2737
|
-
|
|
2738
|
-
|
|
3159
|
+
const data = ctx.data ?? {};
|
|
3160
|
+
const replica = { ...source, ...data };
|
|
3161
|
+
await beforeSaved?.({ replica, data, record });
|
|
3162
|
+
const saved = await adapter.create(replica);
|
|
3163
|
+
await afterSaved?.({ replica: saved, data, record });
|
|
3164
|
+
await finishAction(ctx.action, { ...ctx, replica: saved }, t('filament-actions::replicate.single.notifications.replicated.title'));
|
|
2739
3165
|
});
|
|
2740
3166
|
action.excludeAttributes = (attributes) => { excluded = attributes; return action; };
|
|
3167
|
+
action.beforeReplicaSaved = (callback) => { beforeSaved = callback; return action; };
|
|
3168
|
+
action.afterReplicaSaved = (callback) => { afterSaved = callback; return action; };
|
|
2741
3169
|
return action;
|
|
2742
3170
|
}
|
|
2743
3171
|
}
|
|
@@ -2752,9 +3180,10 @@ export class CreateAction {
|
|
|
2752
3180
|
.modalSubmitActionLabel(t('filament-actions::create.single.modal.actions.create.label'))
|
|
2753
3181
|
.extraModalFooterActions([Action.make('createAnother').label(t('filament-actions::create.single.modal.actions.create_another.label')).color('gray').action(`callMountedAction(${js({ another: true })})`)])
|
|
2754
3182
|
.schema((schema) => of(schema.livewire).form(schema))
|
|
2755
|
-
.action(async (
|
|
2756
|
-
await of(livewire).adapter.create(data);
|
|
2757
|
-
|
|
3183
|
+
.action(async (ctx) => {
|
|
3184
|
+
const record = await of(ctx.livewire).adapter.create(ctx.data);
|
|
3185
|
+
await saveSchemaRelationships(ctx.schema, record);
|
|
3186
|
+
await finishAction(ctx.action, { ...ctx, record }, t('filament-actions::create.single.notifications.created.title'));
|
|
2758
3187
|
});
|
|
2759
3188
|
}
|
|
2760
3189
|
}
|
|
@@ -2769,10 +3198,11 @@ export class EditAction {
|
|
|
2769
3198
|
.modalSubmitActionLabel(t('filament-actions::edit.single.modal.actions.save.label'))
|
|
2770
3199
|
.schema((schema) => of(schema.livewire).form(schema))
|
|
2771
3200
|
.fillForm(({ record }) => ({ ...record }))
|
|
2772
|
-
.action(async (
|
|
2773
|
-
const adapter = of(livewire).adapter;
|
|
2774
|
-
await adapter.update(adapter.getKey(record), data);
|
|
2775
|
-
|
|
3201
|
+
.action(async (ctx) => {
|
|
3202
|
+
const adapter = of(ctx.livewire).adapter;
|
|
3203
|
+
const saved = await adapter.update(adapter.getKey(ctx.record), ctx.data);
|
|
3204
|
+
await saveSchemaRelationships(ctx.schema, saved ?? ctx.record);
|
|
3205
|
+
await finishAction(ctx.action, ctx, t('filament-actions::edit.single.notifications.saved.title'));
|
|
2776
3206
|
});
|
|
2777
3207
|
}
|
|
2778
3208
|
}
|
|
@@ -2840,6 +3270,56 @@ export class DetachBulkAction {
|
|
|
2840
3270
|
});
|
|
2841
3271
|
}
|
|
2842
3272
|
}
|
|
3273
|
+
/** Gives an existing record to the owner of a one-to-many relation manager (its foreign key). */
|
|
3274
|
+
export class AssociateAction {
|
|
3275
|
+
static make() {
|
|
3276
|
+
return Action.make('associate')
|
|
3277
|
+
.label(t('filament-actions::associate.single.label')).color('gray').modalWidth('lg')
|
|
3278
|
+
.modalHeading(({ livewire }) => t('filament-actions::associate.single.modal.heading', { label: String(livewire.resource.modelLabel).split(' ').map(ucfirst).join(' ') }))
|
|
3279
|
+
.modalSubmitActionLabel(t('filament-actions::associate.single.modal.actions.associate.label'))
|
|
3280
|
+
.extraModalFooterActions([Action.make('associateAnother').label(t('filament-actions::associate.single.modal.actions.associate_another.label')).color('gray').action(`callMountedAction(${js({ another: true })})`)])
|
|
3281
|
+
.schema((schema) => {
|
|
3282
|
+
const livewire = schema.livewire;
|
|
3283
|
+
return [Select.make('recordId').label(t('filament-actions::associate.single.modal.fields.record_id.label')).hiddenLabel().required()
|
|
3284
|
+
.getSearchResultsUsing((search) => managerOf(livewire).searchAssociable(livewire.ownerRecord, search))
|
|
3285
|
+
.getOptionLabelUsing((value) => managerOf(livewire).associableLabel(livewire.ownerRecord, value))];
|
|
3286
|
+
})
|
|
3287
|
+
.action(async ({ livewire, data }) => {
|
|
3288
|
+
await managerOf(livewire).associate(livewire.ownerRecord, String(data.recordId));
|
|
3289
|
+
Notification.make().success().title(t('filament-actions::associate.single.notifications.associated.title')).send();
|
|
3290
|
+
});
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
/** Removes a row from the owner (clears its foreign key) without deleting it. */
|
|
3294
|
+
export class DissociateAction {
|
|
3295
|
+
static make() {
|
|
3296
|
+
return Action.make('dissociate')
|
|
3297
|
+
.label(t('filament-actions::dissociate.single.label')).color('danger').icon('c-x-mark').groupedIcon('m-x-mark')
|
|
3298
|
+
.requiresConfirmation().modalIcon('o-x-mark')
|
|
3299
|
+
.modalHeading(({ livewire, record }) => t('filament-actions::dissociate.single.modal.heading', { label: livewire.resource.getRecordTitle(record) }))
|
|
3300
|
+
.modalSubmitActionLabel(t('filament-actions::dissociate.single.modal.actions.dissociate.label'))
|
|
3301
|
+
.action(async ({ livewire, record }) => {
|
|
3302
|
+
await managerOf(livewire).dissociate(livewire.ownerRecord, [livewire.resource.adapter.getKey(record)]);
|
|
3303
|
+
Notification.make().success().title(t('filament-actions::dissociate.single.notifications.dissociated.title')).send();
|
|
3304
|
+
});
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
/** Dissociates the selected rows. */
|
|
3308
|
+
export class DissociateBulkAction {
|
|
3309
|
+
static make() {
|
|
3310
|
+
return Action.make('dissociate')
|
|
3311
|
+
.label(t('filament-actions::dissociate.multiple.label')).color('danger').icon('m-x-mark')
|
|
3312
|
+
.requiresConfirmation().modalIcon('o-x-mark')
|
|
3313
|
+
.modalHeading(({ livewire }) => t('filament-actions::dissociate.multiple.modal.heading', { label: livewire.resource.getPluralModelLabel() }))
|
|
3314
|
+
.modalSubmitActionLabel(t('filament-actions::dissociate.multiple.modal.actions.dissociate.label'))
|
|
3315
|
+
.action(async ({ livewire }) => {
|
|
3316
|
+
// Selected keys come from the manager's current query, so they belong to the owner.
|
|
3317
|
+
await managerOf(livewire).dissociate(livewire.ownerRecord, await livewire.getSelectedRecordKeys());
|
|
3318
|
+
Notification.make().success().title(t('filament-actions::dissociate.multiple.notifications.dissociated.title')).send();
|
|
3319
|
+
livewire.deselectAllTableRecords();
|
|
3320
|
+
});
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
2843
3323
|
/** Restores a soft-deleted record. Only visible when the record is deleted. */
|
|
2844
3324
|
export class RestoreAction {
|
|
2845
3325
|
static make() {
|
|
@@ -2849,10 +3329,10 @@ export class RestoreAction {
|
|
|
2849
3329
|
.hidden(({ livewire }) => !livewire.getDataAdapter().isTrashed(livewire._record))
|
|
2850
3330
|
.modalHeading(({ livewire }) => t('filament-actions::restore.single.modal.heading', { label: livewire.resource.getRecordTitle(livewire._record) }))
|
|
2851
3331
|
.modalSubmitActionLabel(t('filament-actions::restore.single.modal.actions.restore.label'))
|
|
2852
|
-
.action(async (
|
|
2853
|
-
await livewire.getDataAdapter().restore(livewire.recordKey);
|
|
2854
|
-
await livewire.hydrate();
|
|
2855
|
-
|
|
3332
|
+
.action(async (ctx) => {
|
|
3333
|
+
await ctx.livewire.getDataAdapter().restore(ctx.livewire.recordKey);
|
|
3334
|
+
await ctx.livewire.hydrate();
|
|
3335
|
+
await finishAction(ctx.action, ctx, t('filament-actions::restore.single.notifications.restored.title'));
|
|
2856
3336
|
});
|
|
2857
3337
|
}
|
|
2858
3338
|
}
|
|
@@ -2865,28 +3345,93 @@ export class ForceDeleteAction {
|
|
|
2865
3345
|
.hidden(({ livewire }) => !livewire.getDataAdapter().isTrashed(livewire._record))
|
|
2866
3346
|
.modalHeading(({ livewire }) => t('filament-actions::force-delete.single.modal.heading', { label: livewire.resource.getRecordTitle(livewire._record) }))
|
|
2867
3347
|
.modalSubmitActionLabel(t('filament-actions::force-delete.single.modal.actions.delete.label'))
|
|
2868
|
-
.action(async (
|
|
2869
|
-
await livewire.getDataAdapter().forceDelete(livewire.recordKey);
|
|
2870
|
-
|
|
2871
|
-
livewire.redirect(livewire.resource.getUrl(), true);
|
|
3348
|
+
.action(async (ctx) => {
|
|
3349
|
+
await ctx.livewire.getDataAdapter().forceDelete(ctx.livewire.recordKey);
|
|
3350
|
+
await finishAction(ctx.action, ctx, t('filament-actions::force-delete.single.notifications.deleted.title'), ctx.livewire.resource.getUrl());
|
|
2872
3351
|
});
|
|
2873
3352
|
}
|
|
2874
3353
|
}
|
|
2875
|
-
/**
|
|
3354
|
+
/** Actions of a list that may contain action groups: the groups' actions are mounted, never the groups. */
|
|
3355
|
+
const flattenActions = (actions) => actions.flatMap((action) => (action instanceof ActionGroup ? action.getActions() : [action]));
|
|
3356
|
+
/** Value of the table's trashed filter: `'1'` with deleted records, `'0'` only deleted records, `null` without. */
|
|
3357
|
+
const trashedFilterValue = (livewire) => {
|
|
3358
|
+
const filter = livewire.getTable?.().getFilters().find((candidate) => candidate instanceof TrashedFilter);
|
|
3359
|
+
const value = filter ? livewire.tableFilters?.[filter.name]?.value : null;
|
|
3360
|
+
return value === '1' || value === '0' ? value : null;
|
|
3361
|
+
};
|
|
3362
|
+
/**
|
|
3363
|
+
* Deletes the records selected in a table. Without `authorizeIndividualRecords()`, the selection is deleted in one query;
|
|
3364
|
+
* with it, record by record, and the notification reports the records that could not be deleted.
|
|
3365
|
+
*/
|
|
2876
3366
|
export class DeleteBulkAction {
|
|
2877
3367
|
static make() {
|
|
2878
3368
|
return Action.make('delete').authorize(({ livewire, record }) => canOn(livewire, 'deleteAny', record))
|
|
2879
3369
|
.label(t('filament-actions::delete.multiple.label')).color('danger').icon('m-trash')
|
|
2880
3370
|
.requiresConfirmation().modalIcon('o-trash')
|
|
3371
|
+
// Showing only deleted records: there is nothing left to delete.
|
|
3372
|
+
.hidden(({ livewire }) => trashedFilterValue(livewire) === '0')
|
|
2881
3373
|
.modalHeading(({ livewire }) => t('filament-actions::delete.multiple.modal.heading', { label: livewire.resource.getTitleCasePluralModelLabel() }))
|
|
2882
3374
|
.modalSubmitActionLabel(t('filament-actions::delete.multiple.modal.actions.delete.label'))
|
|
2883
|
-
.
|
|
2884
|
-
|
|
2885
|
-
|
|
3375
|
+
.successNotificationTitle(t('filament-actions::delete.multiple.notifications.deleted.title'))
|
|
3376
|
+
.failureNotificationTitle((success, total) => (success ? tc('filament-actions::delete.multiple.notifications.deleted_partial.title', success, { count: success, total }) : tc('filament-actions::delete.multiple.notifications.deleted_none.title', total, { count: total, total })))
|
|
3377
|
+
.missingBulkAuthorizationFailureNotificationMessage((count, total) => tc(`filament-actions::delete.multiple.notifications.${count === total ? 'deleted_none' : 'deleted_partial'}.missing_authorization_failure_message`, count, { count }))
|
|
3378
|
+
.missingBulkProcessingFailureNotificationMessage((count, total) => tc(`filament-actions::delete.multiple.notifications.${count === total ? 'deleted_none' : 'deleted_partial'}.missing_processing_failure_message`, count, { count }))
|
|
3379
|
+
.deselectRecordsAfterCompletion()
|
|
3380
|
+
.action(async ({ livewire, action }) => {
|
|
3381
|
+
const adapter = livewire.getDataAdapterForTable();
|
|
3382
|
+
if (action.getIndividualRecordsAuthorization() !== null) {
|
|
3383
|
+
await livewire.processSelectedRecords(action, (record) => adapter.delete(adapter.getKey(record)));
|
|
3384
|
+
return;
|
|
3385
|
+
}
|
|
3386
|
+
await adapter.deleteMany(await livewire.getSelectedRecordKeys());
|
|
3387
|
+
Notification.make().success().title(action.getSuccessNotificationTitle() ?? t('filament-actions::delete.multiple.notifications.deleted.title')).send();
|
|
2886
3388
|
livewire.deselectAllTableRecords();
|
|
2887
3389
|
});
|
|
2888
3390
|
}
|
|
2889
3391
|
}
|
|
3392
|
+
/** Restores the soft-deleted records selected in a table, each one checked with the `restore` ability. */
|
|
3393
|
+
export class RestoreBulkAction {
|
|
3394
|
+
static make() {
|
|
3395
|
+
return Action.make('restore').authorize(({ livewire, record }) => canOn(livewire, 'restoreAny', record))
|
|
3396
|
+
.label(t('filament-actions::restore.multiple.label')).color('gray').icon('m-arrow-uturn-left')
|
|
3397
|
+
.requiresConfirmation().modalIcon('o-arrow-uturn-left')
|
|
3398
|
+
.hidden(({ livewire }) => trashedFilterValue(livewire) === null)
|
|
3399
|
+
.modalHeading(({ livewire }) => t('filament-actions::restore.multiple.modal.heading', { label: livewire.resource.getTitleCasePluralModelLabel() }))
|
|
3400
|
+
.modalSubmitActionLabel(t('filament-actions::restore.multiple.modal.actions.restore.label'))
|
|
3401
|
+
.authorizeIndividualRecords('restore')
|
|
3402
|
+
.successNotificationTitle(t('filament-actions::restore.multiple.notifications.restored.title'))
|
|
3403
|
+
.failureNotificationTitle((success, total) => (success ? tc('filament-actions::restore.multiple.notifications.restored_partial.title', success, { count: success, total }) : tc('filament-actions::restore.multiple.notifications.restored_none.title', total, { count: total, total })))
|
|
3404
|
+
.missingBulkAuthorizationFailureNotificationMessage((count, total) => tc(`filament-actions::restore.multiple.notifications.${count === total ? 'restored_none' : 'restored_partial'}.missing_authorization_failure_message`, count, { count }))
|
|
3405
|
+
.missingBulkProcessingFailureNotificationMessage((count, total) => tc(`filament-actions::restore.multiple.notifications.${count === total ? 'restored_none' : 'restored_partial'}.missing_processing_failure_message`, count, { count }))
|
|
3406
|
+
.deselectRecordsAfterCompletion()
|
|
3407
|
+
.action(async ({ livewire, action }) => {
|
|
3408
|
+
const adapter = livewire.getDataAdapterForTable();
|
|
3409
|
+
// Only deleted records are restored; the others count as failures.
|
|
3410
|
+
await livewire.processSelectedRecords(action, (record) => adapter.isTrashed(record) && adapter.restore(adapter.getKey(record)));
|
|
3411
|
+
});
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
/** Permanently deletes the soft-deleted records selected in a table, each one checked with the `forceDelete` ability. */
|
|
3415
|
+
export class ForceDeleteBulkAction {
|
|
3416
|
+
static make() {
|
|
3417
|
+
return Action.make('forceDelete').authorize(({ livewire, record }) => canOn(livewire, 'forceDeleteAny', record))
|
|
3418
|
+
.label(t('filament-actions::force-delete.multiple.label')).color('danger').icon('m-trash')
|
|
3419
|
+
.requiresConfirmation().modalIcon('o-trash')
|
|
3420
|
+
.hidden(({ livewire }) => trashedFilterValue(livewire) === null)
|
|
3421
|
+
.modalHeading(({ livewire }) => t('filament-actions::force-delete.multiple.modal.heading', { label: livewire.resource.getTitleCasePluralModelLabel() }))
|
|
3422
|
+
.modalSubmitActionLabel(t('filament-actions::force-delete.multiple.modal.actions.delete.label'))
|
|
3423
|
+
.authorizeIndividualRecords('forceDelete')
|
|
3424
|
+
.successNotificationTitle(t('filament-actions::force-delete.multiple.notifications.deleted.title'))
|
|
3425
|
+
.failureNotificationTitle((success, total) => (success ? tc('filament-actions::force-delete.multiple.notifications.deleted_partial.title', success, { count: success, total }) : tc('filament-actions::force-delete.multiple.notifications.deleted_none.title', total, { count: total, total })))
|
|
3426
|
+
.missingBulkAuthorizationFailureNotificationMessage((count, total) => tc(`filament-actions::force-delete.multiple.notifications.${count === total ? 'deleted_none' : 'deleted_partial'}.missing_authorization_failure_message`, count, { count }))
|
|
3427
|
+
.missingBulkProcessingFailureNotificationMessage((count, total) => tc(`filament-actions::force-delete.multiple.notifications.${count === total ? 'deleted_none' : 'deleted_partial'}.missing_processing_failure_message`, count, { count }))
|
|
3428
|
+
.deselectRecordsAfterCompletion()
|
|
3429
|
+
.action(async ({ livewire, action }) => {
|
|
3430
|
+
const adapter = livewire.getDataAdapterForTable();
|
|
3431
|
+
await livewire.processSelectedRecords(action, (record) => adapter.forceDelete(adapter.getKey(record)));
|
|
3432
|
+
});
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
2890
3435
|
/** Record edit page. */
|
|
2891
3436
|
export class EditRecord extends ResourceFormPage {
|
|
2892
3437
|
static callable = ['save'];
|
|
@@ -2910,7 +3455,10 @@ export class EditRecord extends ResourceFormPage {
|
|
|
2910
3455
|
this.recordKey = String(params.record ?? '');
|
|
2911
3456
|
this.previousUrl = previousUrlOf(this._ctx?.req);
|
|
2912
3457
|
await this.hydrate();
|
|
3458
|
+
await this.beforeFill();
|
|
2913
3459
|
fillSchemaFromRecord(this.getSchema('form'), this.data, await this.mutateFormDataBeforeFill({ ...this._record }));
|
|
3460
|
+
await loadSchemaRelationships(this.getSchema('form'), this.data, this._record);
|
|
3461
|
+
await this.afterFill();
|
|
2914
3462
|
}
|
|
2915
3463
|
/** The record is reloaded on every request (no route model binding). */
|
|
2916
3464
|
async hydrate() {
|
|
@@ -2967,6 +3515,7 @@ export class EditRecord extends ResourceFormPage {
|
|
|
2967
3515
|
const data = await this.mutateFormDataBeforeSave(await getSchemaState(this.getSchema('form')));
|
|
2968
3516
|
await this.beforeSave(data);
|
|
2969
3517
|
this._record = await this.handleRecordUpdate(this._record, data);
|
|
3518
|
+
await saveSchemaRelationships(this.getSchema('form'), this._record);
|
|
2970
3519
|
await this.afterSave(this._record);
|
|
2971
3520
|
const title = this.getSavedNotificationTitle();
|
|
2972
3521
|
if (title !== null)
|
|
@@ -3047,6 +3596,7 @@ async function renderUserMenu(component, position) {
|
|
|
3047
3596
|
profileUrl: profileUrl && !custom.profile ? profileUrl : null,
|
|
3048
3597
|
itemsBefore: await html(visible.filter((item) => item.getSort() < 0)), itemsAfter: await html(visible.filter((item) => item.getSort() >= 0)),
|
|
3049
3598
|
themes: panel.getThemes().map((theme) => ({ id: theme.id, label: theme.label, swatch: swatchOf(theme, panel) })), activeTheme: panel.resolveTheme(req)?.id ?? null,
|
|
3599
|
+
hasThemeSwitcher: panel.hasDarkMode() && !panel.hasDarkModeForced(), defaultThemeMode: panel.getDefaultThemeMode(),
|
|
3050
3600
|
});
|
|
3051
3601
|
}
|
|
3052
3602
|
/** Tenant menu: current tenant, other tenants, registration, profile. */
|
|
@@ -3210,7 +3760,24 @@ const runtimePanels = new WeakMap();
|
|
|
3210
3760
|
/** Reserved segments right after a multi-tenant panel's path (they are not tenant keys). */
|
|
3211
3761
|
const RESERVED_SEGMENTS = new Set(['login', 'logout', 'register', 'password-reset', 'email-verification', 'profile', 'livewire']);
|
|
3212
3762
|
/** Express router for a panel: assets, runtime endpoint, authentication, tenants, protected pages. */
|
|
3763
|
+
/** Panels sharing an auth guard: same key and same auth provider, otherwise a user id of one could be read as a user of the other. */
|
|
3764
|
+
const guardOwners = new Map();
|
|
3765
|
+
function checkSharedGuard(panel) {
|
|
3766
|
+
const guard = panel.getAuthGuard();
|
|
3767
|
+
if (guard === null)
|
|
3768
|
+
return;
|
|
3769
|
+
const other = guardOwners.get(guard);
|
|
3770
|
+
if (!other || other === panel) {
|
|
3771
|
+
guardOwners.set(guard, panel);
|
|
3772
|
+
return;
|
|
3773
|
+
}
|
|
3774
|
+
if (other._key !== panel._key)
|
|
3775
|
+
throw new Error(`Panels "${other.getId()}" and "${panel.getId()}" share the auth guard "${guard}" but not the same key: give them the same key, or a different authGuard().`);
|
|
3776
|
+
if (other.getAuth() !== panel.getAuth())
|
|
3777
|
+
throw new Error(`Panels "${other.getId()}" and "${panel.getId()}" share the auth guard "${guard}" but not the same auth provider: share the provider, or give one of them authGuard(null) or another guard.`);
|
|
3778
|
+
}
|
|
3213
3779
|
export function panelRouter(panel, runtime) {
|
|
3780
|
+
checkSharedGuard(panel);
|
|
3214
3781
|
const owner = runtimePanels.get(runtime);
|
|
3215
3782
|
if (owner && owner !== panel)
|
|
3216
3783
|
throw new Error(`This WireRuntime already serves panel "${owner.getId()}": create one WireRuntime per panel.`);
|
|
@@ -3245,7 +3812,13 @@ export function panelRouter(panel, runtime) {
|
|
|
3245
3812
|
const name = (suffix) => `navette.${panel.getId()}.${suffix}`;
|
|
3246
3813
|
const livewire = panel.getLivewirePrefix();
|
|
3247
3814
|
// The notifications mailbox is per browser: it is keyed by the CSRF token.
|
|
3248
|
-
const context = async (req, res) =>
|
|
3815
|
+
const context = async (req, res) => {
|
|
3816
|
+
const user = await panel.user(req);
|
|
3817
|
+
// Signed in through a shared session, but not allowed into this panel: treated as a guest that gets 403 pages.
|
|
3818
|
+
const denied = user !== null && !(await panel.userCanAccess(user));
|
|
3819
|
+
return { req, res, panel, user: denied ? null : user, denied, mailbox: csrfToken(req, res), tenant: null };
|
|
3820
|
+
};
|
|
3821
|
+
const forbidden = (res) => void res.status(403).send('Forbidden');
|
|
3249
3822
|
/** Tenant designated by a panel URL, if the user is a member. */
|
|
3250
3823
|
const tenantFromPath = async (path, user) => {
|
|
3251
3824
|
if (!panel.hasTenancy() || !user)
|
|
@@ -3262,7 +3835,7 @@ export function panelRouter(panel, runtime) {
|
|
|
3262
3835
|
return null;
|
|
3263
3836
|
}
|
|
3264
3837
|
const registration = panel.getTenantRegistrationPage();
|
|
3265
|
-
if (!key ||
|
|
3838
|
+
if (!key || panel.getReservedSegments().has(key) || key === registration?.slug || key.length > 200)
|
|
3266
3839
|
return null;
|
|
3267
3840
|
const provider = panel.getTenantProvider();
|
|
3268
3841
|
const tenant = await provider.find(key);
|
|
@@ -3376,7 +3949,7 @@ export function panelRouter(panel, runtime) {
|
|
|
3376
3949
|
const bodyClass = options.layout === 'index' ? cls([{ 'fi-body-has-navigation': hasNavigation, 'fi-body-has-sidebar-collapsible-on-desktop': panel.isSidebarCollapsibleOnDesktop(), 'fi-body-has-sidebar-fully-collapsible-on-desktop': panel.isSidebarFullyCollapsibleOnDesktop(), 'fi-body-has-topbar': panel.hasTopbar(), 'fi-body-has-top-navigation': panel.hasTopNavigation() }]) || null : null;
|
|
3377
3950
|
res.send(views.render('filament-panels::layout.base', {
|
|
3378
3951
|
renderHookScopes, title: options.title, brandName: panel.getBrandName(), favicon: panel.getFavicon(), theme: panel.getTheme(), motion: panel.hasMotion(), panelId: panel.getId(), font: panel.resolveTheme(req)?.font ? { ...panel.getFont(), ...fontOf(panel.resolveTheme(req).font) } : panel.getFont(), themeCss: panel.resolveTheme(req)?.css ?? '',
|
|
3379
|
-
bodyClass, body, spa: panel.hasSpaMode(),
|
|
3952
|
+
bodyClass, body, spa: panel.hasSpaMode(), darkMode: panel.hasDarkMode(), darkModeForced: panel.hasDarkModeForced(), defaultThemeMode: panel.getDefaultThemeMode(),
|
|
3380
3953
|
csrf: csrfToken(req, res), colorVariables: panel.colors.renderCssVariables(), wireStyles: wireStyles(), wireScripts: wireScripts(req, res, livewire),
|
|
3381
3954
|
notifications: (await runtime.mount('navette.notifications', {}, request)).replace(/^<div /, '<div wire:key="lw-1-0-0" '),
|
|
3382
3955
|
}));
|
|
@@ -3398,6 +3971,8 @@ export function panelRouter(panel, runtime) {
|
|
|
3398
3971
|
const guestPage = (path, component, title, params = () => ({})) => {
|
|
3399
3972
|
router.get(panel.getBaseUrl(path), async (req, res) => {
|
|
3400
3973
|
const ctx = await context(req, res);
|
|
3974
|
+
if (ctx.denied)
|
|
3975
|
+
return forbidden(res);
|
|
3401
3976
|
if (ctx.user)
|
|
3402
3977
|
return res.redirect((await homeUrl(ctx.user)) ?? panel.getBaseUrl());
|
|
3403
3978
|
await guarded(res, () => document(req, res, ctx, { title, component, layout: 'simple', params: params(req) }));
|
|
@@ -3407,13 +3982,29 @@ export function panelRouter(panel, runtime) {
|
|
|
3407
3982
|
const userPage = (path, component, title, layout = 'simple') => {
|
|
3408
3983
|
router.get(panel.getBaseUrl(path), async (req, res) => {
|
|
3409
3984
|
const ctx = await context(req, res);
|
|
3985
|
+
if (ctx.denied)
|
|
3986
|
+
return forbidden(res);
|
|
3410
3987
|
if (!ctx.user)
|
|
3411
3988
|
return res.redirect(panel.getLoginUrl());
|
|
3412
3989
|
await guarded(res, () => document(req, res, ctx, { title, component, layout }));
|
|
3413
3990
|
});
|
|
3414
3991
|
};
|
|
3992
|
+
// Files offered to the signed-in user (exports, failed import rows): only the user who created them can download them.
|
|
3993
|
+
router.get(panel.getBaseUrl('downloads/:token'), async (req, res) => {
|
|
3994
|
+
const ctx = await context(req, res);
|
|
3995
|
+
if (!ctx.user || ctx.denied)
|
|
3996
|
+
return forbidden(res);
|
|
3997
|
+
const file = panel.getDownloads().get(String(req.params.token), ctx.user.id);
|
|
3998
|
+
if (!file)
|
|
3999
|
+
return void res.status(404).send('Not Found');
|
|
4000
|
+
res.setHeader('Content-Type', file.type);
|
|
4001
|
+
res.setHeader('Content-Disposition', `attachment; filename="${file.name.replace(/[^A-Za-z0-9._-]/g, '_')}"`);
|
|
4002
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
4003
|
+
res.setHeader('Cache-Control', 'private, no-store');
|
|
4004
|
+
res.send(file.content);
|
|
4005
|
+
});
|
|
3415
4006
|
if (LoginPage) {
|
|
3416
|
-
guestPage(
|
|
4007
|
+
guestPage(panel.getAuthPaths().login, name('auth.login'), t('filament-panels::auth/pages/login.title'));
|
|
3417
4008
|
router.post(panel.getLogoutUrl(), express.urlencoded({ extended: false }), (req, res) => {
|
|
3418
4009
|
if (!verifyCsrf(req, req.body?._token))
|
|
3419
4010
|
return res.status(419).send('Invalid CSRF token.');
|
|
@@ -3422,16 +4013,18 @@ export function panelRouter(panel, runtime) {
|
|
|
3422
4013
|
});
|
|
3423
4014
|
}
|
|
3424
4015
|
if (RegistrationPage)
|
|
3425
|
-
guestPage(
|
|
4016
|
+
guestPage(panel.getAuthPaths().registration, name('auth.register'), t('filament-panels::auth/pages/register.title'));
|
|
3426
4017
|
if (passwordReset) {
|
|
3427
|
-
guestPage(
|
|
3428
|
-
guestPage(
|
|
4018
|
+
guestPage(panel.getAuthPaths().passwordResetRequest, name('auth.password-reset.request'), t('filament-panels::auth/pages/password-reset/request-password-reset.title'));
|
|
4019
|
+
guestPage(panel.getAuthPaths().passwordReset, name('auth.password-reset.reset'), t('filament-panels::auth/pages/password-reset/reset-password.title'), (req) => ({ email: typeof req.query.email === 'string' ? req.query.email : null, token: typeof req.query.token === 'string' ? req.query.token : null }));
|
|
3429
4020
|
}
|
|
3430
4021
|
if (VerificationPrompt) {
|
|
3431
4022
|
userPage('email-verification/prompt', name('auth.email-verification.prompt'), t('filament-panels::auth/pages/email-verification/email-verification-prompt.title'));
|
|
3432
4023
|
// Emailed link: signed, time-limited, and only valid for the signed-in user it designates.
|
|
3433
4024
|
router.get(panel.getBaseUrl('email-verification/verify/:id/:hash'), async (req, res) => {
|
|
3434
4025
|
const ctx = await context(req, res);
|
|
4026
|
+
if (ctx.denied)
|
|
4027
|
+
return forbidden(res);
|
|
3435
4028
|
if (!ctx.user)
|
|
3436
4029
|
return res.redirect(panel.getLoginUrl());
|
|
3437
4030
|
const id = String(req.params.id), hash = String(req.params.hash);
|
|
@@ -3449,6 +4042,10 @@ export function panelRouter(panel, runtime) {
|
|
|
3449
4042
|
/** Context of a protected page: login, email verification, tenant. Returns `null` after responding. */
|
|
3450
4043
|
const pageContext = async (req, res) => {
|
|
3451
4044
|
const ctx = await context(req, res);
|
|
4045
|
+
if (ctx.denied) {
|
|
4046
|
+
forbidden(res);
|
|
4047
|
+
return null;
|
|
4048
|
+
}
|
|
3452
4049
|
if (!ctx.user) {
|
|
3453
4050
|
res.redirect(panel.getLoginUrl());
|
|
3454
4051
|
return null;
|
|
@@ -3471,6 +4068,8 @@ export function panelRouter(panel, runtime) {
|
|
|
3471
4068
|
// Panel root: the user's first tenant, otherwise tenant registration.
|
|
3472
4069
|
router.get(panel.getBaseUrl(), async (req, res) => {
|
|
3473
4070
|
const ctx = await context(req, res);
|
|
4071
|
+
if (ctx.denied)
|
|
4072
|
+
return forbidden(res);
|
|
3474
4073
|
if (!ctx.user)
|
|
3475
4074
|
return res.redirect(panel.getLoginUrl());
|
|
3476
4075
|
if (await panel.mustVerifyEmail(ctx.user))
|
|
@@ -3483,7 +4082,7 @@ export function panelRouter(panel, runtime) {
|
|
|
3483
4082
|
// Tenant root without a dashboard: the first menu page.
|
|
3484
4083
|
if (!panel.getPages().some((page) => page.slug === '')) {
|
|
3485
4084
|
router.get(panel.getBaseUrl(':tenant'), async (req, res, next) => {
|
|
3486
|
-
if (
|
|
4085
|
+
if (panel.getReservedSegments().has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug)
|
|
3487
4086
|
return next();
|
|
3488
4087
|
const ctx = await pageContext(req, res);
|
|
3489
4088
|
if (!ctx)
|
|
@@ -3500,7 +4099,7 @@ export function panelRouter(panel, runtime) {
|
|
|
3500
4099
|
runtime.register(component, PageClass);
|
|
3501
4100
|
// `{record}` in the slug becomes a route parameter, passed to mount().
|
|
3502
4101
|
router.get(panel.getBaseUrl(tenantSegment + PageClass.slug).replace(/\{(\w+)\}/g, ':$1'), async (req, res, next) => {
|
|
3503
|
-
if (panel.hasTenancy() && (
|
|
4102
|
+
if (panel.hasTenancy() && (panel.getReservedSegments().has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug))
|
|
3504
4103
|
return next();
|
|
3505
4104
|
const ctx = await pageContext(req, res);
|
|
3506
4105
|
if (!ctx)
|