@navette-js/panels 1.0.0-beta.1 → 1.0.0-beta.3
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/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +2 -2
- package/lib/auth.d.ts +16 -16
- package/lib/auth.js +19 -21
- package/lib/calendar.d.ts +18 -18
- package/lib/calendar.js +10 -10
- package/lib/index.d.ts +333 -328
- package/lib/index.js +509 -464
- package/lib/notifications.d.ts +9 -9
- package/lib/notifications.js +12 -12
- package/lib/topbar.d.ts +11 -11
- package/lib/topbar.js +15 -16
- package/package.json +10 -11
- package/theme.css +3 -3
- package/views/components/logo.ejs +1 -1
- package/views/components/tenant-menu.ejs +1 -1
- package/views/components/user-menu.ejs +2 -2
- package/views/layout/base.ejs +2 -2
- package/views/layout/index.ejs +1 -1
- package/views/layout/simple.ejs +1 -1
- package/views/livewire/sidebar.ejs +2 -2
- package/views/livewire/topbar.ejs +1 -1
- package/views/pages/page.ejs +3 -3
- package/views/pages/simple.ejs +1 -1
package/lib/index.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
// @navette-js/panels: Panel, layouts, pages, resources, navigation, authentication and session.
|
|
2
10
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
11
|
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
4
12
|
import { checkVerificationUrl, consoleMailer, readToken, signToken, signVerificationUrl } from "./auth.js";
|
|
5
13
|
export { consoleMailer, fileMailer, hashPassword, knexAuthProvider, knexTenantProvider, mailText, verifyPassword } from "./auth.js";
|
|
6
|
-
import { readFileSync } from 'node:fs';
|
|
7
|
-
import {
|
|
14
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
17
|
import express from 'express';
|
|
9
18
|
import { Action, useColorManager, ActionGroup } from '@navette-js/actions';
|
|
10
19
|
import { Checkbox, Field, Select, TextInput, Wizard, richDocumentToHtml, richHtmlToDocument, fillSchema, fillSchemaFromRecord, getSchemaState, validateSchema, useColorManager as useFormsColorManager } from '@navette-js/forms';
|
|
@@ -28,7 +37,6 @@ views.namespace('filament-panels', dir('../views'));
|
|
|
28
37
|
views.namespace('filament', fileURLToPath(new URL('../views', import.meta.resolve('@navette-js/support'))));
|
|
29
38
|
views.share({ t, tc });
|
|
30
39
|
export const ASSET_VERSION = '5.11.2.0';
|
|
31
|
-
// ------------------------------------------------------------------ navigation
|
|
32
40
|
export class NavigationItem {
|
|
33
41
|
label;
|
|
34
42
|
_url = '#';
|
|
@@ -45,12 +53,11 @@ export class NavigationItem {
|
|
|
45
53
|
group(group) { this._group = group; return this; }
|
|
46
54
|
sort(sort) { this._sort = sort; return this; }
|
|
47
55
|
badge(badge, color = 'primary') { this._badge = badge; this._badgeColor = color; return this; }
|
|
48
|
-
/**
|
|
56
|
+
/** By default an item is active on its exact URL; this overrides the rule. */
|
|
49
57
|
isActiveWhen(fn) { this._isActive = fn; return this; }
|
|
50
58
|
isActive(path) { return this._isActive ? this._isActive(path) : path === this._url; }
|
|
51
59
|
}
|
|
52
60
|
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
53
|
-
// ------------------------------------------------------------------ panel
|
|
54
61
|
export class Panel {
|
|
55
62
|
_id = 'admin';
|
|
56
63
|
_path = '';
|
|
@@ -79,31 +86,31 @@ export class Panel {
|
|
|
79
86
|
brandLogo(url) { this._brandLogo = url; return this; }
|
|
80
87
|
darkModeBrandLogo(url) { this._darkModeBrandLogo = url; return this; }
|
|
81
88
|
brandLogoHeight(height) { this._brandLogoHeight = height; return this; }
|
|
82
|
-
/**
|
|
89
|
+
/** Browser tab icon. */
|
|
83
90
|
favicon(url) { this._favicon = url; return this; }
|
|
84
91
|
getFavicon() { return this._favicon; }
|
|
85
92
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
93
|
+
* Theme stylesheet compiled by the application (Tailwind); it replaces the default stylesheet.
|
|
94
|
+
* The theme imports `@navette-js/panels/theme.css` and declares its own sources.
|
|
88
95
|
*/
|
|
89
96
|
theme(url) { this._theme = url; return this; }
|
|
90
97
|
getTheme() { return this._theme; }
|
|
91
98
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
99
|
+
* Navette's motion and loading indicators (springs, activity dot, spinner for deferred areas).
|
|
100
|
+
* `motion(false)` renders the plain interface without them.
|
|
94
101
|
*/
|
|
95
102
|
motion(enabled = true) { this._motion = enabled; return this; }
|
|
96
103
|
hasMotion() { return this._motion; }
|
|
97
|
-
/**
|
|
104
|
+
/** Themes offered to the user; the first one is the default. */
|
|
98
105
|
themes(themes) {
|
|
99
106
|
for (const theme of themes)
|
|
100
107
|
if (!/^[a-z0-9-]{1,40}$/.test(theme.id))
|
|
101
|
-
throw new Error(`
|
|
108
|
+
throw new Error(`Invalid theme id: ${theme.id}`);
|
|
102
109
|
this._themes = themes;
|
|
103
110
|
return this;
|
|
104
111
|
}
|
|
105
112
|
getThemes() { return this._themes; }
|
|
106
|
-
/**
|
|
113
|
+
/** Theme for the request: the cookie's theme if declared, otherwise the first one. `null` if the panel has no themes. */
|
|
107
114
|
resolveTheme(req) {
|
|
108
115
|
if (!this._themes.length)
|
|
109
116
|
return null;
|
|
@@ -111,17 +118,17 @@ export class Panel {
|
|
|
111
118
|
return this._themes.find((theme) => theme.id === id) ?? this._themes[0];
|
|
112
119
|
}
|
|
113
120
|
rememberTheme(res, theme) { res.cookie(`navette_${this._id}_theme`, theme.id, { sameSite: 'lax', secure: res.req?.secure === true, maxAge: 1000 * 60 * 60 * 24 * 365 }); }
|
|
114
|
-
/**
|
|
121
|
+
/** Runs a render with the colors of the request's theme. */
|
|
115
122
|
withTheme(req, fn) {
|
|
116
123
|
const theme = this.resolveTheme(req);
|
|
117
|
-
//
|
|
118
|
-
//
|
|
124
|
+
// Components from other packages read a color manager shared by all panels:
|
|
125
|
+
// this panel's (and its theme's) colors only apply for the duration of the request.
|
|
119
126
|
const withPanelColors = () => sharedColors.runWith(`${this._id}:${theme?.id ?? ''}`, this.colors.getColors(), fn);
|
|
120
|
-
//
|
|
127
|
+
// A panel's render hooks only apply to its own requests.
|
|
121
128
|
return runWithRenderHooks(this._renderHooks, () => (theme?.colors ? this.colors.runWith(theme.id, theme.colors, withPanelColors) : withPanelColors()));
|
|
122
129
|
}
|
|
123
130
|
setColors(colors) { this.colors.register(colors); return this; }
|
|
124
|
-
/**
|
|
131
|
+
/** Panel font. Without a URL, it is loaded from Bunny Fonts. */
|
|
125
132
|
font(family, url) {
|
|
126
133
|
this._font = { family, url: url ?? `https://fonts.bunny.net/css?family=${slug(family)}:400,500,600,700&display=swap`, preconnect: url ? null : 'https://fonts.bunny.net' };
|
|
127
134
|
return this;
|
|
@@ -130,7 +137,7 @@ export class Panel {
|
|
|
130
137
|
auth(provider) { this._auth = provider; return this; }
|
|
131
138
|
key(key) { this._key = key; return this; }
|
|
132
139
|
pages(pages) { this._pages.push(...pages); return this; }
|
|
133
|
-
/**
|
|
140
|
+
/** Registers resources: their pages and navigation items. */
|
|
134
141
|
resources(resources) {
|
|
135
142
|
for (const resource of resources) {
|
|
136
143
|
resource.panel = this;
|
|
@@ -140,13 +147,13 @@ export class Panel {
|
|
|
140
147
|
return this;
|
|
141
148
|
}
|
|
142
149
|
navigationItems(items) { this._navigationItems.push(...items); return this; }
|
|
143
|
-
/**
|
|
150
|
+
/** Order of navigation groups. */
|
|
144
151
|
navigationGroups(groups) { this._navigationGroups = groups; return this; }
|
|
145
152
|
_plugins = [];
|
|
146
|
-
/**
|
|
153
|
+
/** Applies a plugin: an object that configures the panel (resources, pages, render hooks, themes…). */
|
|
147
154
|
plugin(plugin) {
|
|
148
155
|
if (this._plugins.some((p) => p.id === plugin.id))
|
|
149
|
-
throw new Error(`Plugin
|
|
156
|
+
throw new Error(`Plugin already registered: ${plugin.id}`);
|
|
150
157
|
this._plugins.push(plugin);
|
|
151
158
|
plugin.register(this);
|
|
152
159
|
return this;
|
|
@@ -156,15 +163,15 @@ export class Panel {
|
|
|
156
163
|
getPlugins() { return this._plugins; }
|
|
157
164
|
_renderHooks = new RenderHookRegistry();
|
|
158
165
|
/**
|
|
159
|
-
*
|
|
160
|
-
* `scopes`
|
|
166
|
+
* Inserts HTML at a named location of this panel's UI (names: `PanelsRenderHook`, `TablesRenderHook`, `ActionsRenderHook`, `WidgetsRenderHook`).
|
|
167
|
+
* `scopes` limits the hook to some pages or resources (their class). The HTML is inserted as-is: escape any data with `e()`.
|
|
161
168
|
*/
|
|
162
169
|
renderHook(name, render, scopes = null) { this._renderHooks.register(name, render, scopes); return this; }
|
|
163
170
|
getRenderHooks() { return this._renderHooks; }
|
|
164
171
|
_authorizer = null;
|
|
165
|
-
/**
|
|
172
|
+
/** Plugs in your own permission system: consulted before any policy, for every ability, page and widget. */
|
|
166
173
|
authorizeUsing(authorizer) { this._authorizer = authorizer; return this; }
|
|
167
|
-
/**
|
|
174
|
+
/** Authorization decision: the panel hook first, then the given rule. Always `false` without a user. */
|
|
168
175
|
async authorize(user, ability, subject = {}) {
|
|
169
176
|
if (!user)
|
|
170
177
|
return false;
|
|
@@ -173,7 +180,7 @@ export class Panel {
|
|
|
173
180
|
return decided;
|
|
174
181
|
return subject.rule ? Boolean(await subject.rule()) : true;
|
|
175
182
|
}
|
|
176
|
-
/**
|
|
183
|
+
/** Whether the user can open a page. List: `viewAny`; create: `create`; edit: checked against the record; custom page: `canAccess`. */
|
|
177
184
|
async canAccessPage(PageClass, user) {
|
|
178
185
|
const resource = PageClass.resource;
|
|
179
186
|
if (resource && PageClass.prototype instanceof EditRecord)
|
|
@@ -185,37 +192,37 @@ export class Panel {
|
|
|
185
192
|
return this.authorize(user, 'access', { page: PageClass, rule: () => PageClass.canAccess(user) });
|
|
186
193
|
}
|
|
187
194
|
_widgets = [];
|
|
188
|
-
/**
|
|
195
|
+
/** Panel widgets (dashboard, custom pages): registered as components, displayed through a page's `getHeaderWidgets()`. */
|
|
189
196
|
widgets(widgets) { this._widgets.push(...widgets); return this; }
|
|
190
197
|
getWidgets() { return this._widgets; }
|
|
191
198
|
maxContentWidth(width) { this._maxContentWidth = width; return this; }
|
|
192
199
|
getId() { return this._id; }
|
|
193
200
|
getBrandName() { return this._brandName; }
|
|
194
201
|
getAuth() { if (!this._auth)
|
|
195
|
-
throw new Error('
|
|
202
|
+
throw new Error('No auth provider: call panel.auth(...).'); return this._auth; }
|
|
196
203
|
getLoginPage() { return this._login; }
|
|
197
204
|
getPages() { return this._pages; }
|
|
198
205
|
getResources() { return this._resources; }
|
|
199
|
-
// --------------------------------------------------------------
|
|
206
|
+
// -------------------------------------------------------------- global search
|
|
200
207
|
_globalSearch = true;
|
|
201
208
|
_globalSearchKeyBindings = [];
|
|
202
209
|
_globalSearchDebounce = '500ms';
|
|
203
210
|
_globalSearchSuffix = null;
|
|
204
|
-
/** `false`
|
|
211
|
+
/** `false` removes the global search field. It only appears when at least one resource is searchable. */
|
|
205
212
|
globalSearch(condition = true) { this._globalSearch = condition; return this; }
|
|
206
|
-
/**
|
|
213
|
+
/** Keyboard shortcuts that focus the field: `['command+k', 'ctrl+k']`. */
|
|
207
214
|
globalSearchKeyBindings(keyBindings) { this._globalSearchKeyBindings = keyBindings.filter((binding) => /^[a-z0-9+]+$/.test(binding)); return this; }
|
|
208
215
|
getGlobalSearchKeyBindings() { return this._globalSearchKeyBindings; }
|
|
209
|
-
/**
|
|
216
|
+
/** Delay between typing and searching (`500ms` by default). */
|
|
210
217
|
globalSearchDebounce(debounce) { if (/^[0-9]+m?s$/.test(debounce))
|
|
211
218
|
this._globalSearchDebounce = debounce; return this; }
|
|
212
219
|
getGlobalSearchDebounce() { return this._globalSearchDebounce; }
|
|
213
220
|
globalSearchFieldSuffix(suffix) { this._globalSearchSuffix = suffix; return this; }
|
|
214
|
-
/**
|
|
221
|
+
/** Shows the first shortcut in the field, written for the browser's platform (`command+k` shows as "META+K" outside macOS). */
|
|
215
222
|
globalSearchFieldKeyBindingSuffix() { this._globalSearchSuffix = (userAgent) => keyBindingSuffix(this._globalSearchKeyBindings, userAgent); return this; }
|
|
216
223
|
getGlobalSearchFieldSuffix(userAgent = '') { return typeof this._globalSearchSuffix === 'function' ? this._globalSearchSuffix(userAgent) : this._globalSearchSuffix; }
|
|
217
224
|
hasGlobalSearch() { return this._globalSearch && this._resources.some((resource) => resource.canGloballySearch()); }
|
|
218
|
-
/**
|
|
225
|
+
/** Results per resource, in registration order. Only resources the user can list are queried. */
|
|
219
226
|
async getGlobalSearchResults(search, user) {
|
|
220
227
|
const categories = [];
|
|
221
228
|
if (!this._globalSearch)
|
|
@@ -230,38 +237,38 @@ export class Panel {
|
|
|
230
237
|
}
|
|
231
238
|
return categories;
|
|
232
239
|
}
|
|
233
|
-
// -------------------------------------------------------------- notifications
|
|
240
|
+
// -------------------------------------------------------------- database notifications
|
|
234
241
|
_databaseNotifications = null;
|
|
235
242
|
_databaseNotificationsPolling = '30s';
|
|
236
|
-
/**
|
|
243
|
+
/** Enables the database notifications bell, read from and written to this store (`knexNotificationStore(knex)`). */
|
|
237
244
|
databaseNotifications(store) { this._databaseNotifications = store; return this; }
|
|
238
245
|
getDatabaseNotifications() { return this._databaseNotifications; }
|
|
239
|
-
/**
|
|
246
|
+
/** Polling interval (`30s` by default); `null` disables polling. */
|
|
240
247
|
databaseNotificationsPolling(interval) { this._databaseNotificationsPolling = interval; return this; }
|
|
241
248
|
getDatabaseNotificationsPollingInterval() { return this._databaseNotificationsPolling; }
|
|
242
|
-
// --------------------------------------------------------------
|
|
249
|
+
// -------------------------------------------------------------- layout: topbar, navigation, sidebar
|
|
243
250
|
_topbar = true;
|
|
244
251
|
_topNavigation = false;
|
|
245
252
|
_navigation = true;
|
|
246
253
|
_sidebarCollapsibleOnDesktop = false;
|
|
247
254
|
_sidebarFullyCollapsibleOnDesktop = false;
|
|
248
255
|
_positions = { userMenu: null, globalSearch: null, databaseNotifications: null };
|
|
249
|
-
/**
|
|
256
|
+
/** Topbar, shown by default. `topbar(false)` moves the logo, search, bell and user menu into the sidebar. */
|
|
250
257
|
topbar(condition = true) { this._topbar = condition; return this; }
|
|
251
258
|
hasTopbar() { return this._topbar; }
|
|
252
|
-
/** Navigation
|
|
259
|
+
/** Navigation in the topbar (one dropdown per group) instead of the sidebar. */
|
|
253
260
|
topNavigation(condition = true) { this._topNavigation = condition; return this; }
|
|
254
261
|
hasTopNavigation() { return this._topNavigation; }
|
|
255
|
-
/** `navigation(false)`
|
|
262
|
+
/** `navigation(false)` removes all navigation (no sidebar, no menus). */
|
|
256
263
|
navigation(condition = true) { this._navigation = condition; return this; }
|
|
257
264
|
hasNavigation() { return this._navigation; }
|
|
258
|
-
/**
|
|
265
|
+
/** On desktop, the sidebar can collapse to a column of icons. */
|
|
259
266
|
sidebarCollapsibleOnDesktop(condition = true) { this._sidebarCollapsibleOnDesktop = condition; return this; }
|
|
260
267
|
isSidebarCollapsibleOnDesktop() { return this._sidebarCollapsibleOnDesktop; }
|
|
261
|
-
/**
|
|
268
|
+
/** On desktop, the sidebar can be fully hidden. */
|
|
262
269
|
sidebarFullyCollapsibleOnDesktop(condition = true) { this._sidebarFullyCollapsibleOnDesktop = condition; return this; }
|
|
263
270
|
isSidebarFullyCollapsibleOnDesktop() { return this._sidebarFullyCollapsibleOnDesktop; }
|
|
264
|
-
/**
|
|
271
|
+
/** Where the user menu goes; defaults to the topbar when there is one. */
|
|
265
272
|
userMenuPosition(position) { this._positions.userMenu = position; return this; }
|
|
266
273
|
getUserMenuPosition() { return this._positions.userMenu ?? (this._topbar ? 'topbar' : 'sidebar'); }
|
|
267
274
|
globalSearchPosition(position) { this._positions.globalSearch = position; return this; }
|
|
@@ -269,37 +276,37 @@ export class Panel {
|
|
|
269
276
|
databaseNotificationsPosition(position) { this._positions.databaseNotifications = position; return this; }
|
|
270
277
|
getDatabaseNotificationsPosition() { return this._positions.databaseNotifications ?? (this._topbar ? 'topbar' : 'sidebar'); }
|
|
271
278
|
_userMenuItems = {};
|
|
272
|
-
/**
|
|
279
|
+
/** Items added to the user menu (URL or modal actions). A negative `sort()` places them above the theme switcher. Keys `profile` and `logout` replace the defaults. */
|
|
273
280
|
userMenuItems(items) {
|
|
274
281
|
for (const [key, item] of Array.isArray(items) ? items.map((item) => [item.getName(), item]) : Object.entries(items))
|
|
275
282
|
this._userMenuItems[key] = item;
|
|
276
283
|
return this;
|
|
277
284
|
}
|
|
278
285
|
getUserMenuItems() { return this._userMenuItems; }
|
|
279
|
-
// --------------------------------------------------------------
|
|
286
|
+
// -------------------------------------------------------------- registration, password reset, email verification, profile
|
|
280
287
|
_registration = null;
|
|
281
288
|
_passwordReset = null;
|
|
282
289
|
_emailVerification = null;
|
|
283
290
|
_profile = null;
|
|
284
291
|
_mailer = consoleMailer;
|
|
285
|
-
/**
|
|
292
|
+
/** Registration page (`/register`). The auth provider must implement `create` and `findByEmail`. */
|
|
286
293
|
registration(page = Register) { this._registration = page; return this; }
|
|
287
294
|
hasRegistration() { return this._registration !== null; }
|
|
288
295
|
getRegistrationPage() { return this._registration; }
|
|
289
|
-
/**
|
|
296
|
+
/** Password reset: request page (`/password-reset/request`), then reset through a signed link sent by email. */
|
|
290
297
|
passwordReset(request = RequestPasswordReset, reset = ResetPassword) { this._passwordReset = { request, reset }; return this; }
|
|
291
298
|
hasPasswordReset() { return this._passwordReset !== null; }
|
|
292
299
|
getPasswordResetPages() { return this._passwordReset; }
|
|
293
|
-
/**
|
|
300
|
+
/** Email verification: a signed link is sent on registration. Until it is followed, only the verification prompt is accessible (`isRequired`). */
|
|
294
301
|
emailVerification(prompt = EmailVerificationPrompt, isRequired = true) { this._emailVerification = { prompt, isRequired }; return this; }
|
|
295
302
|
hasEmailVerification() { return this._emailVerification !== null; }
|
|
296
303
|
isEmailVerificationRequired() { return this._emailVerification?.isRequired === true; }
|
|
297
304
|
getEmailVerificationPrompt() { return this._emailVerification?.prompt ?? null; }
|
|
298
|
-
/**
|
|
305
|
+
/** Profile page (`/profile`): name, email, password. `isSimple`: simple layout (default) or full panel layout. */
|
|
299
306
|
profile(page = EditProfile, isSimple = true) { this._profile = { page, isSimple }; return this; }
|
|
300
307
|
hasProfile() { return this._profile !== null; }
|
|
301
308
|
getProfile() { return this._profile; }
|
|
302
|
-
/**
|
|
309
|
+
/** Mailer for password reset and verification emails. Defaults to the console. */
|
|
303
310
|
mailer(mailer) { this._mailer = mailer; return this; }
|
|
304
311
|
async sendMail(message) { await this._mailer.send(message); }
|
|
305
312
|
getAuthKey() { return this._key; }
|
|
@@ -309,7 +316,7 @@ export class Panel {
|
|
|
309
316
|
const token = signToken(this._key, `${this._id}.password-reset`, { uid: user.id, fp: fingerprint }, 60 * 60);
|
|
310
317
|
return `${this.getBaseUrl('password-reset/reset')}?email=${encodeURIComponent(user.email)}&token=${encodeURIComponent(token)}`;
|
|
311
318
|
}
|
|
312
|
-
/**
|
|
319
|
+
/** Payload of a password reset token valid for this panel, otherwise `null`. */
|
|
313
320
|
readResetPasswordToken(token) { return readToken(this._key, `${this._id}.password-reset`, token); }
|
|
314
321
|
getVerifyEmailUrl(user) {
|
|
315
322
|
const expires = Math.floor(Date.now() / 1000) + 60 * 60;
|
|
@@ -319,14 +326,14 @@ export class Panel {
|
|
|
319
326
|
checkVerifyEmailUrl(id, hash, expires, signature) { return checkVerificationUrl(`${this._key}.${this._id}`, id, hash, expires, signature); }
|
|
320
327
|
getEmailVerificationPromptUrl() { return this.getBaseUrl('email-verification/prompt'); }
|
|
321
328
|
getProfileUrl() { return this._profile ? this.getBaseUrl('profile') : null; }
|
|
322
|
-
/**
|
|
329
|
+
/** Whether the user must still verify their email before using the panel. */
|
|
323
330
|
async mustVerifyEmail(user) {
|
|
324
331
|
if (!user || !this.isEmailVerificationRequired())
|
|
325
332
|
return false;
|
|
326
333
|
const auth = this.getAuth();
|
|
327
334
|
return auth.hasVerifiedEmail ? !(await auth.hasVerifiedEmail(user)) : false;
|
|
328
335
|
}
|
|
329
|
-
// --------------------------------------------------------------
|
|
336
|
+
// -------------------------------------------------------------- multi-tenancy
|
|
330
337
|
_tenant = null;
|
|
331
338
|
_tenantOwnershipColumn = null;
|
|
332
339
|
_tenantRegistration = null;
|
|
@@ -335,18 +342,18 @@ export class Panel {
|
|
|
335
342
|
_tenantMenuItems = {};
|
|
336
343
|
_tenantScope = new AsyncLocalStorage();
|
|
337
344
|
/**
|
|
338
|
-
*
|
|
339
|
-
*
|
|
345
|
+
* Makes the panel multi-tenant. `ownershipColumn` is the column linking a record to its tenant (`team_id`);
|
|
346
|
+
* the panel's resources then only read and write the current tenant's records (see `Resource.isScopedToTenant`).
|
|
340
347
|
*/
|
|
341
348
|
tenant(provider, options = {}) { this._tenant = provider; this._tenantOwnershipColumn = options.ownershipColumn ?? null; return this; }
|
|
342
349
|
hasTenancy() { return this._tenant !== null; }
|
|
343
350
|
getTenantProvider() { if (!this._tenant)
|
|
344
|
-
throw new Error('Panel
|
|
351
|
+
throw new Error('Panel has no tenancy: call panel.tenant(...).'); return this._tenant; }
|
|
345
352
|
getTenantOwnershipColumn() { return this._tenantOwnershipColumn; }
|
|
346
|
-
/**
|
|
353
|
+
/** Tenant registration page (`/new`). */
|
|
347
354
|
tenantRegistration(page) { this._tenantRegistration = page; return this; }
|
|
348
355
|
getTenantRegistrationPage() { return this._tenantRegistration; }
|
|
349
|
-
/**
|
|
356
|
+
/** Tenant profile page (`/<tenant>/profile`). */
|
|
350
357
|
tenantProfile(page) { this._tenantProfile = page; return this; }
|
|
351
358
|
getTenantProfilePage() { return this._tenantProfile; }
|
|
352
359
|
tenantMenu(condition = true) { this._tenantMenu = condition; return this; }
|
|
@@ -357,41 +364,41 @@ export class Panel {
|
|
|
357
364
|
return this;
|
|
358
365
|
}
|
|
359
366
|
getTenantMenuItems() { return this._tenantMenuItems; }
|
|
360
|
-
/**
|
|
367
|
+
/** Runs `fn` for a tenant: generated URLs (`getUrl`) and resource queries are scoped to it. */
|
|
361
368
|
withTenant(tenant, fn) { return this._tenantScope.run({ tenant }, fn); }
|
|
362
|
-
/**
|
|
369
|
+
/** Tenant of the current request, or `null`. */
|
|
363
370
|
getTenant() { return (this._tenantScope.getStore()?.tenant ?? null); }
|
|
364
371
|
getTenantUrlKey(tenant) { return this.getTenantProvider().getKey(tenant); }
|
|
365
372
|
_subNavigationPosition = 'start';
|
|
366
|
-
/**
|
|
373
|
+
/** Default position of page sub-navigation (`start`). */
|
|
367
374
|
subNavigationPosition(position) { this._subNavigationPosition = position; return this; }
|
|
368
375
|
getSubNavigationPosition() { return this._subNavigationPosition; }
|
|
369
376
|
_appUrl = null;
|
|
370
|
-
/**
|
|
377
|
+
/** Public URL of the application (`https://example.com`), used in emailed links. Without it the request origin is used, which should be avoided in production (the `Host` header can be forged). */
|
|
371
378
|
appUrl(url) { this._appUrl = url ? url.replace(/\/$/, '') : null; return this; }
|
|
372
379
|
getAppUrl() { return this._appUrl; }
|
|
373
380
|
getMaxContentWidth() { return this._maxContentWidth; }
|
|
374
|
-
/**
|
|
381
|
+
/** URL inside the panel; in a multi-tenant panel, prefixed with the current (or given) tenant. */
|
|
375
382
|
getUrl(sub = '', tenant = this.getTenant()) {
|
|
376
383
|
if (this._tenant && tenant)
|
|
377
384
|
return '/' + [this._path, encodeURIComponent(this.getTenantUrlKey(tenant)), sub.replace(/^\/|\/$/g, '')].filter(Boolean).join('/');
|
|
378
385
|
return this.getBaseUrl(sub);
|
|
379
386
|
}
|
|
380
|
-
/**
|
|
387
|
+
/** URL outside any tenant (login, registration, profile, tenant registration). */
|
|
381
388
|
getBaseUrl(sub = '') { return '/' + [this._path, sub.replace(/^\/|\/$/g, '')].filter(Boolean).join('/'); }
|
|
382
389
|
getLoginUrl() { return this.getBaseUrl('login'); }
|
|
383
390
|
getLogoutUrl() { return this.getBaseUrl('logout'); }
|
|
384
|
-
/**
|
|
391
|
+
/** Prefix of this panel's Livewire endpoint (`/livewire` or `/<path>/livewire`): each panel has its own. */
|
|
385
392
|
getLivewirePrefix() { return this._path ? `/${this._path}/livewire` : '/livewire'; }
|
|
386
393
|
assetUrl(file) { return `/${file}?v=${ASSET_VERSION}`; }
|
|
387
394
|
getBrand() { return { name: this._brandName, logo: this._brandLogo, darkLogo: this._darkModeBrandLogo, height: this._brandLogoHeight }; }
|
|
388
395
|
getFont() { return this._font; }
|
|
389
|
-
/**
|
|
396
|
+
/** Default avatar: the name's initials from the external ui-avatars.com service. */
|
|
390
397
|
getUserAvatarUrl(user) { return this.getAvatarUrlFor(user.name); }
|
|
391
|
-
/**
|
|
398
|
+
/** Tenant avatar: from the tenant provider (`getAvatarUrl`), otherwise its initials. */
|
|
392
399
|
getTenantAvatarUrl(tenant) { return this.getTenantProvider().getAvatarUrl?.(tenant) ?? this.getAvatarUrlFor(this.getTenantProvider().getName(tenant)); }
|
|
393
400
|
_spa = false;
|
|
394
|
-
/**
|
|
401
|
+
/** SPA mode: navigate between panel pages without full reloads (`wire:navigate`). */
|
|
395
402
|
spa(condition = true) { this._spa = condition; return this; }
|
|
396
403
|
hasSpaMode() { return this._spa; }
|
|
397
404
|
getAvatarUrlFor(name) {
|
|
@@ -399,8 +406,8 @@ export class Panel {
|
|
|
399
406
|
const background = '#' + convertToRgb(this.colors.getColor('gray')[950]).map((n) => n.toString(16).padStart(2, '0')).join('');
|
|
400
407
|
return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials).replace(/%20/g, '+')}&format=svg&color=FFFFFF&background=${encodeURIComponent(background)}`;
|
|
401
408
|
}
|
|
402
|
-
/**
|
|
403
|
-
/**
|
|
409
|
+
/** Navigation groups: registered pages plus custom items, sorted, grouped in the declared order. */
|
|
410
|
+
/** Computed navigation badges: page URL → value. */
|
|
404
411
|
async resolveNavigationBadges() {
|
|
405
412
|
const badges = new Map();
|
|
406
413
|
for (const page of this._pages)
|
|
@@ -408,7 +415,7 @@ export class Panel {
|
|
|
408
415
|
badges.set(page.slug, await page.getNavigationBadge());
|
|
409
416
|
return badges;
|
|
410
417
|
}
|
|
411
|
-
/**
|
|
418
|
+
/** URLs of menu pages this user cannot open. */
|
|
412
419
|
async resolveInaccessiblePages(user) {
|
|
413
420
|
const hidden = new Set();
|
|
414
421
|
for (const page of this._pages)
|
|
@@ -431,7 +438,7 @@ export class Panel {
|
|
|
431
438
|
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: own };
|
|
432
439
|
}).filter((g) => g.items.length);
|
|
433
440
|
}
|
|
434
|
-
// --- session
|
|
441
|
+
// --- session: an HMAC-signed cookie holding the user id
|
|
435
442
|
sign(value) { return createHmac('sha256', this._key).update(value).digest('base64url'); }
|
|
436
443
|
logIn(res, user, remember) {
|
|
437
444
|
const value = Buffer.from(JSON.stringify({ uid: user.id })).toString('base64url');
|
|
@@ -454,11 +461,8 @@ export class Panel {
|
|
|
454
461
|
}
|
|
455
462
|
}
|
|
456
463
|
}
|
|
457
|
-
// ------------------------------------------------------------------ render hooks
|
|
458
464
|
/**
|
|
459
|
-
*
|
|
460
|
-
* Ceux d'une brique pas encore portée (barre du haut, recherche globale, menu de locataire, sous-navigation, inscription,
|
|
461
|
-
* réinitialisation du mot de passe, enregistrements liés) sont déclarés mais ne sont rendus nulle part pour l'instant : voir `UNPLACED_RENDER_HOOKS`.
|
|
465
|
+
* Render hook locations of a panel. A few are declared but not rendered anywhere yet: see `UNPLACED_RENDER_HOOKS`.
|
|
462
466
|
*/
|
|
463
467
|
export const PanelsRenderHook = {
|
|
464
468
|
AUTH_LOGIN_FORM_AFTER: 'panels::auth.login.form.after',
|
|
@@ -498,7 +502,7 @@ export const PanelsRenderHook = {
|
|
|
498
502
|
PAGE_HEADER_WIDGETS_END: 'panels::page.header-widgets.end',
|
|
499
503
|
PAGE_HEADER_WIDGETS_START: 'panels::page.header-widgets.start',
|
|
500
504
|
PAGE_START: 'panels::page.start',
|
|
501
|
-
/**
|
|
505
|
+
/** The hook name says "module-menu", not "mobile-menu": kept for compatibility. */
|
|
502
506
|
PAGE_SUB_NAVIGATION_MOBILE_MENU_AFTER: 'panels::page.sub-navigation.module-menu.after',
|
|
503
507
|
PAGE_SUB_NAVIGATION_MOBILE_MENU_BEFORE: 'panels::page.sub-navigation.module-menu.before',
|
|
504
508
|
PAGE_SUB_NAVIGATION_SIDEBAR_AFTER: 'panels::page.sub-navigation.sidebar.after',
|
|
@@ -546,23 +550,21 @@ export const PanelsRenderHook = {
|
|
|
546
550
|
USER_MENU_PROFILE_AFTER: 'panels::user-menu.profile.after',
|
|
547
551
|
USER_MENU_PROFILE_BEFORE: 'panels::user-menu.profile.before',
|
|
548
552
|
};
|
|
549
|
-
/**
|
|
553
|
+
/** Widget render hook locations, rendered by `TableWidget`. */
|
|
550
554
|
export const WidgetsRenderHook = {
|
|
551
555
|
TABLE_WIDGET_END: 'widgets::table-widget.end',
|
|
552
556
|
TABLE_WIDGET_START: 'widgets::table-widget.start',
|
|
553
557
|
};
|
|
554
558
|
/**
|
|
555
|
-
*
|
|
556
|
-
* Les quatre « tabs » ne sont rendus par aucune vue de Filament 5.11.2 non plus.
|
|
559
|
+
* Hook locations that are declared but not rendered anywhere yet, with the reason.
|
|
557
560
|
*/
|
|
558
561
|
export const UNPLACED_RENDER_HOOKS = {
|
|
559
|
-
...Object.fromEntries(['RESOURCE_PAGES_MANAGE_RELATED_RECORDS_TABLE_AFTER', 'RESOURCE_PAGES_MANAGE_RELATED_RECORDS_TABLE_BEFORE'].map((k) => [PanelsRenderHook[k], 'page
|
|
560
|
-
...Object.fromEntries(['RESOURCE_PAGES_LIST_RECORDS_TABS_END', 'RESOURCE_PAGES_LIST_RECORDS_TABS_START', 'RESOURCE_TABS_END', 'RESOURCE_TABS_START'].map((k) => [PanelsRenderHook[k], '
|
|
562
|
+
...Object.fromEntries(['RESOURCE_PAGES_MANAGE_RELATED_RECORDS_TABLE_AFTER', 'RESOURCE_PAGES_MANAGE_RELATED_RECORDS_TABLE_BEFORE'].map((k) => [PanelsRenderHook[k], 'ManageRelatedRecords page not implemented'])),
|
|
563
|
+
...Object.fromEntries(['RESOURCE_PAGES_LIST_RECORDS_TABS_END', 'RESOURCE_PAGES_LIST_RECORDS_TABS_START', 'RESOURCE_TABS_END', 'RESOURCE_TABS_START'].map((k) => [PanelsRenderHook[k], 'reserved, no view renders it'])),
|
|
561
564
|
};
|
|
562
|
-
|
|
563
|
-
/** Portée Alpine d'un composant de schéma sans clé (cellule de grille anonyme). */
|
|
565
|
+
/** Alpine scope of a schema component without a key (anonymous grid cell). */
|
|
564
566
|
const ANONYMOUS_SCHEMA_COMPONENT = `x-data="filamentSchemaComponent(${e(js({ key: null, exposedMethods: [], path: '', containerPath: '' }))})"`;
|
|
565
|
-
/**
|
|
567
|
+
/** Common base: a stateful component hosting schemas (`<name>Schema(schema)`). */
|
|
566
568
|
class BasePage extends Component {
|
|
567
569
|
get panel() { return this._ctx.panel; }
|
|
568
570
|
getSchema(name) {
|
|
@@ -572,15 +574,15 @@ class BasePage extends Component {
|
|
|
572
574
|
return this[`${name}Schema`](schema);
|
|
573
575
|
}
|
|
574
576
|
contentSchema(schema) { return schema.components([]); }
|
|
575
|
-
/**
|
|
577
|
+
/** Render hook scopes of this page: its class (and its resource, for a resource page). */
|
|
576
578
|
getRenderHookScopes() {
|
|
577
579
|
const resource = this.constructor.resource;
|
|
578
580
|
return resource ? [this.constructor, resource] : [this.constructor];
|
|
579
581
|
}
|
|
580
582
|
}
|
|
581
|
-
/**
|
|
583
|
+
/** A panel page, in the full layout (sidebar, header). */
|
|
582
584
|
export class Page extends BasePage {
|
|
583
|
-
/**
|
|
585
|
+
/** Methods the browser may call. A subclass adds its own: `static callable = ['approve']`. */
|
|
584
586
|
static callable = ['mountAction', 'unmountAction', 'callMountedAction', 'callSchemaComponentMethod'];
|
|
585
587
|
static slug = '';
|
|
586
588
|
static title = null;
|
|
@@ -591,23 +593,23 @@ export class Page extends BasePage {
|
|
|
591
593
|
static navigationBadge = null;
|
|
592
594
|
static navigationBadgeColor = 'primary';
|
|
593
595
|
static shouldRegisterNavigation = true;
|
|
594
|
-
/**
|
|
596
|
+
/** Restricts the page to some users: returning `false` hides it from the menu and denies it (403). */
|
|
595
597
|
static canAccess(_user) { return true; }
|
|
596
598
|
static getTitle() { return this.title ?? this.name.replace(/([a-z])([A-Z])/g, '$1 $2'); }
|
|
597
599
|
static getNavigationLabel() { return this.navigationLabel ?? this.getTitle(); }
|
|
598
|
-
/**
|
|
600
|
+
/** Every request, including browser updates, requires an authenticated user: a snapshot kept after logout is not enough. */
|
|
599
601
|
async boot() {
|
|
600
602
|
const ctx = this._ctx;
|
|
601
603
|
if (!ctx?.user)
|
|
602
|
-
throw new WireError(401, '
|
|
603
|
-
//
|
|
604
|
+
throw new WireError(401, 'Unauthenticated.');
|
|
605
|
+
// In a multi-tenant panel, every page belongs to a tenant the user is a member of (checked by the router).
|
|
604
606
|
if (ctx.panel.hasTenancy() && !ctx.tenant)
|
|
605
|
-
throw new WireError(404, '
|
|
607
|
+
throw new WireError(404, 'Tenant not found.');
|
|
606
608
|
if (await ctx.panel.mustVerifyEmail(ctx.user))
|
|
607
|
-
throw new WireError(403, '
|
|
608
|
-
//
|
|
609
|
+
throw new WireError(403, 'Email address not verified.');
|
|
610
|
+
// Checked on every request, not only on display: a forged request to a forbidden page is denied.
|
|
609
611
|
if (!(await this.isAccessAuthorized(ctx)))
|
|
610
|
-
throw new WireError(403, '
|
|
612
|
+
throw new WireError(403, 'Forbidden.');
|
|
611
613
|
}
|
|
612
614
|
isAccessAuthorized(ctx) { return ctx.panel.canAccessPage(this.constructor, ctx.user); }
|
|
613
615
|
getTitle() { return this.constructor.getTitle(); }
|
|
@@ -615,19 +617,19 @@ export class Page extends BasePage {
|
|
|
615
617
|
getBreadcrumbs() { return []; }
|
|
616
618
|
getHeaderActions() { return []; }
|
|
617
619
|
getPageClasses() { return []; }
|
|
618
|
-
/**
|
|
620
|
+
/** Page sub-navigation; empty by default. */
|
|
619
621
|
getSubNavigation() { return []; }
|
|
620
|
-
/**
|
|
622
|
+
/** Sub-navigation position: `start` (column before the content), `end` (after) or `top` (tabs). Defaults to the resource's, then the panel's. */
|
|
621
623
|
static subNavigationPosition = null;
|
|
622
624
|
getSubNavigationPosition() {
|
|
623
625
|
const own = this.constructor.subNavigationPosition;
|
|
624
|
-
const resource = this.constructor.resource ?? this.constructor.
|
|
626
|
+
const resource = this.constructor.resource ?? this.constructor.parentResource;
|
|
625
627
|
return own ?? resource?.subNavigationPosition ?? this.panel.getSubNavigationPosition();
|
|
626
628
|
}
|
|
627
|
-
/** Widgets
|
|
629
|
+
/** Widgets shown below the page content. */
|
|
628
630
|
getFooterWidgets() { return []; }
|
|
629
631
|
getFooterWidgetsColumns() { return 2; }
|
|
630
|
-
/**
|
|
632
|
+
/** Footer widget grid, in the page's `footerWidgets` schema. */
|
|
631
633
|
async renderFooterWidgets() {
|
|
632
634
|
const widgets = await this.filterVisibleWidgets(this.getFooterWidgets());
|
|
633
635
|
if (!widgets.length)
|
|
@@ -635,11 +637,11 @@ export class Page extends BasePage {
|
|
|
635
637
|
const liberate = (hook) => `<div class="fi-sc-liberated" wire:key="" ${ANONYMOUS_SCHEMA_COMPONENT}>${renderHook(hook, this.getRenderHookScopes())}</div>`;
|
|
636
638
|
return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'footerWidgets' })" 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));">${liberate(PanelsRenderHook.PAGE_FOOTER_WIDGETS_START)}${await this.renderWidgetsGrid(widgets, this.getFooterWidgetsColumns(), 'footerWidgets')}${liberate(PanelsRenderHook.PAGE_FOOTER_WIDGETS_END)}</div>`;
|
|
637
639
|
}
|
|
638
|
-
/** Widgets
|
|
640
|
+
/** Widgets shown above the page content, with the data passed to each (widget properties). */
|
|
639
641
|
getHeaderWidgets() { return []; }
|
|
640
642
|
getHeaderWidgetsColumns() { return 2; }
|
|
641
643
|
getWidgetData() { return {}; }
|
|
642
|
-
/**
|
|
644
|
+
/** Header widget grid, in the page's `headerWidgets` schema. */
|
|
643
645
|
async renderHeaderWidgets() {
|
|
644
646
|
const widgets = await this.filterVisibleWidgets(this.getHeaderWidgets());
|
|
645
647
|
if (!widgets.length)
|
|
@@ -647,7 +649,7 @@ export class Page extends BasePage {
|
|
|
647
649
|
const liberate = (hook) => `<div class="fi-sc-liberated" wire:key="" ${ANONYMOUS_SCHEMA_COMPONENT}>${renderHook(hook, this.getRenderHookScopes())}</div>`;
|
|
648
650
|
return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'headerWidgets' })" 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));">${liberate(PanelsRenderHook.PAGE_HEADER_WIDGETS_START)}${await this.renderWidgetsGrid(widgets, this.getHeaderWidgetsColumns(), 'headerWidgets')}${liberate(PanelsRenderHook.PAGE_HEADER_WIDGETS_END)}</div>`;
|
|
649
651
|
}
|
|
650
|
-
/** Widgets
|
|
652
|
+
/** Widgets the current user is allowed to see. */
|
|
651
653
|
async filterVisibleWidgets(candidates) {
|
|
652
654
|
const viewer = this._ctx.user;
|
|
653
655
|
const widgets = [];
|
|
@@ -656,50 +658,51 @@ export class Page extends BasePage {
|
|
|
656
658
|
widgets.push(widget);
|
|
657
659
|
return widgets;
|
|
658
660
|
}
|
|
659
|
-
/**
|
|
661
|
+
/** Widget grid of a schema: each widget is a child component, lazy-loaded by default. */
|
|
660
662
|
async renderWidgetsGrid(widgets, columns, schemaKey) {
|
|
661
663
|
const items = [];
|
|
662
664
|
for (const [index, widget] of widgets.entries()) {
|
|
663
|
-
const
|
|
664
|
-
const
|
|
665
|
+
const componentName = this._runtime.nameOf(widget);
|
|
666
|
+
const key = `${schemaKey}.${componentName}-${index}`;
|
|
667
|
+
const child = await this.mountChild(componentName, this.getWidgetData(), `${this.id}.${key}`, { lazy: widget.isLazy });
|
|
665
668
|
items.push(`<div class="fi-sc-liberated" wire:key="${e(`${this.id}.${key}`)}" x-data="filamentSchemaComponent(${e(js({ key, exposedMethods: [], path: '', containerPath: '' }))})">${child}</div>`);
|
|
666
669
|
}
|
|
667
670
|
const grid = new Attributes().grid(typeof columns === 'number' ? { lg: columns } : columns).class(['fi-sc', 'fi-sc-has-gap']);
|
|
668
671
|
return `<div ${ANONYMOUS_SCHEMA_COMPONENT} class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component"><div><div ${grid.toHtml()}>${items.join('')}</div></div></div></div>`;
|
|
669
672
|
}
|
|
670
|
-
/**
|
|
673
|
+
/** Page content: its `content` schema by default. */
|
|
671
674
|
async renderContent() { return (await this.getSchema('content')).toHtml(); }
|
|
672
|
-
// --- actions
|
|
675
|
+
// --- mounted actions: the stack lives in the component state, modals are rendered server-side
|
|
673
676
|
mountedActions = [];
|
|
674
677
|
getActionMountContext() { return {}; }
|
|
675
678
|
getActionRecord() { return null; }
|
|
676
|
-
/**
|
|
679
|
+
/** Record targeted by an action mounted in a given context (table row…). */
|
|
677
680
|
async resolveActionRecord(_context) { return this.getActionRecord(); }
|
|
678
|
-
/** Actions
|
|
681
|
+
/** Actions that can be mounted in a given context (page header by default). */
|
|
679
682
|
getMountableActions(context) {
|
|
680
683
|
return typeof context.schemaComponent === 'string' ? this.getSchemaComponentActions(context.schemaComponent) : this.getHeaderActions();
|
|
681
684
|
}
|
|
682
685
|
/**
|
|
683
|
-
* Actions
|
|
684
|
-
*
|
|
686
|
+
* Actions owned by a schema component (Repeater: add, delete, move; Section: header actions).
|
|
687
|
+
* The key comes from the browser: it must match a real component of the schema, or the schema itself.
|
|
685
688
|
*/
|
|
686
689
|
async getSchemaComponentActions(key) {
|
|
687
690
|
const name = key.split('.')[0];
|
|
688
|
-
//
|
|
691
|
+
// The component may belong to the form of an already mounted action (rich editor in a modal).
|
|
689
692
|
const mountedIndex = /^mountedActionSchema(\d+)$/.exec(name);
|
|
690
693
|
if (!mountedIndex && (!/^[A-Za-z]+$/.test(name) || typeof this[`${name}Schema`] !== 'function'))
|
|
691
694
|
return [];
|
|
692
695
|
const schema = mountedIndex ? await this.getMountedActionSchema(Number(mountedIndex[1])) : this.getSchema(name);
|
|
693
696
|
if (!schema)
|
|
694
697
|
return [];
|
|
695
|
-
//
|
|
698
|
+
// Only visible components expose their actions: a hidden component (section, callout…) mounts none.
|
|
696
699
|
const components = (await schema.getVisibleFlatComponents());
|
|
697
700
|
const exact = components.find((c) => c.getKey() === key && typeof c.getActions === 'function');
|
|
698
701
|
if (exact)
|
|
699
702
|
return exact.getActions();
|
|
700
703
|
return key === schema.getKey() ? components.filter((c) => !c.hasStatePath() && typeof c.getActions === 'function').flatMap((c) => c.getActions()) : [];
|
|
701
704
|
}
|
|
702
|
-
/**
|
|
705
|
+
/** Context kept for a mounted action: the server's, never the browser's as-is. */
|
|
703
706
|
resolveMountContext(clientContext) {
|
|
704
707
|
const component = clientContext?.schemaComponent;
|
|
705
708
|
return typeof component === 'string' && component.length <= 200 ? { ...this.getActionMountContext(), schemaComponent: component } : this.getActionMountContext();
|
|
@@ -707,11 +710,11 @@ export class Page extends BasePage {
|
|
|
707
710
|
async findMountableAction(name, context = this.getActionMountContext()) {
|
|
708
711
|
const action = (await this.getMountableActions(context)).find((a) => a.getName() === name && a.isMountable());
|
|
709
712
|
if (!action)
|
|
710
|
-
throw new WireError(404, `Action
|
|
713
|
+
throw new WireError(404, `Action not found: ${String(name)}`);
|
|
711
714
|
return action;
|
|
712
715
|
}
|
|
713
716
|
async actionContext(mounted) { return { livewire: this, record: await this.resolveActionRecord(mounted.context), arguments: mounted.arguments, data: emptyToNull(mounted.data ?? {}) }; }
|
|
714
|
-
/**
|
|
717
|
+
/** Form schema of a mounted action: key `mountedActionSchema<n>`, state under `mountedActions.<n>.data`. */
|
|
715
718
|
async getMountedActionSchema(index) {
|
|
716
719
|
const mounted = this.mountedActions[index];
|
|
717
720
|
if (!mounted)
|
|
@@ -723,7 +726,7 @@ export class Page extends BasePage {
|
|
|
723
726
|
const mounted = this.mountedActions[index];
|
|
724
727
|
return (await this.findMountableAction(mounted.name, mounted.context)).toModalHtml(await this.actionContext(mounted), index, (await (await this.getMountedActionSchema(index))?.toHtml()) ?? null);
|
|
725
728
|
}
|
|
726
|
-
/**
|
|
729
|
+
/** Cancels a partial modal render prepared by a live field when the same request then runs an action. */
|
|
727
730
|
cancelModalPartialRender() {
|
|
728
731
|
for (const name of Object.keys(this._effects.partials ?? {}))
|
|
729
732
|
if (name.startsWith('action-modals.')) {
|
|
@@ -731,7 +734,7 @@ export class Page extends BasePage {
|
|
|
731
734
|
this._skipRender = false;
|
|
732
735
|
}
|
|
733
736
|
}
|
|
734
|
-
/**
|
|
737
|
+
/** Live field in a mounted action's form: runs `afterStateUpdated`, then only returns the affected modal. */
|
|
735
738
|
async updatedMountedActions(_value, key) {
|
|
736
739
|
const m = /^(\d+)\.data\.(.+)$/.exec(key ?? '');
|
|
737
740
|
if (!m)
|
|
@@ -745,7 +748,7 @@ export class Page extends BasePage {
|
|
|
745
748
|
await field.callAfterStateUpdated((name, value) => { data[name] = value; });
|
|
746
749
|
this.renderPartial(`action-modals.${index}`, await this.renderActionModal(index));
|
|
747
750
|
}
|
|
748
|
-
/**
|
|
751
|
+
/** Calls a method exposed by a field, from that field's JavaScript (e.g. Select search). Does not re-render. */
|
|
749
752
|
async callSchemaComponentMethod(componentKey, method, args = []) {
|
|
750
753
|
if (typeof componentKey !== 'string' || typeof method !== 'string') {
|
|
751
754
|
this.skipRender();
|
|
@@ -754,34 +757,34 @@ export class Page extends BasePage {
|
|
|
754
757
|
const name = componentKey.split('.')[0];
|
|
755
758
|
const mountedIndex = /^mountedActionSchema(\d+)$/.exec(name);
|
|
756
759
|
const schema = mountedIndex ? await this.getMountedActionSchema(Number(mountedIndex[1])) : typeof this[`${name}Schema`] === 'function' ? await this.getSchema(name) : null;
|
|
757
|
-
//
|
|
760
|
+
// A hidden component exposes no method, even if the browser forges the call.
|
|
758
761
|
const component = schema ? (await schema.getVisibleFlatComponents()).find((c) => c.getKey() === componentKey) : undefined;
|
|
759
|
-
//
|
|
762
|
+
// Only methods the component declares as exposed can be called from the browser.
|
|
760
763
|
if (!component || !component.getExposedMethods().includes(method) || typeof component[method] !== 'function') {
|
|
761
764
|
this.skipRender();
|
|
762
765
|
return null;
|
|
763
766
|
}
|
|
764
767
|
if (component.isRenderlessMethod(method))
|
|
765
768
|
this.skipRender();
|
|
766
|
-
//
|
|
769
|
+
// Named arguments (an object), as sent by the front-end JavaScript: passed in key order.
|
|
767
770
|
return component[method](...(Array.isArray(args) ? args : args && typeof args === 'object' ? Object.values(args) : []));
|
|
768
771
|
}
|
|
769
|
-
/**
|
|
772
|
+
/** A hidden action can be neither mounted nor run, even through a call forged by the browser. */
|
|
770
773
|
async assertActionVisible(action, context = {}) {
|
|
771
774
|
const record = await this.resolveActionRecord(context);
|
|
772
775
|
if (context.recordKey !== undefined && record === null)
|
|
773
|
-
throw new WireError(404, '
|
|
776
|
+
throw new WireError(404, 'Record not found.');
|
|
774
777
|
const ctx = { livewire: this, component: null, operation: null, record, get: () => undefined, state: undefined };
|
|
775
778
|
if (!(await action.isVisible(ctx)) || (await action.isDisabled(ctx)))
|
|
776
|
-
throw new WireError(403, `Action
|
|
777
|
-
//
|
|
779
|
+
throw new WireError(403, `Action unavailable: ${action.getName()}`);
|
|
780
|
+
// An action with a URL for this record is a link: it cannot be mounted.
|
|
778
781
|
if (action.resolveUrl(ctx) !== null)
|
|
779
|
-
throw new WireError(404, `Action
|
|
782
|
+
throw new WireError(404, `Action not found: ${action.getName()}`);
|
|
780
783
|
}
|
|
781
784
|
syncActionModals() {
|
|
782
785
|
this.dispatch('sync-action-modals', { id: this.id, newActionNestingIndex: this.mountedActions.length ? this.mountedActions.length - 1 : null, shouldOverlayParentActions: false });
|
|
783
786
|
}
|
|
784
|
-
/**
|
|
787
|
+
/** False for a table component: its modals are rendered inside the table. */
|
|
785
788
|
rendersActionModalsInPage() { return true; }
|
|
786
789
|
async renderActionModals() {
|
|
787
790
|
const modals = [];
|
|
@@ -791,7 +794,7 @@ export class Page extends BasePage {
|
|
|
791
794
|
livewireId: ${js(this.id)},
|
|
792
795
|
})" style="height: 0">${modals.join('')}</div>`;
|
|
793
796
|
}
|
|
794
|
-
/**
|
|
797
|
+
/** Mounts an action: without a modal it runs immediately; with one, only the modals fragment is returned. */
|
|
795
798
|
async mountAction(name, args = {}, _context = {}) {
|
|
796
799
|
this.cancelModalPartialRender();
|
|
797
800
|
const context = this.resolveMountContext(_context);
|
|
@@ -805,7 +808,7 @@ export class Page extends BasePage {
|
|
|
805
808
|
this.mountedActions.push(mounted);
|
|
806
809
|
const schema = await this.getMountedActionSchema(this.mountedActions.length - 1);
|
|
807
810
|
if (schema) {
|
|
808
|
-
//
|
|
811
|
+
// Initial state: field defaults, then `fillForm()`.
|
|
809
812
|
const data = {};
|
|
810
813
|
mounted.data = data;
|
|
811
814
|
fillSchema(schema, data);
|
|
@@ -841,7 +844,7 @@ export class Page extends BasePage {
|
|
|
841
844
|
if (schema)
|
|
842
845
|
callContext.data = await getSchemaState(schema);
|
|
843
846
|
await action.call(callContext);
|
|
844
|
-
//
|
|
847
|
+
// "… & create another": the modal stays open with a reset form.
|
|
845
848
|
if (schema && _args && typeof _args === 'object' && _args.another === true) {
|
|
846
849
|
const data = {};
|
|
847
850
|
mounted.data = data;
|
|
@@ -866,25 +869,25 @@ export class Page extends BasePage {
|
|
|
866
869
|
}
|
|
867
870
|
}
|
|
868
871
|
/**
|
|
869
|
-
*
|
|
870
|
-
*
|
|
872
|
+
* Dashboard: a widget grid, preceded by a filters form when the page defines `filtersFormSchema(schema)`.
|
|
873
|
+
* The filters are passed to widgets as `pageFilters`.
|
|
871
874
|
*/
|
|
872
875
|
export class Dashboard extends Page {
|
|
873
876
|
static slug = '';
|
|
874
877
|
static title = 'Dashboard';
|
|
875
878
|
static navigationIcon = 'o-home';
|
|
876
879
|
static navigationSort = -2;
|
|
877
|
-
/**
|
|
880
|
+
/** Filters form state. Stays `null` while the page has no filters. */
|
|
878
881
|
filters = null;
|
|
879
|
-
/**
|
|
882
|
+
/** Dashboard widgets. Defaults to the panel's (`panel.widgets([...])`). */
|
|
880
883
|
getWidgets() { return this.panel.getWidgets(); }
|
|
881
|
-
/**
|
|
884
|
+
/** Grid columns: a number (at the `lg` breakpoint), or one number per breakpoint. */
|
|
882
885
|
getColumns() { return 2; }
|
|
883
886
|
hasFiltersForm() { return typeof this.filtersFormSchema === 'function'; }
|
|
884
887
|
getSchema(name) {
|
|
885
888
|
if (name !== 'filtersForm')
|
|
886
889
|
return super.getSchema(name);
|
|
887
|
-
//
|
|
890
|
+
// 2 / 3 / 4 column grid, state under `filters`, live fields.
|
|
888
891
|
const schema = this.filtersFormSchema(Schema.make(this).key('filtersForm').columns({ md: 2, xl: 3, '2xl': 4 }).statePath('filters').partialName('table-filters-form'));
|
|
889
892
|
for (const component of schema.getFlatComponents())
|
|
890
893
|
if (component instanceof Field && typeof component.live === 'function')
|
|
@@ -904,13 +907,13 @@ export class Dashboard extends Page {
|
|
|
904
907
|
return `<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));">${filters}${widgets}</div>`;
|
|
905
908
|
}
|
|
906
909
|
}
|
|
907
|
-
/**
|
|
910
|
+
/** In-memory rate limiter: `max` attempts per window, per key. Fine for a single instance; use a shared store when running several. */
|
|
908
911
|
export class Throttle {
|
|
909
912
|
hits = new Map();
|
|
910
913
|
max;
|
|
911
914
|
windowMs;
|
|
912
915
|
constructor(max = 5, windowMs = 60_000) { this.max = max; this.windowMs = windowMs; }
|
|
913
|
-
/**
|
|
916
|
+
/** Records an attempt; returns 0 if allowed, otherwise the number of seconds to wait. */
|
|
914
917
|
hit(key, now = Date.now()) {
|
|
915
918
|
const recent = (this.hits.get(key) ?? []).filter((at) => at > now - this.windowMs);
|
|
916
919
|
if (recent.length >= this.max) {
|
|
@@ -919,7 +922,7 @@ export class Throttle {
|
|
|
919
922
|
}
|
|
920
923
|
recent.push(now);
|
|
921
924
|
this.hits.set(key, recent);
|
|
922
|
-
//
|
|
925
|
+
// Keep the store from growing forever.
|
|
923
926
|
if (this.hits.size > 10_000)
|
|
924
927
|
for (const [k, v] of this.hits)
|
|
925
928
|
if (!v.some((at) => at > now - this.windowMs))
|
|
@@ -929,35 +932,35 @@ export class Throttle {
|
|
|
929
932
|
clear(key) { this.hits.delete(key); }
|
|
930
933
|
}
|
|
931
934
|
const loginThrottle = new Throttle();
|
|
932
|
-
/** Page
|
|
935
|
+
/** Page with the simple layout (login, registration…). */
|
|
933
936
|
export class SimplePage extends BasePage {
|
|
934
937
|
getHeading() { return this.getTitle(); }
|
|
935
|
-
/**
|
|
938
|
+
/** Subheading, as trusted HTML (built by the page, with data escaped). */
|
|
936
939
|
getSubheading() { return null; }
|
|
937
940
|
hasLogo() { return true; }
|
|
938
941
|
async render() {
|
|
939
942
|
return views.render('filament-panels::pages.simple', { brand: this.panel.getBrand(), heading: this.getHeading(), subheading: this.getSubheading(), hasLogo: this.hasLogo(), content: await this.getSchema('content').toHtml(), livewireId: this.id, renderHookScopes: this.getRenderHookScopes() });
|
|
940
943
|
}
|
|
941
944
|
}
|
|
942
|
-
/**
|
|
945
|
+
/** Color manager read by the other packages and shared by all panels; each request sets its panel's colors on it (`Panel.withTheme`). */
|
|
943
946
|
const sharedColors = new ColorManager();
|
|
944
|
-
/**
|
|
947
|
+
/** `fi-link` link of an authentication page. */
|
|
945
948
|
function authLink(url, label, options = {}) {
|
|
946
949
|
const classes = ['fi-ac-link-action', 'fi-link', `fi-size-${options.size ?? 'md'}`, ...sharedColors.getComponentClasses('link', 'primary')];
|
|
947
950
|
return `<a href="${e(url)}" class="${classes.join(' ')}" >${options.iconName ? icon(options.iconName) : ''}<span class="fi-link-label"> ${e(label)} </span></a>`;
|
|
948
951
|
}
|
|
949
|
-
/**
|
|
952
|
+
/** Absolute URL for an email: based on `panel.appUrl()`, otherwise the request origin. */
|
|
950
953
|
function absoluteUrl(panel, req, path) { return `${panel.getAppUrl() ?? `${req.protocol}://${req.get('host')}`}${path}`; }
|
|
951
954
|
const registerThrottle = new Throttle(2);
|
|
952
955
|
const passwordResetThrottle = new Throttle(2);
|
|
953
956
|
const verificationThrottle = new Throttle(2);
|
|
954
957
|
const throttled = (group, wait) => Notification.make().danger().title(t(`${group}.notifications.throttled.title`, { seconds: wait, minutes: Math.ceil(wait / 60) })).body(t(`${group}.notifications.throttled.body`, { seconds: wait, minutes: Math.ceil(wait / 60) })).send();
|
|
955
|
-
/**
|
|
958
|
+
/** Password rules (at least 8 characters) and confirmation rule (`same`). */
|
|
956
959
|
const passwordRules = (confirmationField, confirmationLabel) => [
|
|
957
960
|
{ name: 'min', passes: (value) => String(value).length >= 8, message: (attribute) => `The ${attribute} field must be at least 8 characters.` },
|
|
958
961
|
{ name: 'same', passes: (value, ctx) => value === ctx.get(confirmationField), message: (attribute) => `The ${attribute} field must match ${confirmationLabel}.` },
|
|
959
962
|
];
|
|
960
|
-
/**
|
|
963
|
+
/** Email verification message. */
|
|
961
964
|
async function sendVerificationEmail(panel, req, user) {
|
|
962
965
|
await panel.sendMail({
|
|
963
966
|
to: user.email, subject: 'Verify Email Address',
|
|
@@ -972,7 +975,7 @@ const formContent = (submit, actions, options = {}) => [
|
|
|
972
975
|
.footer([Actions.make(actions).alignment(options.alignment ?? null).fullWidth(options.fullWidth ?? true).key('form-actions')]),
|
|
973
976
|
...(options.after ? [RenderHook.make(options.after)] : []),
|
|
974
977
|
];
|
|
975
|
-
/**
|
|
978
|
+
/** Registration page: name, email, confirmed password. The user is logged in, then asked to verify their email if the panel requires it. */
|
|
976
979
|
export class Register extends SimplePage {
|
|
977
980
|
static callable = ['register', 'callSchemaComponentMethod'];
|
|
978
981
|
data = {};
|
|
@@ -1003,9 +1006,9 @@ export class Register extends SimplePage {
|
|
|
1003
1006
|
contentSchema(schema) {
|
|
1004
1007
|
return schema.components(formContent('register', [Action.make('register').label(t('filament-panels::auth/pages/register.form.actions.register.label')).submit('register')], { before: PanelsRenderHook.AUTH_REGISTER_FORM_BEFORE, after: PanelsRenderHook.AUTH_REGISTER_FORM_AFTER }));
|
|
1005
1008
|
}
|
|
1006
|
-
/**
|
|
1009
|
+
/** Validated data, just before creation (add a value, normalize the email…). */
|
|
1007
1010
|
mutateFormDataBeforeRegister(data) { return data; }
|
|
1008
|
-
/**
|
|
1011
|
+
/** Creates the user; defaults to the auth provider's `create()`. */
|
|
1009
1012
|
handleRegistration(data) {
|
|
1010
1013
|
return this.panel.getAuth().create({ name: String(data.name), email: String(data.email), password: String(data.password) });
|
|
1011
1014
|
}
|
|
@@ -1029,7 +1032,7 @@ export class Register extends SimplePage {
|
|
|
1029
1032
|
this.redirect(panel.getUrl());
|
|
1030
1033
|
}
|
|
1031
1034
|
}
|
|
1032
|
-
/**
|
|
1035
|
+
/** Password reset request page: a signed link, valid for 60 minutes, is sent by email. */
|
|
1033
1036
|
export class RequestPasswordReset extends SimplePage {
|
|
1034
1037
|
static callable = ['request', 'callSchemaComponentMethod'];
|
|
1035
1038
|
data = {};
|
|
@@ -1074,14 +1077,14 @@ export class RequestPasswordReset extends SimplePage {
|
|
|
1074
1077
|
outro: ['This password reset link will expire in 60 minutes.', 'If you did not request a password reset, no further action is required.'],
|
|
1075
1078
|
});
|
|
1076
1079
|
}
|
|
1077
|
-
//
|
|
1078
|
-
//
|
|
1080
|
+
// Security: always answer the same way, whether or not an account exists, so the response
|
|
1081
|
+
// does not reveal which email addresses are registered.
|
|
1079
1082
|
Notification.make().success().title('We have emailed your password reset link.').body(t('filament-panels::auth/pages/password-reset/request-password-reset.notifications.sent.body')).send();
|
|
1080
1083
|
this.data = {};
|
|
1081
1084
|
fillSchema(form, this.data);
|
|
1082
1085
|
}
|
|
1083
1086
|
}
|
|
1084
|
-
/**
|
|
1087
|
+
/** Sets a new password from the emailed link. The link works once: it dies with the old password. */
|
|
1085
1088
|
export class ResetPassword extends SimplePage {
|
|
1086
1089
|
static callable = ['resetPassword', 'callSchemaComponentMethod'];
|
|
1087
1090
|
static locked = ['email', 'token'];
|
|
@@ -1097,7 +1100,7 @@ export class ResetPassword extends SimplePage {
|
|
|
1097
1100
|
this.token = typeof params.token === 'string' ? params.token : null;
|
|
1098
1101
|
this.email = typeof params.email === 'string' ? params.email : null;
|
|
1099
1102
|
}
|
|
1100
|
-
/**
|
|
1103
|
+
/** The form has no state path: its fields are page properties. */
|
|
1101
1104
|
getSchema(name) { return name === 'form' ? this.formSchema(Schema.make(this).key('form')) : super.getSchema(name); }
|
|
1102
1105
|
getTitle() { return t('filament-panels::auth/pages/password-reset/reset-password.title'); }
|
|
1103
1106
|
getHeading() { return t('filament-panels::auth/pages/password-reset/reset-password.heading'); }
|
|
@@ -1126,7 +1129,7 @@ export class ResetPassword extends SimplePage {
|
|
|
1126
1129
|
const auth = panel.getAuth();
|
|
1127
1130
|
const payload = panel.readResetPasswordToken(this.token);
|
|
1128
1131
|
const user = payload ? await auth.find(payload.uid) : null;
|
|
1129
|
-
//
|
|
1132
|
+
// The token identifies the user; the URL's email must be theirs and their password must not have changed since the link was sent.
|
|
1130
1133
|
if (!payload || !user || user.email !== this.email || (await auth.getPasswordFingerprint(user)) !== payload.fp) {
|
|
1131
1134
|
Notification.make().danger().title('This password reset token is invalid.').send();
|
|
1132
1135
|
return;
|
|
@@ -1136,7 +1139,7 @@ export class ResetPassword extends SimplePage {
|
|
|
1136
1139
|
this.redirect(panel.getLoginUrl());
|
|
1137
1140
|
}
|
|
1138
1141
|
}
|
|
1139
|
-
/**
|
|
1142
|
+
/** Email verification prompt, with a "Resend it." link. */
|
|
1140
1143
|
export class EmailVerificationPrompt extends SimplePage {
|
|
1141
1144
|
static callable = ['mountAction'];
|
|
1142
1145
|
async mount() {
|
|
@@ -1156,11 +1159,11 @@ export class EmailVerificationPrompt extends SimplePage {
|
|
|
1156
1159
|
text(`${e(t('filament-panels::auth/pages/email-verification/email-verification-prompt.messages.notification_not_received'))} ${link}`),
|
|
1157
1160
|
]);
|
|
1158
1161
|
}
|
|
1159
|
-
/**
|
|
1162
|
+
/** The page's only action: resend the email (at most twice per minute). */
|
|
1160
1163
|
async mountAction(name) {
|
|
1161
1164
|
const { req, panel, user } = this._ctx;
|
|
1162
1165
|
if (name !== 'resendNotification' || !user)
|
|
1163
|
-
throw new WireError(404, 'Action
|
|
1166
|
+
throw new WireError(404, 'Action not found.');
|
|
1164
1167
|
this.skipRender();
|
|
1165
1168
|
const wait = verificationThrottle.hit(`${panel.getId()}:verify:${user.id}`);
|
|
1166
1169
|
if (wait > 0) {
|
|
@@ -1172,7 +1175,7 @@ export class EmailVerificationPrompt extends SimplePage {
|
|
|
1172
1175
|
Notification.make().success().title(t('filament-panels::auth/pages/email-verification/email-verification-prompt.notifications.notification_resent.title')).send();
|
|
1173
1176
|
}
|
|
1174
1177
|
}
|
|
1175
|
-
/**
|
|
1178
|
+
/** User profile page: name, email, new password. The current password is required to change the email or password. */
|
|
1176
1179
|
export class EditProfile extends SimplePage {
|
|
1177
1180
|
static callable = ['save', 'callSchemaComponentMethod'];
|
|
1178
1181
|
static slug = 'profile';
|
|
@@ -1181,11 +1184,11 @@ export class EditProfile extends SimplePage {
|
|
|
1181
1184
|
async mount() {
|
|
1182
1185
|
const { user } = this._ctx;
|
|
1183
1186
|
if (!user)
|
|
1184
|
-
throw new WireError(401, '
|
|
1187
|
+
throw new WireError(401, 'Unauthenticated.');
|
|
1185
1188
|
this.data = { name: user.name, email: user.email, password: null, passwordConfirmation: null, currentPassword: null };
|
|
1186
1189
|
}
|
|
1187
1190
|
async boot() { if (!this._ctx?.user)
|
|
1188
|
-
throw new WireError(401, '
|
|
1191
|
+
throw new WireError(401, 'Unauthenticated.'); }
|
|
1189
1192
|
getTitle() { return this.constructor.getLabel(); }
|
|
1190
1193
|
hasLogo() { return false; }
|
|
1191
1194
|
formSchema(schema) {
|
|
@@ -1213,7 +1216,7 @@ export class EditProfile extends SimplePage {
|
|
|
1213
1216
|
async save() {
|
|
1214
1217
|
const { panel, user } = this._ctx;
|
|
1215
1218
|
if (!user)
|
|
1216
|
-
throw new WireError(401, '
|
|
1219
|
+
throw new WireError(401, 'Unauthenticated.');
|
|
1217
1220
|
const form = this.getSchema('form');
|
|
1218
1221
|
this._errors = await validateSchema(form);
|
|
1219
1222
|
if (Object.keys(this._errors).length) {
|
|
@@ -1227,18 +1230,18 @@ export class EditProfile extends SimplePage {
|
|
|
1227
1230
|
}
|
|
1228
1231
|
}
|
|
1229
1232
|
const blankValue = (value) => value === null || value === undefined || value === '';
|
|
1230
|
-
/**
|
|
1233
|
+
/** Tenant registration page: the subclass provides its label and form (`formSchema`). */
|
|
1231
1234
|
export class RegisterTenant extends SimplePage {
|
|
1232
1235
|
static callable = ['register', 'callSchemaComponentMethod'];
|
|
1233
1236
|
static slug = 'new';
|
|
1234
1237
|
data = {};
|
|
1235
1238
|
static getLabel() { return 'Register tenant'; }
|
|
1236
|
-
/**
|
|
1239
|
+
/** Whether tenants can be created; by default, if the tenant provider implements `create`. */
|
|
1237
1240
|
static canView(panel, _user) { return typeof panel.getTenantProvider().create === 'function'; }
|
|
1238
1241
|
async boot() {
|
|
1239
1242
|
const ctx = this._ctx;
|
|
1240
1243
|
if (!ctx?.user)
|
|
1241
|
-
throw new WireError(401, '
|
|
1244
|
+
throw new WireError(401, 'Unauthenticated.');
|
|
1242
1245
|
if (!(await this.constructor.canView(ctx.panel, ctx.user)))
|
|
1243
1246
|
throw new WireError(404, 'Not Found');
|
|
1244
1247
|
}
|
|
@@ -1266,7 +1269,7 @@ export class RegisterTenant extends SimplePage {
|
|
|
1266
1269
|
this.redirect(panel.getUrl('', tenant));
|
|
1267
1270
|
}
|
|
1268
1271
|
}
|
|
1269
|
-
/**
|
|
1272
|
+
/** Current tenant's profile page, in the full layout. */
|
|
1270
1273
|
export class EditTenantProfile extends Page {
|
|
1271
1274
|
static callable = [...Page.callable, 'save'];
|
|
1272
1275
|
static slug = 'profile';
|
|
@@ -1319,7 +1322,7 @@ export class Login extends SimplePage {
|
|
|
1319
1322
|
RenderHook.make(PanelsRenderHook.AUTH_LOGIN_FORM_BEFORE),
|
|
1320
1323
|
Form.make([EmbeddedSchema.make('form')]).id('form').livewireSubmitHandler('authenticate')
|
|
1321
1324
|
.footer([Actions.make([Action.make('authenticate').label(t('filament-panels::auth/pages/login.form.actions.authenticate.label')).submit('authenticate')]).fullWidth().key('form-actions')]),
|
|
1322
|
-
//
|
|
1325
|
+
// Multi-factor challenge form: hidden until MFA is implemented.
|
|
1323
1326
|
Form.make([]).id('multiFactorChallengeForm').hidden(),
|
|
1324
1327
|
RenderHook.make(PanelsRenderHook.AUTH_LOGIN_FORM_AFTER),
|
|
1325
1328
|
]);
|
|
@@ -1331,7 +1334,7 @@ export class Login extends SimplePage {
|
|
|
1331
1334
|
return;
|
|
1332
1335
|
}
|
|
1333
1336
|
const { req, res, panel } = this._ctx;
|
|
1334
|
-
//
|
|
1337
|
+
// Five attempts per minute per IP address, then wait.
|
|
1335
1338
|
const wait = loginThrottle.hit(`${panel.getId()}:${req.ip ?? ''}`);
|
|
1336
1339
|
if (wait > 0) {
|
|
1337
1340
|
Notification.make().danger().title(t('filament-actions::notifications.throttled.title', { seconds: wait, minutes: Math.ceil(wait / 60) })).body(t('filament-actions::notifications.throttled.body', { seconds: wait, minutes: Math.ceil(wait / 60) })).send();
|
|
@@ -1347,16 +1350,15 @@ export class Login extends SimplePage {
|
|
|
1347
1350
|
this.redirect(panel.getUrl());
|
|
1348
1351
|
}
|
|
1349
1352
|
}
|
|
1350
|
-
/**
|
|
1353
|
+
/** Whether the host component's user has this ability on the component's resource (or relation manager). Use it in your actions' `authorize()`. */
|
|
1351
1354
|
export const can = (livewire, ability, record = null) => canOn(livewire, ability, record);
|
|
1352
1355
|
function canOn(livewire, ability, record = null) {
|
|
1353
1356
|
const resource = livewire?.resource;
|
|
1354
1357
|
return typeof resource?.can === 'function' ? resource.can(ability, livewire._ctx?.user ?? null, record) : true;
|
|
1355
1358
|
}
|
|
1356
|
-
// ------------------------------------------------------------------ resources
|
|
1357
1359
|
const plural = (s) => (/[^aeiou]y$/.test(s) ? s.slice(0, -1) + 'ies' : /(s|x|z|ch|sh)$/.test(s) ? s + 'es' : s + 's');
|
|
1358
1360
|
const ucfirst = (s) => s.replace(/^./, (c) => c.toUpperCase());
|
|
1359
|
-
/** Resource
|
|
1361
|
+
/** Resource: a model with its pages, table and navigation. */
|
|
1360
1362
|
export class Resource {
|
|
1361
1363
|
static panel;
|
|
1362
1364
|
static slug = '';
|
|
@@ -1367,33 +1369,33 @@ export class Resource {
|
|
|
1367
1369
|
static navigationSort = 0;
|
|
1368
1370
|
static navigationBadge = null;
|
|
1369
1371
|
static adapter;
|
|
1370
|
-
/**
|
|
1372
|
+
/** In a multi-tenant panel, whether records belong to each tenant (`true` by default). */
|
|
1371
1373
|
static isScopedToTenant = true;
|
|
1372
|
-
/**
|
|
1374
|
+
/** Tenant column for this resource (defaults to the panel's: `panel.tenant(provider, { ownershipColumn })`). */
|
|
1373
1375
|
static tenantOwnershipColumn = null;
|
|
1374
1376
|
/**
|
|
1375
|
-
*
|
|
1377
|
+
* Resource pages; replace them with your subclasses to change their behavior:
|
|
1376
1378
|
* `static pages = { index: MyList, create: MyCreate, edit: MyEdit }`.
|
|
1377
1379
|
*/
|
|
1378
1380
|
static pages = {};
|
|
1379
|
-
/**
|
|
1381
|
+
/** `true` adds a view page (`ViewRecord`) to the resource; table rows then link to it. */
|
|
1380
1382
|
static hasViewPage = false;
|
|
1381
|
-
/**
|
|
1383
|
+
/** Who can do what on this resource. Without a policy, any signed-in user can do everything. */
|
|
1382
1384
|
static policy = null;
|
|
1383
|
-
/**
|
|
1385
|
+
/** Whether the user has this ability (on this record, if any). */
|
|
1384
1386
|
static can(ability, user, record = null) {
|
|
1385
1387
|
const rule = this.policy?.[ability];
|
|
1386
1388
|
return this.panel.authorize(user, ability, { resource: this, record, rule: rule ? () => rule(user, record) : null });
|
|
1387
1389
|
}
|
|
1388
|
-
/**
|
|
1390
|
+
/** Resource widgets, registered as components. */
|
|
1389
1391
|
static widgets = [];
|
|
1390
|
-
/** Widgets
|
|
1392
|
+
/** Widgets shown above the table on the list page. */
|
|
1391
1393
|
static headerWidgets = [];
|
|
1392
|
-
/** Relation managers
|
|
1394
|
+
/** Relation managers shown below the edit form. */
|
|
1393
1395
|
static relations = [];
|
|
1394
|
-
/**
|
|
1396
|
+
/** Tabs of the list page. */
|
|
1395
1397
|
static tabs() { return {}; }
|
|
1396
|
-
/**
|
|
1398
|
+
/** Simple resource: a single page, with create and edit in modals. */
|
|
1397
1399
|
static isSimple = false;
|
|
1398
1400
|
static getPluralModelLabel() { return this.pluralModelLabel ?? plural(this.modelLabel); }
|
|
1399
1401
|
static getTitleCasePluralModelLabel() { return this.getPluralModelLabel().split(' ').map(ucfirst).join(' '); }
|
|
@@ -1403,11 +1405,11 @@ export class Resource {
|
|
|
1403
1405
|
return `${base}/${record}/${page}`;
|
|
1404
1406
|
return page === 'index' ? base : page === 'create' ? `${base}/create` : page === 'view' ? `${base}/${record}` : `${base}/${record}/edit`;
|
|
1405
1407
|
}
|
|
1406
|
-
/**
|
|
1408
|
+
/** Related records pages: `{ comments: ManagePostComments }` is served at `<slug>/{record}/comments`. */
|
|
1407
1409
|
static relatedPages = {};
|
|
1408
|
-
/** Pages
|
|
1410
|
+
/** Pages listed in a record's sub-navigation, in order: `['view', 'edit', 'comments']`. Empty: no sub-navigation. */
|
|
1409
1411
|
static recordSubNavigation = [];
|
|
1410
|
-
/**
|
|
1412
|
+
/** Record sub-navigation: one tab per declared page, the current page's tab active. */
|
|
1411
1413
|
static getRecordSubNavigation(page) {
|
|
1412
1414
|
const pages = this.getPages();
|
|
1413
1415
|
const key = page.getRecordKey() ?? '';
|
|
@@ -1422,25 +1424,25 @@ export class Resource {
|
|
|
1422
1424
|
});
|
|
1423
1425
|
}
|
|
1424
1426
|
static _registered = new WeakMap();
|
|
1425
|
-
/**
|
|
1427
|
+
/** Page class actually registered under a name (pages are generated once per resource). */
|
|
1426
1428
|
static getRegisteredPage(name) { return this.getPages()[name]; }
|
|
1427
1429
|
static table(table) { return table; }
|
|
1428
1430
|
static form(schema) { return schema; }
|
|
1429
|
-
/**
|
|
1431
|
+
/** Schema of the view page. */
|
|
1430
1432
|
static infolist(schema) { return schema; }
|
|
1431
|
-
// --------------------------------------------------------------
|
|
1432
|
-
/**
|
|
1433
|
+
// -------------------------------------------------------------- global search
|
|
1434
|
+
/** Columns searched by global search; defaults to the title attribute. Empty array: the resource is not searchable. */
|
|
1433
1435
|
static globallySearchableAttributes = null;
|
|
1434
1436
|
static getGloballySearchableAttributes() { return this.globallySearchableAttributes ?? (this.recordTitleAttribute ? [this.recordTitleAttribute] : []); }
|
|
1435
1437
|
static canGloballySearch() { return this.getGloballySearchableAttributes().length > 0; }
|
|
1436
|
-
/**
|
|
1438
|
+
/** Position of this resource's group in the results; without one, after the others in registration order. */
|
|
1437
1439
|
static globalSearchSort = null;
|
|
1438
|
-
/**
|
|
1440
|
+
/** Maximum results rendered per resource. */
|
|
1439
1441
|
static globalSearchResultsLimit = 50;
|
|
1440
1442
|
static getGlobalSearchResultTitle(record) { return this.getRecordTitle(record); }
|
|
1441
|
-
/**
|
|
1443
|
+
/** Details shown under the title: `{ Author: 'Jane' }`. Empty values are skipped. */
|
|
1442
1444
|
static getGlobalSearchResultDetails(_record) { return {}; }
|
|
1443
|
-
/**
|
|
1445
|
+
/** URL of a result: the view page, otherwise the edit page; for a simple resource, the list with the action open. `null` if the user has no access. */
|
|
1444
1446
|
static async getGlobalSearchResultUrl(record, user) {
|
|
1445
1447
|
const key = this.adapter.getKey(record);
|
|
1446
1448
|
const canView = await this.can('view', user, record);
|
|
@@ -1467,11 +1469,11 @@ export class Resource {
|
|
|
1467
1469
|
}
|
|
1468
1470
|
return results;
|
|
1469
1471
|
}
|
|
1470
|
-
/**
|
|
1472
|
+
/** Attribute used as a record's title. */
|
|
1471
1473
|
static recordTitleAttribute = null;
|
|
1472
|
-
/**
|
|
1474
|
+
/** Without a title attribute: the model label in title case ("Leave Request"). */
|
|
1473
1475
|
static getRecordTitle(record) { return this.recordTitleAttribute ? String(record?.[this.recordTitleAttribute] ?? '') : this.modelLabel.split(' ').map(ucfirst).join(' '); }
|
|
1474
|
-
/**
|
|
1476
|
+
/** Generated resource pages: list, create, edit. */
|
|
1475
1477
|
static getPages() {
|
|
1476
1478
|
const known = Resource._registered.get(this);
|
|
1477
1479
|
if (known)
|
|
@@ -1522,90 +1524,90 @@ export class Resource {
|
|
|
1522
1524
|
return { index: List, create: Create, edit: Edit, view: View, ...related };
|
|
1523
1525
|
}
|
|
1524
1526
|
}
|
|
1525
|
-
/** Widget
|
|
1527
|
+
/** Widget: a component displayed in a page's widget grid. */
|
|
1526
1528
|
export class Widget extends Component {
|
|
1527
1529
|
static componentName;
|
|
1528
|
-
/**
|
|
1530
|
+
/** Width in the widget grid: a number of columns, or `'full'`. */
|
|
1529
1531
|
static columnSpan = 1;
|
|
1530
|
-
/**
|
|
1532
|
+
/** Loaded when it becomes visible. */
|
|
1531
1533
|
static isLazy = true;
|
|
1532
1534
|
get panel() { return this._ctx.panel; }
|
|
1533
|
-
/**
|
|
1535
|
+
/** Restricts the widget to some users: returning `false` removes it from the page and refuses to load it. */
|
|
1534
1536
|
static canView(_user) { return true; }
|
|
1535
1537
|
async boot() {
|
|
1536
1538
|
const ctx = this._ctx;
|
|
1537
1539
|
if (!ctx?.user)
|
|
1538
|
-
throw new WireError(401, '
|
|
1540
|
+
throw new WireError(401, 'Unauthenticated.');
|
|
1539
1541
|
const WidgetClass = this.constructor;
|
|
1540
1542
|
if (!(await ctx.panel.authorize(ctx.user, 'view', { widget: WidgetClass, rule: () => WidgetClass.canView(ctx.user) })))
|
|
1541
|
-
throw new WireError(403, '
|
|
1543
|
+
throw new WireError(403, 'Forbidden.');
|
|
1542
1544
|
}
|
|
1543
|
-
/**
|
|
1545
|
+
/** Grid column classes and style, at the `lg` breakpoint. */
|
|
1544
1546
|
gridColumn() {
|
|
1545
1547
|
const span = this.constructor.columnSpan;
|
|
1546
|
-
//
|
|
1548
|
+
// The span is always written, even for a single column.
|
|
1547
1549
|
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)}`};` };
|
|
1548
1550
|
}
|
|
1549
1551
|
placeholder() {
|
|
1550
1552
|
const column = this.gridColumn();
|
|
1551
1553
|
return `<div role="status" aria-busy="true" style="height: 8rem; ${column.style}" class="${cls(['fi-section', 'fi-loading-section', ...column.classes])}"><span class="fi-sr-only">${e(t('filament::components/loading-section.label'))}</span></div>`;
|
|
1552
1554
|
}
|
|
1553
|
-
/**
|
|
1555
|
+
/** Common widget wrapper. */
|
|
1554
1556
|
wrap(content, classes = []) {
|
|
1555
1557
|
const column = this.gridColumn();
|
|
1556
1558
|
return `<div${column.style ? ` style="${column.style}"` : ''} class="${cls(['fi-wi-widget', ...column.classes, ...classes])}">${content}</div>`;
|
|
1557
1559
|
}
|
|
1558
1560
|
}
|
|
1559
1561
|
/**
|
|
1560
|
-
*
|
|
1562
|
+
* Calendar widget: month, week and list views, server-rendered navigation, no JavaScript library.
|
|
1561
1563
|
*
|
|
1562
|
-
*
|
|
1564
|
+
* Minimal use: extend the class and return the events of the displayed period.
|
|
1563
1565
|
*
|
|
1564
1566
|
* class LeaveCalendar extends CalendarWidget {
|
|
1565
1567
|
* static heading = 'Leave';
|
|
1566
1568
|
* async getEvents({ start, end }) { return [{ id: 1, title: 'Ada — vacation', start: '2026-11-12', end: '2026-11-14', color: 'success' }]; }
|
|
1567
1569
|
* }
|
|
1568
1570
|
*
|
|
1569
|
-
*
|
|
1570
|
-
*
|
|
1571
|
-
*
|
|
1572
|
-
*
|
|
1571
|
+
* Everything else can be overridden method by method: available views, first day of the week, locale, labels, events
|
|
1572
|
+
* per cell, day clicks (`onDateClick`) and event clicks (`onEventClick`), event rendering (`renderEvent`), cell footer
|
|
1573
|
+
* (`renderDayFooter`), toolbar buttons (`renderToolbarEnd`), and appearance through the `.nv-cal` CSS variables
|
|
1574
|
+
* (`getExtraClasses`, `getStyle`).
|
|
1573
1575
|
*/
|
|
1574
1576
|
export class CalendarWidget extends Widget {
|
|
1575
1577
|
static columnSpan = 'full';
|
|
1576
|
-
/**
|
|
1578
|
+
/** Heading shown before the period ("Leave — November 2026"). */
|
|
1577
1579
|
static heading = null;
|
|
1578
1580
|
static callable = ['previousPeriod', 'nextPeriod', 'goToToday', 'setView', 'selectDate', 'selectEvent', 'showDay'];
|
|
1579
|
-
/**
|
|
1581
|
+
/** View and date only change through the methods below, which validate them. */
|
|
1580
1582
|
static locked = ['view', 'date'];
|
|
1581
1583
|
view = 'month';
|
|
1582
|
-
/**
|
|
1584
|
+
/** Anchor date (`YYYY-MM-DD`): the displayed month or week contains it. Empty: today. */
|
|
1583
1585
|
date = '';
|
|
1584
|
-
/**
|
|
1586
|
+
/** Available views; the first is the initial one. With a single view, the switcher is hidden. */
|
|
1585
1587
|
getViews() { return ['month', 'week', 'list']; }
|
|
1586
|
-
/** 0 =
|
|
1588
|
+
/** 0 = Sunday, 1 = Monday… */
|
|
1587
1589
|
getFirstDayOfWeek() { return 1; }
|
|
1588
|
-
/**
|
|
1590
|
+
/** Locale for month and day names (`Intl`). */
|
|
1589
1591
|
getLocale() { return 'en-US'; }
|
|
1590
1592
|
getLabels() { return CALENDAR_LABELS; }
|
|
1591
|
-
/**
|
|
1593
|
+
/** In month view, beyond this number the cell shows "+N more" (which opens the list on that day). */
|
|
1592
1594
|
getMaxEventsPerDay() { return 3; }
|
|
1593
1595
|
getHeading() { return this.constructor.heading; }
|
|
1594
|
-
/**
|
|
1596
|
+
/** Today's date, `YYYY-MM-DD` (UTC by default; override for another time zone). */
|
|
1595
1597
|
getToday() { return new Date().toISOString().slice(0, 10); }
|
|
1596
|
-
/**
|
|
1598
|
+
/** Click on a day number. Overriding this makes days clickable: open a page (`this.redirect`), notify, switch view… */
|
|
1597
1599
|
onDateClick(_date) { }
|
|
1598
|
-
/**
|
|
1600
|
+
/** Click on an event without a URL. The event is reloaded server-side, so `event.extra` is available. */
|
|
1599
1601
|
onEventClick(_event) { }
|
|
1600
|
-
/** HTML
|
|
1602
|
+
/** Already-escaped HTML for an event, or `null` for the default rendering (`renderCalendarEvent`). */
|
|
1601
1603
|
renderEvent(_event, _context) { return null; }
|
|
1602
|
-
/** HTML
|
|
1604
|
+
/** Already-escaped HTML appended to the bottom of each cell. */
|
|
1603
1605
|
renderDayFooter(_day, _events) { return ''; }
|
|
1604
|
-
/** HTML
|
|
1606
|
+
/** Already-escaped HTML at the end of the toolbar: a "New" link, a filter… */
|
|
1605
1607
|
renderToolbarEnd() { return ''; }
|
|
1606
|
-
/**
|
|
1608
|
+
/** Extra classes on the calendar, to hook your styles or override its CSS variables. */
|
|
1607
1609
|
getExtraClasses() { return []; }
|
|
1608
|
-
/**
|
|
1610
|
+
/** CSS variables set on the calendar: `{ '--nv-cal-day-min-height': '9rem' }`. */
|
|
1609
1611
|
getStyle() { return {}; }
|
|
1610
1612
|
// ---------------------------------------------------------- navigation
|
|
1611
1613
|
anchor() { return isDate(this.date) ? this.date : this.getToday(); }
|
|
@@ -1616,7 +1618,7 @@ export class CalendarWidget extends Widget {
|
|
|
1616
1618
|
goToToday() { this.date = this.getToday(); }
|
|
1617
1619
|
setView(view) { if (typeof view === 'string' && this.getViews().includes(view) && CALENDAR_VIEWS.includes(view))
|
|
1618
1620
|
this.view = view; }
|
|
1619
|
-
/**
|
|
1621
|
+
/** "+N more": the list view anchored on that day (or the week view if list is not available). */
|
|
1620
1622
|
showDay(date) {
|
|
1621
1623
|
if (!isDate(date))
|
|
1622
1624
|
return;
|
|
@@ -1628,7 +1630,7 @@ export class CalendarWidget extends Widget {
|
|
|
1628
1630
|
eventsClickable() { return this.onEventClick !== CalendarWidget.prototype.onEventClick; }
|
|
1629
1631
|
async selectDate(date) { if (isDate(date) && this.datesClickable())
|
|
1630
1632
|
await this.onDateClick(date); }
|
|
1631
|
-
/**
|
|
1633
|
+
/** The id comes from the browser: the event is looked up among those of the displayed period, never trusted as-is. */
|
|
1632
1634
|
async selectEvent(id) {
|
|
1633
1635
|
if ((typeof id !== 'string' && typeof id !== 'number') || !this.eventsClickable())
|
|
1634
1636
|
return;
|
|
@@ -1655,20 +1657,20 @@ export class CalendarWidget extends Widget {
|
|
|
1655
1657
|
}
|
|
1656
1658
|
}
|
|
1657
1659
|
/**
|
|
1658
|
-
*
|
|
1659
|
-
*
|
|
1660
|
+
* Calendar page: the same calendar as `CalendarWidget`, on a full page, with actions — so modals, forms,
|
|
1661
|
+
* confirmations and slide-overs.
|
|
1660
1662
|
*
|
|
1661
1663
|
* class Planning extends CalendarPage {
|
|
1662
1664
|
* static slug = 'planning';
|
|
1663
1665
|
* async getEvents({ start, end }) { return db('shifts').where('day', '>=', start).where('day', '<=', end)… }
|
|
1664
|
-
* //
|
|
1666
|
+
* // Day click: a modal with a form, prefilled with the date.
|
|
1665
1667
|
* dateAction() {
|
|
1666
1668
|
* return Action.make('createShift').modalHeading('New shift')
|
|
1667
1669
|
* .schema([Select.make('employee_id').options(…).required(), DatePicker.make('day').required()])
|
|
1668
1670
|
* .fillForm(({ arguments: args }) => ({ day: CalendarPage.dateOf(args) }))
|
|
1669
1671
|
* .action(async ({ data }) => { await db('shifts').insert(data); Notification.make().success().title('Saved').send(); });
|
|
1670
1672
|
* }
|
|
1671
|
-
* //
|
|
1673
|
+
* // Event click: another action; the event is reloaded server-side.
|
|
1672
1674
|
* eventAction() {
|
|
1673
1675
|
* return Action.make('editShift').schema([…])
|
|
1674
1676
|
* .fillForm(async ({ livewire, arguments: args }) => (await livewire.findEvent(args.event))?.extra ?? {})
|
|
@@ -1676,10 +1678,10 @@ export class CalendarWidget extends Widget {
|
|
|
1676
1678
|
* }
|
|
1677
1679
|
* }
|
|
1678
1680
|
*
|
|
1679
|
-
*
|
|
1680
|
-
*
|
|
1681
|
-
*
|
|
1682
|
-
* `getCalendarActions()`
|
|
1681
|
+
* Three ways to handle a click: a **URL** on the event (`url`: dedicated page), an **action** (modal),
|
|
1682
|
+
* or **custom code** by overriding `onDateClick()` / `onEventClick()` (redirect, notify, write to the database, mount any
|
|
1683
|
+
* action with `this.mountAction(name, arguments)`). `getHeaderActions()` adds buttons to the page header, and
|
|
1684
|
+
* `getCalendarActions()` declares other actions mountable from your own rendering (`renderEvent`, `renderToolbarEnd`).
|
|
1683
1685
|
*/
|
|
1684
1686
|
export class CalendarPage extends Page {
|
|
1685
1687
|
static callable = ['mountAction', 'unmountAction', 'callMountedAction', 'callSchemaComponentMethod', 'previousPeriod', 'nextPeriod', 'goToToday', 'setView', 'selectDate', 'selectEvent', 'showDay'];
|
|
@@ -1698,21 +1700,21 @@ export class CalendarPage extends Page {
|
|
|
1698
1700
|
renderToolbarEnd() { return ''; }
|
|
1699
1701
|
getExtraClasses() { return []; }
|
|
1700
1702
|
getStyle() { return {}; }
|
|
1701
|
-
/** Action
|
|
1703
|
+
/** Action opened on day click; it receives `arguments.date`. `null`: days are not clickable (unless `onDateClick` is overridden). */
|
|
1702
1704
|
dateAction() { return null; }
|
|
1703
|
-
/** Action
|
|
1705
|
+
/** Action opened on click on an event without a URL; it receives `arguments.event` (an id, to resolve with `findEvent`). */
|
|
1704
1706
|
eventAction() { return null; }
|
|
1705
|
-
/**
|
|
1707
|
+
/** Other actions mountable from your own HTML: `wire:click="mountAction('name', { … })"`. */
|
|
1706
1708
|
getCalendarActions() { return []; }
|
|
1707
|
-
/**
|
|
1709
|
+
/** Opens `dateAction()` by default. Override to do something else (e.g. redirect to a create page). */
|
|
1708
1710
|
async onDateClick(date) { const action = this.dateAction(); if (action)
|
|
1709
1711
|
await this.mountAction(action.getName(), { date }); }
|
|
1710
|
-
/**
|
|
1712
|
+
/** Opens `eventAction()` by default. The event has been resolved server-side before the call. */
|
|
1711
1713
|
async onEventClick(event) { const action = this.eventAction(); if (action)
|
|
1712
1714
|
await this.mountAction(action.getName(), { event: String(event.id) }); }
|
|
1713
|
-
/**
|
|
1715
|
+
/** Clicked date, read from an action's arguments. They come from the browser: only a valid date is returned. */
|
|
1714
1716
|
static dateOf(args) { return isDate(args?.date) ? args.date : null; }
|
|
1715
|
-
/**
|
|
1717
|
+
/** Event of the displayed period with this id, or `null`. Never act on a received id without going through this. */
|
|
1716
1718
|
async findEvent(id) {
|
|
1717
1719
|
if (typeof id !== 'string' && typeof id !== 'number')
|
|
1718
1720
|
return null;
|
|
@@ -1765,11 +1767,11 @@ export class CalendarPage extends Page {
|
|
|
1765
1767
|
return widgetSection(calendar.replace('<div class="nv-cal ', `<div${style ? ` style="${e(style)}"` : ''} class="nv-cal ${extra.length ? `${extra.join(' ')} ` : ''}`));
|
|
1766
1768
|
}
|
|
1767
1769
|
}
|
|
1768
|
-
/**
|
|
1770
|
+
/** Header-less section used by the built-in widgets. */
|
|
1769
1771
|
const widgetSection = (content) => `<section x-data="{
|
|
1770
1772
|
isCollapsed: false ,
|
|
1771
1773
|
}" class="fi-section"><div class="fi-section-content-ctn"><div class="fi-section-content">${content}</div></div></section>`;
|
|
1772
|
-
/**
|
|
1774
|
+
/** Signed-in user's account: avatar, name, logout. */
|
|
1773
1775
|
export class AccountWidget extends Widget {
|
|
1774
1776
|
static isLazy = false;
|
|
1775
1777
|
render() {
|
|
@@ -1779,7 +1781,7 @@ export class AccountWidget extends Widget {
|
|
|
1779
1781
|
return this.wrap(widgetSection(`<img alt="${e(t('filament-panels::layout.avatar.alt', { name: user.name }))}" class="fi-avatar fi-circular fi-size-lg fi-user-avatar" src="${e(panel.getUserAvatarUrl(user))}" loading="lazy" /><div class="fi-account-widget-main"><h2 class="fi-account-widget-heading">${e(t('filament-panels::widgets/account-widget.welcome'))}</h2><p class="fi-account-widget-user-name">${e(user.name)}</p></div><form action="${e(panel.getLogoutUrl())}" method="post" class="fi-account-widget-logout-form"><input type="hidden" name="_token" value="${e(csrfToken(req, res))}" autocomplete="off"><button class="fi-icon-btn fi-size-md" title="${e(logout)}" aria-label="${e(logout)}" type="submit" ${loading}>${icon('m-arrow-left-end-on-rectangle')}</button><button class="fi-btn fi-size-md fi-labeled-from-sm" type="submit" ${loading}>${icon('m-arrow-left-end-on-rectangle')}${e(logout)}</button></form>`), ['fi-account-widget']);
|
|
1780
1782
|
}
|
|
1781
1783
|
}
|
|
1782
|
-
/**
|
|
1784
|
+
/** Navette version and useful links. */
|
|
1783
1785
|
export class NavetteInfoWidget extends Widget {
|
|
1784
1786
|
static isLazy = false;
|
|
1785
1787
|
static homeUrl = 'https://github.com/NavetteJS';
|
|
@@ -1793,9 +1795,9 @@ export class NavetteInfoWidget extends Widget {
|
|
|
1793
1795
|
return this.wrap(widgetSection(`<div class="fi-filament-info-widget-main"><a href="${e(WidgetClass.homeUrl)}" rel="noopener noreferrer" target="_blank"><svg aria-label="Navette" fill="currentColor" role="img" viewBox="0 0 96 24" xmlns="http://www.w3.org/2000/svg" class="fi-filament-info-widget-logo"><text x="0" y="19" font-size="22" font-weight="700" letter-spacing="-0.5">Navette</text></svg></a><p class="fi-filament-info-widget-version">v${e(WidgetClass.version)}</p></div><div class="fi-filament-info-widget-links">${link(WidgetClass.documentationUrl, icon('m-book-open'), t('filament-panels::widgets/filament-info-widget.actions.open_documentation.label'))}${link(WidgetClass.githubUrl, github, t('filament-panels::widgets/filament-info-widget.actions.open_github.label'))}</div>`), ['fi-filament-info-widget']);
|
|
1794
1796
|
}
|
|
1795
1797
|
}
|
|
1796
|
-
/**
|
|
1798
|
+
/** Table properties a list page exposes to its widgets. */
|
|
1797
1799
|
const PAGE_TABLE_PROPERTIES = ['activeTab', 'paginators', 'tableColumnSearches', 'tableFilters', 'tableRecordsPerPage', 'tableSearch', 'tableSort'];
|
|
1798
|
-
/**
|
|
1800
|
+
/** One stat of a StatsOverviewWidget. */
|
|
1799
1801
|
export class Stat {
|
|
1800
1802
|
_description = null;
|
|
1801
1803
|
_descriptionIcon = null;
|
|
@@ -1810,10 +1812,10 @@ export class Stat {
|
|
|
1810
1812
|
static make(label, value) { return new Stat(label, value); }
|
|
1811
1813
|
description(text) { this._description = text; return this; }
|
|
1812
1814
|
descriptionIcon(name, position = 'after') { this._descriptionIcon = name; this._descriptionIconPosition = position; return this; }
|
|
1813
|
-
/**
|
|
1815
|
+
/** Color of the description and chart. */
|
|
1814
1816
|
color(color) { this._color = color; return this; }
|
|
1815
1817
|
icon(name) { this._icon = name; return this; }
|
|
1816
|
-
/**
|
|
1818
|
+
/** Trend chart: a list of values, or values keyed by label. */
|
|
1817
1819
|
chart(values) { this._chart = values; return this; }
|
|
1818
1820
|
url(url) { this._url = url; return this; }
|
|
1819
1821
|
hasChart() { return this._chart !== null; }
|
|
@@ -1835,18 +1837,18 @@ export class Stat {
|
|
|
1835
1837
|
return `<${tag}${this._url ? ` href="${e(this._url)}"` : ''} class="fi-wi-stats-overview-stat"><div class="fi-wi-stats-overview-stat-content"><div class="fi-wi-stats-overview-stat-label-ctn">${this._icon ? icon(this._icon) : ''}<span class="fi-wi-stats-overview-stat-label">${e(this.label)}</span></div>${filled ? `<div class="fi-wi-stats-overview-stat-value">${e(String(this.value))}</div>` : '<div class="fi-wi-stats-overview-stat-placeholder"></div>'}${description}</div>${chart}</${tag}>`;
|
|
1836
1838
|
}
|
|
1837
1839
|
}
|
|
1838
|
-
/**
|
|
1840
|
+
/** A row of stats. */
|
|
1839
1841
|
export class StatsOverviewWidget extends Widget {
|
|
1840
1842
|
static columnSpan = 'full';
|
|
1841
|
-
/**
|
|
1843
|
+
/** Polling interval, or `null` to disable polling. */
|
|
1842
1844
|
static pollingInterval = '5s';
|
|
1843
|
-
/**
|
|
1845
|
+
/** Row columns: three, or four when the number of stats fits better. */
|
|
1844
1846
|
getColumns(count) { return count < 3 ? 3 : count % 3 !== 1 ? 3 : 4; }
|
|
1845
1847
|
async render() {
|
|
1846
1848
|
const stats = await this.getStats();
|
|
1847
1849
|
const columns = this.getColumns(stats.length);
|
|
1848
1850
|
const anonymous = `x-data="filamentSchemaComponent(${e(js({ key: null, exposedMethods: [], path: '', containerPath: '' }))})"`;
|
|
1849
|
-
//
|
|
1851
|
+
// A stat with a chart gets an id (`stats-overview-stat-<n>`), hence a component key; the others stay anonymous.
|
|
1850
1852
|
const cells = stats.map((stat, index) => {
|
|
1851
1853
|
if (!stat.hasChart())
|
|
1852
1854
|
return `<div ${anonymous} class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component">${stat.toHtml(this.panel, `${this.id}.stats.${index}`)}</div></div>`;
|
|
@@ -1861,41 +1863,41 @@ export class StatsOverviewWidget extends Widget {
|
|
|
1861
1863
|
}
|
|
1862
1864
|
}
|
|
1863
1865
|
/**
|
|
1864
|
-
*
|
|
1865
|
-
* `'
|
|
1866
|
-
*
|
|
1866
|
+
* Chart.js chart. A subclass provides `getType()` (`'line'`, `'bar'`, `'pie'`, `'doughnut'`, `'radar'`, `'polarArea'`,
|
|
1867
|
+
* `'scatter'`, `'bubble'`) and `getData()`. Rendering happens in the browser; the server only sends new data when it
|
|
1868
|
+
* has changed (checksum).
|
|
1867
1869
|
*/
|
|
1868
1870
|
export class ChartWidget extends Widget {
|
|
1869
1871
|
static callable = ['updateChartData'];
|
|
1870
1872
|
static locked = ['dataChecksum'];
|
|
1871
|
-
/**
|
|
1873
|
+
/** Polling interval, or `null` to disable polling. */
|
|
1872
1874
|
static pollingInterval = '5s';
|
|
1873
1875
|
static heading = null;
|
|
1874
1876
|
static description = null;
|
|
1875
|
-
/**
|
|
1877
|
+
/** Color of datasets that do not set one. */
|
|
1876
1878
|
static color = 'primary';
|
|
1877
|
-
/**
|
|
1879
|
+
/** Maximum chart height (`'300px'`). */
|
|
1878
1880
|
static maxHeight = null;
|
|
1879
1881
|
static isCollapsible = false;
|
|
1880
1882
|
dataChecksum = null;
|
|
1881
|
-
/**
|
|
1883
|
+
/** Selected filter, one of `getFilters()`. */
|
|
1882
1884
|
filter = null;
|
|
1883
1885
|
_cachedData = null;
|
|
1884
1886
|
getData() { return {}; }
|
|
1885
|
-
/**
|
|
1887
|
+
/** Chart.js options. */
|
|
1886
1888
|
getOptions() { return null; }
|
|
1887
|
-
/**
|
|
1889
|
+
/** Filters offered in the header: value → label. */
|
|
1888
1890
|
getFilters() { return null; }
|
|
1889
1891
|
getHeading() { return this.constructor.heading; }
|
|
1890
1892
|
getDescription() { return this.constructor.description; }
|
|
1891
1893
|
getColor() { return this.constructor.color; }
|
|
1892
1894
|
getMaxHeight() { return this.constructor.maxHeight; }
|
|
1893
|
-
/**
|
|
1895
|
+
/** Text read by screen readers instead of the chart. */
|
|
1894
1896
|
getChartAssistiveContent() { return null; }
|
|
1895
1897
|
async getCachedData() { return (this._cachedData ??= await this.getData()); }
|
|
1896
1898
|
async generateDataChecksum() { return createHash('md5').update(JSON.stringify(await this.getCachedData())).digest('hex'); }
|
|
1897
1899
|
async mount() { this.dataChecksum = await this.generateDataChecksum(); }
|
|
1898
|
-
/**
|
|
1900
|
+
/** Called by polling and before each render: only emits data when it has changed. */
|
|
1899
1901
|
async updateChartData() {
|
|
1900
1902
|
const checksum = await this.generateDataChecksum();
|
|
1901
1903
|
if (checksum === this.dataChecksum)
|
|
@@ -1940,13 +1942,13 @@ export class ChartWidget extends Widget {
|
|
|
1940
1942
|
}
|
|
1941
1943
|
}
|
|
1942
1944
|
/**
|
|
1943
|
-
* Table
|
|
1944
|
-
*
|
|
1945
|
+
* Table in a widget: search, sort, simple pagination (previous / next).
|
|
1946
|
+
* A subclass provides `table(table)` and `getDataAdapterForTable()`. No actions or filters yet.
|
|
1945
1947
|
*/
|
|
1946
1948
|
export class TableWidget extends Widget {
|
|
1947
1949
|
static columnSpan = 'full';
|
|
1948
1950
|
static callable = ['sortTable', 'gotoPage', 'nextPage', 'previousPage', 'applyTableColumnManager', 'resetTableColumnManager'];
|
|
1949
|
-
/**
|
|
1951
|
+
/** Table heading; derived from the class name by default (`FlaggedOrders` → "Flagged Orders"). */
|
|
1950
1952
|
static heading = null;
|
|
1951
1953
|
tableSearch = '';
|
|
1952
1954
|
tableSort = null;
|
|
@@ -1984,14 +1986,14 @@ export class TableWidget extends Widget {
|
|
|
1984
1986
|
})" style="height: 0"></div>` })}${renderHook(WidgetsRenderHook.TABLE_WIDGET_END, scopes)}`, ['fi-wi-table']);
|
|
1985
1987
|
}
|
|
1986
1988
|
}
|
|
1987
|
-
/**
|
|
1989
|
+
/** For widgets that receive the dashboard filters: `static reactive = PAGE_FILTERS` plus a `pageFilters` property. */
|
|
1988
1990
|
export const PAGE_FILTERS = ['pageFilters'];
|
|
1989
1991
|
/**
|
|
1990
|
-
*
|
|
1991
|
-
*
|
|
1992
|
+
* Stats widget bound to a list page's table: it receives the table state (search, filters, tab…)
|
|
1993
|
+
* and can aggregate exactly the records the table displays.
|
|
1992
1994
|
*/
|
|
1993
1995
|
export class PageTableStatsWidget extends StatsOverviewWidget {
|
|
1994
|
-
/**
|
|
1996
|
+
/** List page whose table is observed. */
|
|
1995
1997
|
static tablePage;
|
|
1996
1998
|
static reactive = PAGE_TABLE_PROPERTIES;
|
|
1997
1999
|
activeTab = null;
|
|
@@ -2001,19 +2003,19 @@ export class PageTableStatsWidget extends StatsOverviewWidget {
|
|
|
2001
2003
|
tableRecordsPerPage = 10;
|
|
2002
2004
|
tableSearch = '';
|
|
2003
2005
|
tableSort = null;
|
|
2004
|
-
/**
|
|
2006
|
+
/** Aggregates over the table's current query. The received state goes through the same validation as on the page. */
|
|
2005
2007
|
async aggregatePageTable(columns = {}, modify = null) {
|
|
2006
2008
|
const PageClass = this.constructor.tablePage();
|
|
2007
2009
|
const page = new PageClass();
|
|
2008
2010
|
for (const property of PAGE_TABLE_PROPERTIES)
|
|
2009
2011
|
page[property] = this[property];
|
|
2010
2012
|
const query = getTableQuery(page.getTable(), page);
|
|
2011
|
-
//
|
|
2013
|
+
// Extra restriction for one stat ("among the displayed rows, those that…").
|
|
2012
2014
|
if (modify)
|
|
2013
2015
|
(query.modifiers ??= []).push(modify);
|
|
2014
2016
|
return PageClass.resource.adapter.aggregate(query, columns);
|
|
2015
2017
|
}
|
|
2016
|
-
/**
|
|
2018
|
+
/** Monthly trend over the table's current query (see `ObjectionAdapter.trendPerMonth`). */
|
|
2017
2019
|
async trendPageTable(options) {
|
|
2018
2020
|
const PageClass = this.constructor.tablePage();
|
|
2019
2021
|
const page = new PageClass();
|
|
@@ -2022,12 +2024,12 @@ export class PageTableStatsWidget extends StatsOverviewWidget {
|
|
|
2022
2024
|
return PageClass.resource.adapter.trendPerMonth(getTableQuery(page.getTable(), page), options);
|
|
2023
2025
|
}
|
|
2024
2026
|
}
|
|
2025
|
-
/**
|
|
2027
|
+
/** Schema component that embeds the host component's table. */
|
|
2026
2028
|
export class EmbeddedTable extends SchemaComponent {
|
|
2027
2029
|
static make() { return new EmbeddedTable(); }
|
|
2028
2030
|
async toHtml() { return this.livewire.renderTable(); }
|
|
2029
2031
|
}
|
|
2030
|
-
/**
|
|
2032
|
+
/** Relation manager tabs of an edit page: only the active tab's manager is mounted, as a lazy child component. */
|
|
2031
2033
|
class RelationManagerTabs extends SchemaComponent {
|
|
2032
2034
|
constructor() { super(); this._keyName = 'relationManagerTabs'; }
|
|
2033
2035
|
getExposedMethods() { return ['getLazyLoadedTabBadges', 'getDeferredTabBadges']; }
|
|
@@ -2045,8 +2047,9 @@ class RelationManagerTabs extends SchemaComponent {
|
|
|
2045
2047
|
for (const [index, manager] of managers.entries()) {
|
|
2046
2048
|
let content = '';
|
|
2047
2049
|
if (index === active) {
|
|
2048
|
-
const
|
|
2049
|
-
const
|
|
2050
|
+
const componentName = page._runtime.nameOf(manager);
|
|
2051
|
+
const childKey = `${key}.${index}.${componentName}`;
|
|
2052
|
+
const child = await page.mountChild(componentName, { ownerRecord: page.recordKey }, `${id}.${childKey}`, { lazy: true });
|
|
2050
2053
|
content = `<div class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));"><div wire:partial="schema-component::${e(childKey)}" x-data="filamentSchemaComponent(${e(js({ key: childKey, exposedMethods: [], path: '', containerPath: '' }))})" wire:key="${e(`${id}.${childKey}`)}" class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component">${child}</div></div></div>`;
|
|
2051
2054
|
}
|
|
2052
2055
|
panels.push(`<div aria-labelledby="${tabId(index)}" id="${key}.${index}" role="tabpanel" tabindex="0" wire:key="${id}.${key}.${index}.container" x-show="true" x-bind:class="{ 'fi-active': isTabSelected('${index}') }" x-on:expand="revealTab('${index}')" x-data="filamentSchemaComponent(${e(js({ key: `${key}.${index}`, exposedMethods: [], path: '', containerPath: '' }))})" class="fi-sc-tabs-tab">${content}</div>`);
|
|
@@ -2062,7 +2065,7 @@ class RelationManagerTabs extends SchemaComponent {
|
|
|
2062
2065
|
})" x-load x-load-src="${e(page.panel.assetUrl('js/filament/schemas/components/tabs.js'))}" wire:ignore.self id="${key}" wire:key="${id}.${key}.container" class="fi-sc-tabs"><input type="hidden" x-ref="tabsData" data-active-tab="${active}" /><div x-ref="tabsHeader" class="fi-tabs"><div x-ref="tablist" role="tablist" x-bind:role="hasVisibleTabHeaders ? 'tablist' : 'group'" aria-label="${e(t('filament::components/tabs.label'))}" x-on:keydown="handleKeydown($event)" data-prevent-disabled-clicks wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" wire:target="activeRelationManager" class="fi-sc-tabs-tablist">${tabs.join('')}</div></div>${panels.join('')}</div>`;
|
|
2063
2066
|
}
|
|
2064
2067
|
}
|
|
2065
|
-
/**
|
|
2068
|
+
/** List page tab: restricts the table query and can show a badge. */
|
|
2066
2069
|
export class Tab {
|
|
2067
2070
|
_label;
|
|
2068
2071
|
_badge = null;
|
|
@@ -2071,7 +2074,7 @@ export class Tab {
|
|
|
2071
2074
|
_query = null;
|
|
2072
2075
|
constructor(label = null) { this._label = label; }
|
|
2073
2076
|
static make(label = null) { return new Tab(label); }
|
|
2074
|
-
/**
|
|
2077
|
+
/** Badge computed after the page is displayed. */
|
|
2075
2078
|
badge(resolver) { this._badge = resolver; return this; }
|
|
2076
2079
|
badgeColor(color) { this._badgeColor = color; this._hasBadgeColor = true; return this; }
|
|
2077
2080
|
query(modifier) { this._query = modifier; return this; }
|
|
@@ -2079,16 +2082,16 @@ export class Tab {
|
|
|
2079
2082
|
hasBadge() { return this._badge !== null; }
|
|
2080
2083
|
async getBadge() { return this._badge ? this._badge() : null; }
|
|
2081
2084
|
getBadgeColor() { return this._badgeColor; }
|
|
2082
|
-
/**
|
|
2085
|
+
/** False until `badgeColor()` is called: a lazy-loaded badge then gets no color class. */
|
|
2083
2086
|
hasBadgeColor() { return this._hasBadgeColor; }
|
|
2084
2087
|
getQuery() { return this._query; }
|
|
2085
2088
|
}
|
|
2086
|
-
/**
|
|
2089
|
+
/** List page tabs: hidden when the page declares none. */
|
|
2087
2090
|
class ResourceTabs extends SchemaComponent {
|
|
2088
2091
|
constructor() { super(); this._keyName = 'resourceTabs'; this._hidden = ({ livewire }) => Object.keys(livewire.getTabs()).length === 0; }
|
|
2089
2092
|
getExposedMethods() { return ['getLazyLoadedTabBadges', 'getDeferredTabBadges']; }
|
|
2090
2093
|
getDeferredTabBadges() { return {}; }
|
|
2091
|
-
/**
|
|
2094
|
+
/** Tab badges, requested by the browser once the page is displayed. */
|
|
2092
2095
|
async getLazyLoadedTabBadges() {
|
|
2093
2096
|
const page = this.livewire;
|
|
2094
2097
|
const badges = {};
|
|
@@ -2153,7 +2156,7 @@ class ResourceTabs extends SchemaComponent {
|
|
|
2153
2156
|
}" x-ref="tabsHeader" class="fi-tabs"><div x-ref="tablist" role="group" aria-label="${e(t('filament-panels::resources/pages/list-records.tabs.label'))}" x-on:keydown="handleKeydown($event)" data-prevent-disabled-clicks wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" wire:target="activeTab" class="fi-sc-tabs-tablist">${buttons.join('')}</div></div></div>`;
|
|
2154
2157
|
}
|
|
2155
2158
|
}
|
|
2156
|
-
/**
|
|
2159
|
+
/** Resource list page: table state and methods called by the browser. */
|
|
2157
2160
|
export class ListRecords extends Page {
|
|
2158
2161
|
static callable = ['updateTableColumnState', 'sortTable', 'gotoPage', 'nextPage', 'previousPage', 'applyTableFilters', 'resetTableFiltersForm', 'removeTableFilters', 'removeTableFilter', 'applyTableColumnManager', 'resetTableColumnManager', 'toggleTableReordering', 'reorderTable'];
|
|
2159
2162
|
static resource;
|
|
@@ -2162,24 +2165,24 @@ export class ListRecords extends Page {
|
|
|
2162
2165
|
tableColumnSearches = {};
|
|
2163
2166
|
tableRecordsPerPage = 10;
|
|
2164
2167
|
paginators = { page: 1 };
|
|
2165
|
-
//
|
|
2168
|
+
// Selection: kept by the browser, sent along with the bulk action call.
|
|
2166
2169
|
selectedTableRecords = [];
|
|
2167
2170
|
deselectedTableRecords = [];
|
|
2168
2171
|
isTrackingDeselectedTableRecords = false;
|
|
2169
|
-
//
|
|
2172
|
+
// Filters: `tableDeferredFilters` is the form state, `tableFilters` the applied state (deferred filters by default).
|
|
2170
2173
|
tableFilters = {};
|
|
2171
2174
|
tableDeferredFilters = {};
|
|
2172
2175
|
tableColumns = [];
|
|
2173
2176
|
isTableReordering = false;
|
|
2174
|
-
/**
|
|
2177
|
+
/** Current table grouping (`id:direction`), chosen in the toolbar. */
|
|
2175
2178
|
tableGrouping = null;
|
|
2176
2179
|
updatedTableGrouping() { this.paginators.page = 1; }
|
|
2177
|
-
/**
|
|
2180
|
+
/** Toggles manual reordering mode. */
|
|
2178
2181
|
async toggleTableReordering() { if (this.getTable().getReorderColumn() !== null && (await canOn(this, 'reorder'))) {
|
|
2179
2182
|
this.isTableReordering = !this.isTableReordering;
|
|
2180
2183
|
this.deselectAllTableRecords();
|
|
2181
2184
|
} }
|
|
2182
|
-
/**
|
|
2185
|
+
/** Saves the new order: only keys belonging to the table are kept, each gets its position. */
|
|
2183
2186
|
async reorderTable(order, _draggedRecordKey = null) {
|
|
2184
2187
|
const column = this.getTable().getReorderColumn();
|
|
2185
2188
|
if (column === null || !this.isTableReordering || !Array.isArray(order) || order.length > REORDER_LIMIT || !(await canOn(this, 'reorder')))
|
|
@@ -2196,9 +2199,9 @@ export class ListRecords extends Page {
|
|
|
2196
2199
|
this.tableFilters = table.getDefaultFilterState();
|
|
2197
2200
|
this.tableDeferredFilters = table.getDefaultFilterState();
|
|
2198
2201
|
this.tableColumns = table.getDefaultColumnState();
|
|
2199
|
-
//
|
|
2202
|
+
// Preselect the default group.
|
|
2200
2203
|
initializeTableGrouping(table, this);
|
|
2201
|
-
// `?tableAction=view&tableActionRecord=12` (
|
|
2204
|
+
// `?tableAction=view&tableActionRecord=12` (global search result link): the row action opens on arrival, with the usual checks.
|
|
2202
2205
|
const query = (this._ctx?.req?.query ?? {});
|
|
2203
2206
|
if (typeof query.tableAction === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(query.tableAction) && typeof query.tableActionRecord === 'string' && query.tableActionRecord.length <= 64) {
|
|
2204
2207
|
try {
|
|
@@ -2210,13 +2213,13 @@ export class ListRecords extends Page {
|
|
|
2210
2213
|
}
|
|
2211
2214
|
}
|
|
2212
2215
|
}
|
|
2213
|
-
/**
|
|
2216
|
+
/** Filters form: one group per filter, bound to `tableDeferredFilters.<filter>`. */
|
|
2214
2217
|
tableFiltersFormSchema(schema) {
|
|
2215
2218
|
const table = this.getTable();
|
|
2216
|
-
//
|
|
2219
|
+
// Above the content, filters spread over several columns; in the dropdown, a single one.
|
|
2217
2220
|
const columns = table.getFiltersLayout() === 'above-content-collapsible' ? { sm: 2, lg: 3, xl: 4, '2xl': 5 } : 1;
|
|
2218
2221
|
return schema.statePath('tableDeferredFilters').columns(columns).partiallyRendered().components(table.getFilters().map((filter) => {
|
|
2219
|
-
//
|
|
2222
|
+
// A filter may provide its own form (query builder); otherwise it is a select.
|
|
2220
2223
|
const custom = filter.getFormComponents?.();
|
|
2221
2224
|
if (custom)
|
|
2222
2225
|
return StatefulGroup.make(filter.name, custom).columnSpan(filter.getColumnSpan?.() ?? { default: 1, lg: 1 });
|
|
@@ -2225,7 +2228,7 @@ export class ListRecords extends Page {
|
|
|
2225
2228
|
}));
|
|
2226
2229
|
}
|
|
2227
2230
|
applyTableFilters() {
|
|
2228
|
-
//
|
|
2231
|
+
// Only filters declared by the table reach the applied state: each sanitizes its own state, or else only its known keys are kept.
|
|
2229
2232
|
this.tableFilters = Object.fromEntries(this.getTable().getFilters().map((filter) => {
|
|
2230
2233
|
const deferred = this.tableDeferredFilters?.[filter.name];
|
|
2231
2234
|
if (filter.sanitizeState)
|
|
@@ -2234,7 +2237,7 @@ export class ListRecords extends Page {
|
|
|
2234
2237
|
}));
|
|
2235
2238
|
this.paginators.page = 1;
|
|
2236
2239
|
}
|
|
2237
|
-
/**
|
|
2240
|
+
/** Removes an active filter indicator: the field is cleared from both the applied state and the form. */
|
|
2238
2241
|
removeTableFilter(filterName, field = null) {
|
|
2239
2242
|
const filter = this.getTable().getFilters().find((candidate) => candidate.name === filterName);
|
|
2240
2243
|
if (!filter)
|
|
@@ -2245,7 +2248,7 @@ export class ListRecords extends Page {
|
|
|
2245
2248
|
this.tableDeferredFilters = { ...this.tableDeferredFilters, [filter.name]: JSON.parse(JSON.stringify(next)) };
|
|
2246
2249
|
this.paginators.page = 1;
|
|
2247
2250
|
}
|
|
2248
|
-
/**
|
|
2251
|
+
/** Live field in the filters form (e.g. a rule's operator changed): runs its `afterStateUpdated`. */
|
|
2249
2252
|
async updatedTableDeferredFilters(_value, key) {
|
|
2250
2253
|
if (!key)
|
|
2251
2254
|
return;
|
|
@@ -2274,10 +2277,10 @@ export class ListRecords extends Page {
|
|
|
2274
2277
|
applyTableColumnManager(state = null, _wasReordered = false) { if (state)
|
|
2275
2278
|
this.tableColumns = sanitizeColumnState(this.getTable(), state); }
|
|
2276
2279
|
resetTableColumnManager() { this.tableColumns = this.getTable().getDefaultColumnState(); }
|
|
2277
|
-
// ---
|
|
2280
|
+
// --- list tabs: the empty key is the default tab (`null` in the browser)
|
|
2278
2281
|
activeTab = null;
|
|
2279
2282
|
getTabs() { return this.resource.tabs?.() ?? {}; }
|
|
2280
|
-
/**
|
|
2283
|
+
/** Active tab, coerced to a declared key (an unknown value from the browser means the default tab). */
|
|
2281
2284
|
getActiveTabKey() {
|
|
2282
2285
|
const tabs = this.getTabs();
|
|
2283
2286
|
const key = this.activeTab ?? '';
|
|
@@ -2292,11 +2295,11 @@ export class ListRecords extends Page {
|
|
|
2292
2295
|
}
|
|
2293
2296
|
get resource() { return this.constructor.resource; }
|
|
2294
2297
|
getHeaderWidgets() { return this.resource.headerWidgets ?? []; }
|
|
2295
|
-
/**
|
|
2298
|
+
/** The table state is passed to widgets, which can rerun the same query. */
|
|
2296
2299
|
getWidgetData() { return Object.fromEntries(PAGE_TABLE_PROPERTIES.map((property) => [property, this[property]])); }
|
|
2297
2300
|
rendersActionModalsInPage() { return false; }
|
|
2298
2301
|
getBulkActions() { return this.getTable().getBulkActions() ?? [DeleteBulkAction.make()]; }
|
|
2299
|
-
/**
|
|
2302
|
+
/** Row actions declared by the table, with groups flattened. */
|
|
2300
2303
|
getRecordActions() { return this.getTable().getRecordActions().flatMap((a) => (a instanceof ActionGroup ? a.getActions() : [a])); }
|
|
2301
2304
|
getMountableActions(context) {
|
|
2302
2305
|
if (typeof context.schemaComponent === 'string')
|
|
@@ -2309,7 +2312,7 @@ export class ListRecords extends Page {
|
|
|
2309
2312
|
return this.getTable().getHeaderActions();
|
|
2310
2313
|
return this.getHeaderActions();
|
|
2311
2314
|
}
|
|
2312
|
-
/**
|
|
2315
|
+
/** Only known context shapes are kept; the record key is checked again in the database on mount. */
|
|
2313
2316
|
resolveMountContext(clientContext) {
|
|
2314
2317
|
const c = clientContext && typeof clientContext === 'object' ? clientContext : {};
|
|
2315
2318
|
if (typeof c.schemaComponent === 'string' && c.schemaComponent.length <= 200)
|
|
@@ -2326,17 +2329,17 @@ export class ListRecords extends Page {
|
|
|
2326
2329
|
return typeof context.recordKey === 'string' ? ((await this.resource.adapter.find(context.recordKey)) ?? null) : null;
|
|
2327
2330
|
}
|
|
2328
2331
|
/**
|
|
2329
|
-
*
|
|
2330
|
-
*
|
|
2332
|
+
* Writes an editable cell. Everything comes from the browser: the column must exist and be editable and visible, the record
|
|
2333
|
+
* must be in the table's scope and editable by the user, and the value must be valid.
|
|
2331
2334
|
*/
|
|
2332
2335
|
async updateTableColumnState(columnName, recordKey, input) {
|
|
2333
|
-
//
|
|
2336
|
+
// All checks (visible and editable column, scope, disabled state, authorization, validation, hooks) live in the tables package.
|
|
2334
2337
|
return updateEditableColumnState(this.getTable(), this, this.getDataAdapterForTable(), columnName, recordKey, input, { authorize: (record) => canOn(this, 'update', record) });
|
|
2335
2338
|
}
|
|
2336
|
-
/**
|
|
2339
|
+
/** Selected keys: the sent list, or, in "select all" mode, every record of the current query except deselected ones. */
|
|
2337
2340
|
async getSelectedRecordKeys() {
|
|
2338
2341
|
const clean = (list) => (Array.isArray(list) ? list.filter((k) => typeof k === 'string' || typeof k === 'number').map(String) : []);
|
|
2339
|
-
//
|
|
2342
|
+
// The selection comes from the browser: it is narrowed to records this table can actually display.
|
|
2340
2343
|
if (!this.isTrackingDeselectedTableRecords)
|
|
2341
2344
|
return this.resource.adapter.keysInScope(clean(this.selectedTableRecords));
|
|
2342
2345
|
const excluded = new Set(clean(this.deselectedTableRecords));
|
|
@@ -2351,7 +2354,7 @@ export class ListRecords extends Page {
|
|
|
2351
2354
|
getTable() {
|
|
2352
2355
|
const resource = this.resource;
|
|
2353
2356
|
const table = new Table().pluralModelLabel(resource.getPluralModelLabel());
|
|
2354
|
-
//
|
|
2357
|
+
// Simple resource: the row opens the edit modal; otherwise it links to the edit page.
|
|
2355
2358
|
return resource.table(resource.isSimple ? table.recordAction('edit') : table.recordUrl((record) => resource.getUrl(resource.hasViewPage ? 'view' : 'edit', resource.adapter.getKey(record))));
|
|
2356
2359
|
}
|
|
2357
2360
|
async renderTable() {
|
|
@@ -2382,7 +2385,7 @@ export class ListRecords extends Page {
|
|
|
2382
2385
|
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')}` }],
|
|
2383
2386
|
});
|
|
2384
2387
|
}
|
|
2385
|
-
/**
|
|
2388
|
+
/** Table header actions (none on a list page: they go in the page header). */
|
|
2386
2389
|
async renderTableHeaderActions(ctx) {
|
|
2387
2390
|
const html = [];
|
|
2388
2391
|
for (const action of this.getTable().getHeaderActions())
|
|
@@ -2406,7 +2409,7 @@ export class ListRecords extends Page {
|
|
|
2406
2409
|
return [{ label: this.resource.getTitleCasePluralModelLabel(), url: this.resource.getUrl() }, { label: t('filament-panels::resources/pages/list-records.breadcrumb'), url: null }];
|
|
2407
2410
|
}
|
|
2408
2411
|
getHeaderActions() {
|
|
2409
|
-
//
|
|
2412
|
+
// Simple resource: create in a modal; otherwise link to the create page.
|
|
2410
2413
|
if (this.resource.isSimple)
|
|
2411
2414
|
return [CreateAction.make(this.resource)];
|
|
2412
2415
|
return [...(this.resource.listHeaderActions?.() ?? []), Action.make('create').label(t('filament-actions::create.single.label', { label: this.resource.modelLabel })).url(this.resource.getUrl('create')).authorize(({ livewire }) => canOn(livewire, 'create'))];
|
|
@@ -2417,27 +2420,27 @@ export class ListRecords extends Page {
|
|
|
2417
2420
|
}
|
|
2418
2421
|
}
|
|
2419
2422
|
/**
|
|
2420
|
-
* Table
|
|
2421
|
-
*
|
|
2423
|
+
* Table of records related to another one: a child component of the edit page, loaded when it becomes visible.
|
|
2424
|
+
* Subclasses declare `relationship`, `adapterFor()`, `table()` and `form()`.
|
|
2422
2425
|
*/
|
|
2423
2426
|
export class RelationManager extends ListRecords {
|
|
2424
2427
|
static relationship = '';
|
|
2425
2428
|
static modelLabel = 'record';
|
|
2426
2429
|
static pluralModelLabel = null;
|
|
2427
2430
|
static recordTitleAttribute = null;
|
|
2428
|
-
/**
|
|
2429
|
-
static adapterFor(_ownerKey) { throw new Error('RelationManager.adapterFor()
|
|
2430
|
-
/**
|
|
2431
|
+
/** Data adapter limited to records related to the given owner. */
|
|
2432
|
+
static adapterFor(_ownerKey) { throw new Error('RelationManager.adapterFor() must be defined.'); }
|
|
2433
|
+
/** Policy for related records (same abilities as a resource policy). */
|
|
2431
2434
|
static policy = null;
|
|
2432
2435
|
isAccessAuthorized(ctx) { return this.resource.can('viewAny', ctx.user); }
|
|
2433
2436
|
static table(table) { return table; }
|
|
2434
2437
|
static form(schema) { return schema; }
|
|
2435
2438
|
static getTitle() { return this.title ?? ucfirst(this.relationship.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase()); }
|
|
2436
|
-
/**
|
|
2439
|
+
/** Owner record key: set on mount by the page, never writable from the browser. */
|
|
2437
2440
|
ownerRecord = '';
|
|
2438
2441
|
static locked = ['ownerRecord'];
|
|
2439
2442
|
_facade = null;
|
|
2440
|
-
/**
|
|
2443
|
+
/** What ListRecords expects from a resource, provided here by the relation manager. */
|
|
2441
2444
|
get resource() {
|
|
2442
2445
|
const manager = this.constructor;
|
|
2443
2446
|
return (this._facade ??= {
|
|
@@ -2452,24 +2455,24 @@ export class RelationManager extends ListRecords {
|
|
|
2452
2455
|
getTable() {
|
|
2453
2456
|
const manager = this.constructor;
|
|
2454
2457
|
const table = manager.table(new Table().pluralModelLabel(this.resource.getPluralModelLabel()).heading(manager.getTitle()));
|
|
2455
|
-
//
|
|
2458
|
+
// If there is an "edit" row action, clicking the row opens it.
|
|
2456
2459
|
const hasEdit = table.getRecordActions().flatMap((a) => (a instanceof ActionGroup ? a.getActions() : [a])).some((a) => a.getName() === 'edit');
|
|
2457
2460
|
return hasEdit ? table.recordAction('edit') : table;
|
|
2458
2461
|
}
|
|
2459
|
-
// ---
|
|
2460
|
-
/**
|
|
2461
|
-
static attach(_ownerKey, _recordKey) { throw new Error('RelationManager.attach()
|
|
2462
|
-
/**
|
|
2463
|
-
static detach(_ownerKey, _recordKeys) { throw new Error('RelationManager.detach()
|
|
2464
|
-
/**
|
|
2465
|
-
static searchAttachable(_ownerKey, _search) { throw new Error('RelationManager.searchAttachable()
|
|
2466
|
-
/**
|
|
2467
|
-
static attachableLabel(_ownerKey, _recordKey) { throw new Error('RelationManager.attachableLabel()
|
|
2462
|
+
// --- many-to-many: to be defined by managers offering Attach / Detach
|
|
2463
|
+
/** Attaches an existing record to the owner. */
|
|
2464
|
+
static attach(_ownerKey, _recordKey) { throw new Error('RelationManager.attach() must be defined.'); }
|
|
2465
|
+
/** Detaches records from the owner without deleting them. */
|
|
2466
|
+
static detach(_ownerKey, _recordKeys) { throw new Error('RelationManager.detach() must be defined.'); }
|
|
2467
|
+
/** Attachable records matching the search: [key, label]. */
|
|
2468
|
+
static searchAttachable(_ownerKey, _search) { throw new Error('RelationManager.searchAttachable() must be defined.'); }
|
|
2469
|
+
/** Label of an attachable record, or `null` if it is not attachable (also used as validation). */
|
|
2470
|
+
static attachableLabel(_ownerKey, _recordKey) { throw new Error('RelationManager.attachableLabel() must be defined.'); }
|
|
2468
2471
|
getHeaderActions() { return []; }
|
|
2469
2472
|
placeholder() {
|
|
2470
2473
|
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>`;
|
|
2471
2474
|
}
|
|
2472
|
-
/**
|
|
2475
|
+
/** A relation manager has its own render hook locations, not the list page's. */
|
|
2473
2476
|
contentSchema(schema) {
|
|
2474
2477
|
return schema.components([new ResourceTabs(), RenderHook.make(PanelsRenderHook.RESOURCE_RELATION_MANAGER_BEFORE), EmbeddedTable.make(), RenderHook.make(PanelsRenderHook.RESOURCE_RELATION_MANAGER_AFTER)]);
|
|
2475
2478
|
}
|
|
@@ -2478,25 +2481,25 @@ export class RelationManager extends ListRecords {
|
|
|
2478
2481
|
}
|
|
2479
2482
|
}
|
|
2480
2483
|
/**
|
|
2481
|
-
* Page
|
|
2482
|
-
*
|
|
2484
|
+
* Page listing records related to a record: a table, with create, view and edit in modals.
|
|
2485
|
+
* Declared in the owner resource's `static relatedPages`, it is served at `<slug>/{record}/<name>`.
|
|
2483
2486
|
*/
|
|
2484
2487
|
export class ManageRelatedRecords extends ListRecords {
|
|
2485
|
-
/**
|
|
2488
|
+
/** Owner resource: set when the page is registered. */
|
|
2486
2489
|
static parentResource;
|
|
2487
2490
|
static relationship = '';
|
|
2488
2491
|
static modelLabel = 'record';
|
|
2489
2492
|
static pluralModelLabel = null;
|
|
2490
2493
|
static recordTitleAttribute = null;
|
|
2491
2494
|
static policy = null;
|
|
2492
|
-
/**
|
|
2493
|
-
static adapterFor(_ownerKey) { throw new Error('ManageRelatedRecords.adapterFor()
|
|
2495
|
+
/** Data adapter limited to records related to the given owner. */
|
|
2496
|
+
static adapterFor(_ownerKey) { throw new Error('ManageRelatedRecords.adapterFor() must be defined.'); }
|
|
2494
2497
|
static table(table) { return table; }
|
|
2495
2498
|
static form(schema) { return schema; }
|
|
2496
2499
|
static infolist(schema) { return schema; }
|
|
2497
2500
|
static getTitle() { return this.title ?? ucfirst(this.relationship.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase()); }
|
|
2498
2501
|
static getNavigationLabel() { return this.navigationLabel ?? this.getTitle(); }
|
|
2499
|
-
/**
|
|
2502
|
+
/** Owner record key: read from the URL on mount, never writable from the browser. */
|
|
2500
2503
|
recordKey = '';
|
|
2501
2504
|
static locked = ['recordKey'];
|
|
2502
2505
|
_owner = null;
|
|
@@ -2507,18 +2510,18 @@ export class ManageRelatedRecords extends ListRecords {
|
|
|
2507
2510
|
await this.hydrate();
|
|
2508
2511
|
await ListRecords.prototype.mount?.call(this, params);
|
|
2509
2512
|
}
|
|
2510
|
-
/**
|
|
2513
|
+
/** The owner is reloaded and access to it checked on every request. */
|
|
2511
2514
|
async hydrate() {
|
|
2512
2515
|
const parent = this.related.parentResource;
|
|
2513
2516
|
this._owner = await parent.adapter.find(this.recordKey);
|
|
2514
2517
|
if (!this._owner)
|
|
2515
|
-
throw new WireError(404, '
|
|
2518
|
+
throw new WireError(404, 'Record not found.');
|
|
2516
2519
|
if (!(await parent.can('view', this._ctx?.user ?? null, this._owner)))
|
|
2517
|
-
throw new WireError(403, '
|
|
2520
|
+
throw new WireError(403, 'Forbidden.');
|
|
2518
2521
|
await ListRecords.prototype.hydrate?.call(this);
|
|
2519
2522
|
}
|
|
2520
2523
|
getRecordKey() { return this.recordKey; }
|
|
2521
|
-
/**
|
|
2524
|
+
/** What ListRecords expects from a resource, provided here by the page. */
|
|
2522
2525
|
get resource() {
|
|
2523
2526
|
const page = this.related;
|
|
2524
2527
|
return (this._facade ??= {
|
|
@@ -2532,7 +2535,7 @@ export class ManageRelatedRecords extends ListRecords {
|
|
|
2532
2535
|
}
|
|
2533
2536
|
getTable() { return this.related.table(new Table().pluralModelLabel(this.resource.getPluralModelLabel())); }
|
|
2534
2537
|
getHeaderActions() { return []; }
|
|
2535
|
-
/**
|
|
2538
|
+
/** The page content is a group (tabs, if any, then the table). */
|
|
2536
2539
|
contentSchema(schema) {
|
|
2537
2540
|
return schema.components([Group.make([new ResourceTabs(), RenderHook.make(PanelsRenderHook.RESOURCE_RELATION_MANAGER_BEFORE), EmbeddedTable.make(), RenderHook.make(PanelsRenderHook.RESOURCE_RELATION_MANAGER_AFTER)]), new RelationManagerTabs().key(null).hidden()]);
|
|
2538
2541
|
}
|
|
@@ -2548,7 +2551,7 @@ export class ManageRelatedRecords extends ListRecords {
|
|
|
2548
2551
|
}
|
|
2549
2552
|
getPageClasses() { return ['fi-resource-manage-related-records-page', `fi-resource-${this.related.parentResource.slug.replace(/\//g, '-')}`, `fi-resource-record-${this.recordKey}`]; }
|
|
2550
2553
|
}
|
|
2551
|
-
/**
|
|
2554
|
+
/** Previous page URL if it belongs to this site, otherwise the site root (without a trailing slash). */
|
|
2552
2555
|
function previousUrlOf(req) {
|
|
2553
2556
|
if (!req)
|
|
2554
2557
|
return '';
|
|
@@ -2558,27 +2561,27 @@ function previousUrlOf(req) {
|
|
|
2558
2561
|
if (referer.origin === origin)
|
|
2559
2562
|
return `${origin}${referer.pathname === '/' ? '' : referer.pathname}${referer.search}`;
|
|
2560
2563
|
}
|
|
2561
|
-
catch { /*
|
|
2564
|
+
catch { /* no usable Referer */ }
|
|
2562
2565
|
return origin;
|
|
2563
2566
|
}
|
|
2564
|
-
/**
|
|
2567
|
+
/** A theme's representative color (shade 500 of its primary color), for the menu swatch. */
|
|
2565
2568
|
function swatchOf(theme, panel) {
|
|
2566
2569
|
const primary = theme.colors?.primary;
|
|
2567
2570
|
return (typeof primary === 'string' ? primary : primary?.[500]) ?? panel.colors.getColor('primary')?.[500] ?? 'currentColor';
|
|
2568
2571
|
}
|
|
2569
|
-
/**
|
|
2572
|
+
/** A theme's font: without a URL, it is served by Bunny Fonts like the panel font. */
|
|
2570
2573
|
function fontOf(font) {
|
|
2571
2574
|
return font.url ? { family: font.family, url: font.url } : { family: font.family, url: `https://fonts.bunny.net/css?family=${encodeURIComponent(font.family.toLowerCase().replace(/ /g, '-'))}:400,500,600,700&display=swap`, preconnect: 'https://fonts.bunny.net' };
|
|
2572
2575
|
}
|
|
2573
2576
|
function emptyToNull(data) { return Object.fromEntries(Object.entries(data).map(([k, v]) => [k, v === '' ? null : v])); }
|
|
2574
|
-
/** Base
|
|
2577
|
+
/** Base class for a resource's form pages. */
|
|
2575
2578
|
class ResourceFormPage extends Page {
|
|
2576
|
-
/**
|
|
2579
|
+
/** Live form field (`live()` + `afterStateUpdated()`): `set(name, value)` writes next to the updated field. */
|
|
2577
2580
|
async updatedData(_value, key) {
|
|
2578
2581
|
if (!key)
|
|
2579
2582
|
return;
|
|
2580
2583
|
const path = `data.${key}`;
|
|
2581
|
-
//
|
|
2584
|
+
// The updated field first, then its parent fields (a Repeater recomputes a total when one of its rows changes).
|
|
2582
2585
|
const fields = this.getSchema('form').getFlatComponents().filter((c) => c instanceof Field && c.hasAfterStateUpdated() && (c.getStatePath() === path || path.startsWith(`${c.getStatePath()}.`)))
|
|
2583
2586
|
.sort((a, b) => b.getStatePath().length - a.getStatePath().length);
|
|
2584
2587
|
for (const field of fields) {
|
|
@@ -2600,21 +2603,21 @@ class ResourceFormPage extends Page {
|
|
|
2600
2603
|
getDataAdapter() { return this.resource.adapter; }
|
|
2601
2604
|
getRecordKey() { return null; }
|
|
2602
2605
|
getRecord() { return null; }
|
|
2603
|
-
/**
|
|
2606
|
+
/** Two columns from `lg` by default; the resource may decide otherwise. */
|
|
2604
2607
|
formSchema(schema) {
|
|
2605
2608
|
const form = this.resource.form(schema.columns(2).operation(this.getOperation()).record(this.getRecord()));
|
|
2606
2609
|
const steps = this.getSteps();
|
|
2607
|
-
//
|
|
2610
|
+
// Wizard: the steps replace the resource form's components.
|
|
2608
2611
|
return steps === null ? form : form.columns(null).components([this.getWizardComponent(steps)]);
|
|
2609
2612
|
}
|
|
2610
2613
|
/**
|
|
2611
|
-
*
|
|
2612
|
-
*
|
|
2614
|
+
* Wizard steps: as soon as this method returns some, the page shows its form step by step,
|
|
2615
|
+
* with "Back" / "Next" and the submit button on the last step.
|
|
2613
2616
|
*/
|
|
2614
2617
|
getSteps() { return null; }
|
|
2615
|
-
/**
|
|
2618
|
+
/** `true`: steps can be visited in any order, without validation along the way. */
|
|
2616
2619
|
hasSkippableSteps() { return false; }
|
|
2617
|
-
/**
|
|
2620
|
+
/** Step shown on open (1-based). */
|
|
2618
2621
|
getStartStep() { return 1; }
|
|
2619
2622
|
getWizardComponent(steps) {
|
|
2620
2623
|
const [submit] = this.getFormActions();
|
|
@@ -2623,7 +2626,7 @@ class ResourceFormPage extends Page {
|
|
|
2623
2626
|
.alpineSubmitHandler(`$wire.${this.getSubmitMethod()}()`).skippable(this.hasSkippableSteps()).contained(false);
|
|
2624
2627
|
}
|
|
2625
2628
|
contentSchema(schema) {
|
|
2626
|
-
//
|
|
2629
|
+
// Wizard: no surrounding <form>; each step has its own and the wizard holds the actions.
|
|
2627
2630
|
if (this.getSteps() !== null)
|
|
2628
2631
|
return schema.components([EmbeddedSchema.make('form')]);
|
|
2629
2632
|
return schema.components([
|
|
@@ -2631,13 +2634,13 @@ class ResourceFormPage extends Page {
|
|
|
2631
2634
|
.footer([Actions.make(this.getFormActions()).alignment('start').key('form-actions')]),
|
|
2632
2635
|
]);
|
|
2633
2636
|
}
|
|
2634
|
-
/**
|
|
2637
|
+
/** Where "Cancel" goes when the browser has no history: the list page, unless the page decides otherwise. */
|
|
2635
2638
|
getCancelUrl() { return this.resource.getUrl(); }
|
|
2636
2639
|
cancelAction() {
|
|
2637
2640
|
return Action.make('cancel').label(t(`filament-panels::resources/pages/${this.getOperation()}-record.form.actions.cancel.label`)).color('gray')
|
|
2638
2641
|
.alpineClickHandler(`document.referrer ? window.history.back() : Livewire.navigate(${js(this.getCancelUrl())})`);
|
|
2639
2642
|
}
|
|
2640
|
-
/**
|
|
2643
|
+
/** Validates the form; on error, sets the errors on the component and notifies the browser. */
|
|
2641
2644
|
async validateForm() {
|
|
2642
2645
|
this._errors = await validateSchema(this.getSchema('form'));
|
|
2643
2646
|
if (!Object.keys(this._errors).length)
|
|
@@ -2646,7 +2649,7 @@ class ResourceFormPage extends Page {
|
|
|
2646
2649
|
return false;
|
|
2647
2650
|
}
|
|
2648
2651
|
}
|
|
2649
|
-
/**
|
|
2652
|
+
/** Record creation page. */
|
|
2650
2653
|
export class CreateRecord extends ResourceFormPage {
|
|
2651
2654
|
static callable = ['create', 'createAnother'];
|
|
2652
2655
|
mount() { fillSchema(this.getSchema('form'), this.data); }
|
|
@@ -2664,18 +2667,18 @@ export class CreateRecord extends ResourceFormPage {
|
|
|
2664
2667
|
this.cancelAction(),
|
|
2665
2668
|
];
|
|
2666
2669
|
}
|
|
2667
|
-
// ---
|
|
2668
|
-
/**
|
|
2670
|
+
// --- lifecycle hooks, to override in a subclass (none can be called from the browser)
|
|
2671
|
+
/** Transforms validated data before creation (add the author, compute a field…). */
|
|
2669
2672
|
mutateFormDataBeforeCreate(data) { return data; }
|
|
2670
|
-
/**
|
|
2673
|
+
/** Right before creation; throwing cancels it. */
|
|
2671
2674
|
beforeCreate(_data) { }
|
|
2672
|
-
/**
|
|
2675
|
+
/** The creation itself; defaults to the resource's data adapter. */
|
|
2673
2676
|
handleRecordCreation(data) { return this.getDataAdapter().create(data); }
|
|
2674
|
-
/**
|
|
2677
|
+
/** After creation (logging, sending a message…). */
|
|
2675
2678
|
afterCreate(_record) { }
|
|
2676
|
-
/**
|
|
2679
|
+
/** Where to go next; defaults to the new record's edit page. */
|
|
2677
2680
|
getRedirectUrl(record) { return this.resource.getUrl('edit', this.getDataAdapter().getKey(record)); }
|
|
2678
|
-
/**
|
|
2681
|
+
/** Success notification title; `null` to show nothing. */
|
|
2679
2682
|
getCreatedNotificationTitle() { return t('filament-panels::resources/pages/create-record.notifications.created.title'); }
|
|
2680
2683
|
async create(another = false) {
|
|
2681
2684
|
if (!(await this.validateForm()))
|
|
@@ -2696,14 +2699,14 @@ export class CreateRecord extends ResourceFormPage {
|
|
|
2696
2699
|
}
|
|
2697
2700
|
createAnother() { return this.create(true); }
|
|
2698
2701
|
}
|
|
2699
|
-
/**
|
|
2702
|
+
/** Delete action with confirmation. */
|
|
2700
2703
|
export class DeleteAction {
|
|
2701
|
-
/**
|
|
2704
|
+
/** On an edit page, deletes the page's record and returns to the list. On a table row, deletes the row. */
|
|
2702
2705
|
static make() {
|
|
2703
2706
|
return Action.make('delete').authorize(({ livewire, record }) => canOn(livewire, 'delete', record))
|
|
2704
2707
|
.label(t('filament-actions::delete.single.label')).color('danger').keyBindings(['mod+d']).tableIcon('c-trash').groupedIcon('m-trash')
|
|
2705
2708
|
.requiresConfirmation().modalIcon('o-trash')
|
|
2706
|
-
//
|
|
2709
|
+
// Hidden for an already soft-deleted record.
|
|
2707
2710
|
.hidden(({ livewire, record }) => { const target = record ?? livewire._record; return target ? livewire.getDataAdapter().isTrashed(target) : false; })
|
|
2708
2711
|
.modalHeading(({ livewire, record }) => t('filament-actions::delete.single.modal.heading', { label: livewire.resource.getRecordTitle(record ?? livewire._record) }))
|
|
2709
2712
|
.modalSubmitActionLabel(t('filament-actions::delete.single.modal.actions.delete.label'))
|
|
@@ -2717,7 +2720,7 @@ export class DeleteAction {
|
|
|
2717
2720
|
});
|
|
2718
2721
|
}
|
|
2719
2722
|
}
|
|
2720
|
-
/**
|
|
2723
|
+
/** Duplicates a table row: the copy takes the record's attributes, except its key, timestamps and excluded ones. */
|
|
2721
2724
|
export class ReplicateAction {
|
|
2722
2725
|
static make() {
|
|
2723
2726
|
let excluded = [];
|
|
@@ -2728,7 +2731,7 @@ export class ReplicateAction {
|
|
|
2728
2731
|
.action(async ({ livewire, record }) => {
|
|
2729
2732
|
const adapter = livewire.getDataAdapter();
|
|
2730
2733
|
const source = typeof record?.toJSON === 'function' ? record.toJSON() : { ...record };
|
|
2731
|
-
//
|
|
2734
|
+
// No primary key, no created/updated timestamps.
|
|
2732
2735
|
for (const attribute of ['id', 'created_at', 'updated_at', ...excluded])
|
|
2733
2736
|
delete source[attribute];
|
|
2734
2737
|
await adapter.create(source);
|
|
@@ -2738,9 +2741,9 @@ export class ReplicateAction {
|
|
|
2738
2741
|
return action;
|
|
2739
2742
|
}
|
|
2740
2743
|
}
|
|
2741
|
-
/**
|
|
2744
|
+
/** Create in a modal with the resource form. */
|
|
2742
2745
|
export class CreateAction {
|
|
2743
|
-
/**
|
|
2746
|
+
/** Without an argument, the resource (or relation manager) is the host component's. */
|
|
2744
2747
|
static make(resource = null) {
|
|
2745
2748
|
const of = (livewire) => resource ?? livewire.resource;
|
|
2746
2749
|
return Action.make('create').authorize(({ livewire, record }) => canOn(livewire, 'create', record))
|
|
@@ -2755,9 +2758,9 @@ export class CreateAction {
|
|
|
2755
2758
|
});
|
|
2756
2759
|
}
|
|
2757
2760
|
}
|
|
2758
|
-
/**
|
|
2761
|
+
/** Edit a table row in a modal, with the resource form. */
|
|
2759
2762
|
export class EditAction {
|
|
2760
|
-
/**
|
|
2763
|
+
/** Without an argument, the resource (or relation manager) is the host component's. */
|
|
2761
2764
|
static make(resource = null) {
|
|
2762
2765
|
const of = (livewire) => resource ?? livewire.resource;
|
|
2763
2766
|
return Action.make('edit').authorize(({ livewire, record }) => canOn(livewire, 'update', record))
|
|
@@ -2773,13 +2776,13 @@ export class EditAction {
|
|
|
2773
2776
|
});
|
|
2774
2777
|
}
|
|
2775
2778
|
}
|
|
2776
|
-
/**
|
|
2779
|
+
/** Link to the record's view page (when the resource has one). */
|
|
2777
2780
|
export class ViewAction {
|
|
2778
2781
|
static make(resource = null) {
|
|
2779
2782
|
const of = (livewire) => resource ?? livewire.resource;
|
|
2780
2783
|
return Action.make('view').authorize(({ livewire, record }) => canOn(livewire, 'view', record))
|
|
2781
2784
|
.label(t('filament-actions::view.single.label')).color('gray').tableIcon('c-eye').groupedIcon('m-eye')
|
|
2782
|
-
//
|
|
2785
|
+
// Without a view page (simple resource, related records): the infolist opens in a modal.
|
|
2783
2786
|
.url(({ livewire, record }) => (record && of(livewire).hasViewPage ? of(livewire).getUrl('view', of(livewire).adapter.getKey(record)) : null))
|
|
2784
2787
|
.modalHeading(({ livewire, record }) => t('filament-actions::view.single.modal.heading', { label: of(livewire).getRecordTitle(record) }))
|
|
2785
2788
|
.modalSubmitAction(false).modalCancelActionLabel(t('filament-actions::view.single.modal.actions.close.label'))
|
|
@@ -2787,7 +2790,7 @@ export class ViewAction {
|
|
|
2787
2790
|
}
|
|
2788
2791
|
}
|
|
2789
2792
|
const managerOf = (livewire) => livewire.constructor;
|
|
2790
|
-
/**
|
|
2793
|
+
/** Attaches an existing record to a relation manager's owner. */
|
|
2791
2794
|
export class AttachAction {
|
|
2792
2795
|
static make() {
|
|
2793
2796
|
return Action.make('attach')
|
|
@@ -2807,7 +2810,7 @@ export class AttachAction {
|
|
|
2807
2810
|
});
|
|
2808
2811
|
}
|
|
2809
2812
|
}
|
|
2810
|
-
/**
|
|
2813
|
+
/** Detaches a row from the owner without deleting it. */
|
|
2811
2814
|
export class DetachAction {
|
|
2812
2815
|
static make() {
|
|
2813
2816
|
return Action.make('detach')
|
|
@@ -2821,7 +2824,7 @@ export class DetachAction {
|
|
|
2821
2824
|
});
|
|
2822
2825
|
}
|
|
2823
2826
|
}
|
|
2824
|
-
/**
|
|
2827
|
+
/** Detaches the selected rows. */
|
|
2825
2828
|
export class DetachBulkAction {
|
|
2826
2829
|
static make() {
|
|
2827
2830
|
return Action.make('detach')
|
|
@@ -2830,14 +2833,14 @@ export class DetachBulkAction {
|
|
|
2830
2833
|
.modalHeading(({ livewire }) => t('filament-actions::detach.multiple.modal.heading', { label: livewire.resource.getPluralModelLabel() }))
|
|
2831
2834
|
.modalSubmitActionLabel(t('filament-actions::detach.multiple.modal.actions.detach.label'))
|
|
2832
2835
|
.action(async ({ livewire }) => {
|
|
2833
|
-
//
|
|
2836
|
+
// Selected keys come from the manager's current query, so they belong to the owner.
|
|
2834
2837
|
await managerOf(livewire).detach(livewire.ownerRecord, await livewire.getSelectedRecordKeys());
|
|
2835
2838
|
Notification.make().success().title(t('filament-actions::detach.multiple.notifications.detached.title')).send();
|
|
2836
2839
|
livewire.deselectAllTableRecords();
|
|
2837
2840
|
});
|
|
2838
2841
|
}
|
|
2839
2842
|
}
|
|
2840
|
-
/**
|
|
2843
|
+
/** Restores a soft-deleted record. Only visible when the record is deleted. */
|
|
2841
2844
|
export class RestoreAction {
|
|
2842
2845
|
static make() {
|
|
2843
2846
|
return Action.make('restore').authorize(({ livewire, record }) => canOn(livewire, 'restore', record))
|
|
@@ -2853,7 +2856,7 @@ export class RestoreAction {
|
|
|
2853
2856
|
});
|
|
2854
2857
|
}
|
|
2855
2858
|
}
|
|
2856
|
-
/**
|
|
2859
|
+
/** Permanently deletes a soft-deleted record. Only visible when the record is deleted. */
|
|
2857
2860
|
export class ForceDeleteAction {
|
|
2858
2861
|
static make() {
|
|
2859
2862
|
return Action.make('forceDelete').authorize(({ livewire, record }) => canOn(livewire, 'forceDelete', record))
|
|
@@ -2869,7 +2872,7 @@ export class ForceDeleteAction {
|
|
|
2869
2872
|
});
|
|
2870
2873
|
}
|
|
2871
2874
|
}
|
|
2872
|
-
/**
|
|
2875
|
+
/** Deletes the records selected in a table. */
|
|
2873
2876
|
export class DeleteBulkAction {
|
|
2874
2877
|
static make() {
|
|
2875
2878
|
return Action.make('delete').authorize(({ livewire, record }) => canOn(livewire, 'deleteAny', record))
|
|
@@ -2884,21 +2887,21 @@ export class DeleteBulkAction {
|
|
|
2884
2887
|
});
|
|
2885
2888
|
}
|
|
2886
2889
|
}
|
|
2887
|
-
/**
|
|
2890
|
+
/** Record edit page. */
|
|
2888
2891
|
export class EditRecord extends ResourceFormPage {
|
|
2889
2892
|
static callable = ['save'];
|
|
2890
2893
|
recordKey = '';
|
|
2891
|
-
/**
|
|
2894
|
+
/** Active relation manager tab (index in `Resource.relations`). */
|
|
2892
2895
|
activeRelationManager = '0';
|
|
2893
2896
|
/**
|
|
2894
|
-
*
|
|
2895
|
-
*
|
|
2897
|
+
* Previous page, captured on first display from the Referer header: only a same-site URL is accepted,
|
|
2898
|
+
* and it cannot be changed afterwards.
|
|
2896
2899
|
*/
|
|
2897
2900
|
previousUrl = '';
|
|
2898
2901
|
getCancelUrl() { return this.previousUrl; }
|
|
2899
2902
|
contentSchema(schema) {
|
|
2900
2903
|
const content = super.contentSchema(schema);
|
|
2901
|
-
//
|
|
2904
|
+
// Always present: hidden (empty column) when the resource has no relation manager.
|
|
2902
2905
|
return content.components([...content.getComponents(), this.resource.relations.length ? new RelationManagerTabs() : new RelationManagerTabs().key(null).hidden()]);
|
|
2903
2906
|
}
|
|
2904
2907
|
static locked = ['recordKey', 'previousUrl'];
|
|
@@ -2907,25 +2910,24 @@ export class EditRecord extends ResourceFormPage {
|
|
|
2907
2910
|
this.recordKey = String(params.record ?? '');
|
|
2908
2911
|
this.previousUrl = previousUrlOf(this._ctx?.req);
|
|
2909
2912
|
await this.hydrate();
|
|
2910
|
-
// Remplit le formulaire avec les valeurs de l'enregistrement pour chaque champ déclaré.
|
|
2911
2913
|
fillSchemaFromRecord(this.getSchema('form'), this.data, await this.mutateFormDataBeforeFill({ ...this._record }));
|
|
2912
2914
|
}
|
|
2913
|
-
/**
|
|
2915
|
+
/** The record is reloaded on every request (no route model binding). */
|
|
2914
2916
|
async hydrate() {
|
|
2915
2917
|
this._record = await this.getDataAdapter().find(this.recordKey);
|
|
2916
2918
|
if (!this._record)
|
|
2917
|
-
throw new WireError(404, '
|
|
2919
|
+
throw new WireError(404, 'Record not found.');
|
|
2918
2920
|
if (!(await canOn(this, 'update', this._record)))
|
|
2919
|
-
throw new WireError(403, '
|
|
2921
|
+
throw new WireError(403, 'Forbidden.');
|
|
2920
2922
|
}
|
|
2921
2923
|
getRecord() { return this._record; }
|
|
2922
2924
|
getRecordKey() { return this.recordKey; }
|
|
2923
2925
|
getActionMountContext() { return { recordKey: this.recordKey }; }
|
|
2924
2926
|
getActionRecord() { return this._record; }
|
|
2925
|
-
/**
|
|
2927
|
+
/** Delete, plus restore and force-delete when the model uses soft deletes. */
|
|
2926
2928
|
getHeaderActions() {
|
|
2927
2929
|
const defaults = this.getDataAdapter().supportsSoftDeletes() ? [DeleteAction.make(), RestoreAction.make(), ForceDeleteAction.make()] : [DeleteAction.make()];
|
|
2928
|
-
//
|
|
2930
|
+
// Resource-specific actions come before the default ones.
|
|
2929
2931
|
return [...(this.resource.editHeaderActions?.() ?? []), ...defaults];
|
|
2930
2932
|
}
|
|
2931
2933
|
getOperation() { return 'edit'; }
|
|
@@ -2934,7 +2936,7 @@ export class EditRecord extends ResourceFormPage {
|
|
|
2934
2936
|
getBreadcrumbs() {
|
|
2935
2937
|
return [
|
|
2936
2938
|
{ label: this.resource.getTitleCasePluralModelLabel(), url: this.resource.getUrl() },
|
|
2937
|
-
//
|
|
2939
|
+
// The record title only appears if the resource declares `recordTitleAttribute`; it links to the view page when there is one.
|
|
2938
2940
|
...(this.resource.recordTitleAttribute ? [{ label: this.resource.getRecordTitle(this._record), url: this.resource.getUrl(this.resource.hasViewPage ? 'view' : 'edit', this.recordKey) }] : []),
|
|
2939
2941
|
{ label: t('filament-panels::resources/pages/edit-record.breadcrumb'), url: null },
|
|
2940
2942
|
];
|
|
@@ -2944,20 +2946,20 @@ export class EditRecord extends ResourceFormPage {
|
|
|
2944
2946
|
getFormActions() {
|
|
2945
2947
|
return [Action.make('save').label(t('filament-panels::resources/pages/edit-record.form.actions.save.label')).submit('save').keyBindings(['mod+s']), this.cancelAction()];
|
|
2946
2948
|
}
|
|
2947
|
-
// ---
|
|
2948
|
-
/**
|
|
2949
|
+
// --- lifecycle hooks, to override in a subclass (none can be called from the browser)
|
|
2950
|
+
/** Transforms the record's values before filling the form. */
|
|
2949
2951
|
mutateFormDataBeforeFill(data) { return data; }
|
|
2950
|
-
/**
|
|
2952
|
+
/** Transforms validated data before saving. */
|
|
2951
2953
|
mutateFormDataBeforeSave(data) { return data; }
|
|
2952
|
-
/**
|
|
2954
|
+
/** Right before saving; throwing cancels it. */
|
|
2953
2955
|
beforeSave(_data) { }
|
|
2954
|
-
/**
|
|
2956
|
+
/** The save itself; defaults to the resource's data adapter. */
|
|
2955
2957
|
handleRecordUpdate(record, data) { return this.getDataAdapter().update(this.getDataAdapter().getKey(record), data); }
|
|
2956
|
-
/**
|
|
2958
|
+
/** After saving. */
|
|
2957
2959
|
afterSave(_record) { }
|
|
2958
|
-
/**
|
|
2960
|
+
/** URL to go to after saving; `null` (default) stays on the page. */
|
|
2959
2961
|
getRedirectUrl(_record) { return null; }
|
|
2960
|
-
/**
|
|
2962
|
+
/** Success notification title; `null` to show nothing. */
|
|
2961
2963
|
getSavedNotificationTitle() { return t('filament-panels::resources/pages/edit-record.notifications.saved.title'); }
|
|
2962
2964
|
async save() {
|
|
2963
2965
|
if (!(await this.validateForm()))
|
|
@@ -2974,12 +2976,12 @@ export class EditRecord extends ResourceFormPage {
|
|
|
2974
2976
|
this.redirect(url, true);
|
|
2975
2977
|
}
|
|
2976
2978
|
}
|
|
2977
|
-
/**
|
|
2979
|
+
/** Record view page: the resource's read-only `infolist()` schema. */
|
|
2978
2980
|
export class ViewRecord extends Page {
|
|
2979
2981
|
static resource;
|
|
2980
2982
|
static locked = ['recordKey'];
|
|
2981
2983
|
recordKey = '';
|
|
2982
|
-
/**
|
|
2984
|
+
/** Active relation manager tab (index in `Resource.relations`). */
|
|
2983
2985
|
activeRelationManager = '0';
|
|
2984
2986
|
_record = null;
|
|
2985
2987
|
get resource() { return this.constructor.resource; }
|
|
@@ -2988,13 +2990,13 @@ export class ViewRecord extends Page {
|
|
|
2988
2990
|
this.recordKey = String(params.record ?? '');
|
|
2989
2991
|
await this.hydrate();
|
|
2990
2992
|
}
|
|
2991
|
-
/**
|
|
2993
|
+
/** The record is reloaded on every request, as on the edit page. */
|
|
2992
2994
|
async hydrate() {
|
|
2993
2995
|
this._record = await this.getDataAdapter().find(this.recordKey);
|
|
2994
2996
|
if (!this._record)
|
|
2995
|
-
throw new WireError(404, '
|
|
2997
|
+
throw new WireError(404, 'Record not found.');
|
|
2996
2998
|
if (!(await canOn(this, 'view', this._record)))
|
|
2997
|
-
throw new WireError(403, '
|
|
2999
|
+
throw new WireError(403, 'Forbidden.');
|
|
2998
3000
|
}
|
|
2999
3001
|
getRecordKey() { return this.recordKey; }
|
|
3000
3002
|
getActionMountContext() { return { recordKey: this.recordKey }; }
|
|
@@ -3002,10 +3004,10 @@ export class ViewRecord extends Page {
|
|
|
3002
3004
|
infolistSchema(schema) { return this.resource.infolist(schema.columns(2).operation('view').record(this._record)); }
|
|
3003
3005
|
contentSchema(schema) {
|
|
3004
3006
|
const content = schema.components([EmbeddedSchema.make('infolist')]);
|
|
3005
|
-
//
|
|
3007
|
+
// Always present: hidden (empty column) when the resource has no relation manager.
|
|
3006
3008
|
return content.components([...content.getComponents(), this.resource.relations.length ? new RelationManagerTabs() : new RelationManagerTabs().key(null).hidden()]);
|
|
3007
3009
|
}
|
|
3008
|
-
/**
|
|
3010
|
+
/** Defaults to a link to the edit page. */
|
|
3009
3011
|
getHeaderActions() { return [this.editAction()]; }
|
|
3010
3012
|
editAction() {
|
|
3011
3013
|
return Action.make('edit').authorize(({ livewire, record }) => canOn(livewire, 'update', record)).label(t('filament-actions::edit.single.label')).url(() => this.resource.getUrl('edit', this.recordKey));
|
|
@@ -3014,7 +3016,7 @@ export class ViewRecord extends Page {
|
|
|
3014
3016
|
getBreadcrumbs() {
|
|
3015
3017
|
return [
|
|
3016
3018
|
{ label: this.resource.getTitleCasePluralModelLabel(), url: this.resource.getUrl() },
|
|
3017
|
-
//
|
|
3019
|
+
// The record title only appears if the resource declares `recordTitleAttribute`.
|
|
3018
3020
|
...(this.resource.recordTitleAttribute ? [{ label: this.resource.getRecordTitle(this._record), url: this.resource.getUrl('view', this.recordKey) }] : []),
|
|
3019
3021
|
{ label: t('filament-panels::resources/pages/view-record.breadcrumb'), url: null },
|
|
3020
3022
|
];
|
|
@@ -3022,10 +3024,9 @@ export class ViewRecord extends Page {
|
|
|
3022
3024
|
getSubNavigation() { return this.resource.getRecordSubNavigation(this); }
|
|
3023
3025
|
getPageClasses() { return ['fi-resource-view-record-page', `fi-resource-${this.resource.slug.replace(/\//g, '-')}`, `fi-resource-record-${this.recordKey}`]; }
|
|
3024
3026
|
}
|
|
3025
|
-
|
|
3026
|
-
/** Contexte d'évaluation des actions d'un menu du panel (pas d'enregistrement). */
|
|
3027
|
+
/** Evaluation context for panel menu actions (no record). */
|
|
3027
3028
|
const menuContext = (livewire) => ({ livewire: livewire, component: null, operation: null, record: null, get: () => undefined, state: undefined });
|
|
3028
|
-
/**
|
|
3029
|
+
/** User menu: profile, panel items, themes, logout. */
|
|
3029
3030
|
async function renderUserMenu(component, position) {
|
|
3030
3031
|
const { req, res, panel, user } = component._ctx;
|
|
3031
3032
|
const ctx = menuContext(component);
|
|
@@ -3048,7 +3049,7 @@ async function renderUserMenu(component, position) {
|
|
|
3048
3049
|
themes: panel.getThemes().map((theme) => ({ id: theme.id, label: theme.label, swatch: swatchOf(theme, panel) })), activeTheme: panel.resolveTheme(req)?.id ?? null,
|
|
3049
3050
|
});
|
|
3050
3051
|
}
|
|
3051
|
-
/**
|
|
3052
|
+
/** Tenant menu: current tenant, other tenants, registration, profile. */
|
|
3052
3053
|
async function renderTenantMenu(component, options) {
|
|
3053
3054
|
const { panel, user } = component._ctx;
|
|
3054
3055
|
const tenant = panel.getTenant();
|
|
@@ -3081,13 +3082,13 @@ async function renderTenantMenu(component, options) {
|
|
|
3081
3082
|
switchable: others.map((other) => ({ name: provider.getName(other), url: panel.getUrl('', other), avatarUrl: panel.getTenantAvatarUrl(other) })),
|
|
3082
3083
|
});
|
|
3083
3084
|
}
|
|
3084
|
-
/** Sidebar
|
|
3085
|
+
/** Sidebar: logo, tenant menu, search, navigation and, without a topbar, the bell and user menu. */
|
|
3085
3086
|
export class Sidebar extends Component {
|
|
3086
3087
|
static callable = ['setTheme'];
|
|
3087
3088
|
currentPath = '/';
|
|
3088
3089
|
static locked = ['currentPath'];
|
|
3089
3090
|
mount(params) { this.currentPath = String(params.path ?? '/'); }
|
|
3090
|
-
/**
|
|
3091
|
+
/** A resource item stays active on its create and edit pages. */
|
|
3091
3092
|
async render() {
|
|
3092
3093
|
const { panel, user } = this._ctx;
|
|
3093
3094
|
if (!user)
|
|
@@ -3104,22 +3105,22 @@ export class Sidebar extends Component {
|
|
|
3104
3105
|
livewireId: this.id,
|
|
3105
3106
|
});
|
|
3106
3107
|
}
|
|
3107
|
-
/**
|
|
3108
|
+
/** Theme choice by the user: only a theme declared by the panel is accepted; the page reloads to apply it. */
|
|
3108
3109
|
setTheme(id) { applyTheme(this, id); this.skipRender(); }
|
|
3109
3110
|
}
|
|
3110
|
-
/**
|
|
3111
|
+
/** Theme choice (sidebar or topbar): only a theme declared by the panel is accepted; the page reloads. */
|
|
3111
3112
|
function applyTheme(component, id) {
|
|
3112
3113
|
const { req, res, panel, user } = component._ctx;
|
|
3113
3114
|
if (!user)
|
|
3114
|
-
throw new WireError(401, '
|
|
3115
|
+
throw new WireError(401, 'Unauthenticated.');
|
|
3115
3116
|
const theme = panel.getThemes().find((candidate) => candidate.id === id);
|
|
3116
3117
|
if (!theme)
|
|
3117
|
-
throw new WireError(404, '
|
|
3118
|
+
throw new WireError(404, 'Unknown theme.');
|
|
3118
3119
|
panel.rememberTheme(res, theme);
|
|
3119
3120
|
const referer = previousUrlOf(req);
|
|
3120
3121
|
component.redirect(referer.replace(/^https?:\/\/[^/]+/, '') || '/');
|
|
3121
3122
|
}
|
|
3122
|
-
/**
|
|
3123
|
+
/** Topbar: logo, optional navigation, search, bell, user menu. */
|
|
3123
3124
|
export class Topbar extends Component {
|
|
3124
3125
|
static callable = ['setTheme'];
|
|
3125
3126
|
currentPath = '/';
|
|
@@ -3147,7 +3148,7 @@ export class Topbar extends Component {
|
|
|
3147
3148
|
}
|
|
3148
3149
|
setTheme(id) { applyTheme(this, id); this.skipRender(); }
|
|
3149
3150
|
}
|
|
3150
|
-
/**
|
|
3151
|
+
/** User menu on simple pages for a signed-in user. */
|
|
3151
3152
|
export class SimpleUserMenu extends Component {
|
|
3152
3153
|
static callable = ['setTheme'];
|
|
3153
3154
|
async render() {
|
|
@@ -3159,32 +3160,76 @@ export class SimpleUserMenu extends Component {
|
|
|
3159
3160
|
}
|
|
3160
3161
|
setTheme(id) { applyTheme(this, id); this.skipRender(); }
|
|
3161
3162
|
}
|
|
3162
|
-
|
|
3163
|
-
|
|
3163
|
+
/** `.ts`, `.mts`, `.js`, `.mjs` modules of a folder and its subfolders, in alphabetical path order. */
|
|
3164
|
+
function moduleFiles(directory) {
|
|
3165
|
+
const root = directory instanceof URL ? fileURLToPath(directory) : directory;
|
|
3166
|
+
if (!existsSync(root))
|
|
3167
|
+
return [];
|
|
3168
|
+
const files = [];
|
|
3169
|
+
const walk = (dir) => {
|
|
3170
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
3171
|
+
const full = join(dir, entry.name);
|
|
3172
|
+
if (entry.isDirectory())
|
|
3173
|
+
walk(full);
|
|
3174
|
+
else if (/\.(m?ts|m?js)$/.test(entry.name) && !/\.(d|test|spec)\.m?[tj]s$/.test(entry.name))
|
|
3175
|
+
files.push(full);
|
|
3176
|
+
}
|
|
3177
|
+
};
|
|
3178
|
+
walk(root);
|
|
3179
|
+
return files;
|
|
3180
|
+
}
|
|
3181
|
+
/** Classes exported by a folder's modules that extend `base` (the base class itself, abstract or imported, is skipped). */
|
|
3182
|
+
async function discoverClasses(directory, base) {
|
|
3183
|
+
const found = [];
|
|
3184
|
+
for (const file of moduleFiles(directory)) {
|
|
3185
|
+
const module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
|
|
3186
|
+
for (const value of Object.values(module))
|
|
3187
|
+
if (typeof value === 'function' && value.prototype instanceof base && !found.includes(value))
|
|
3188
|
+
found.push(value);
|
|
3189
|
+
}
|
|
3190
|
+
return found;
|
|
3191
|
+
}
|
|
3192
|
+
/** Resources defined in a folder: `.resources(await discoverResources(new URL('./resources/', import.meta.url)))`. New files are picked up on the next start. */
|
|
3193
|
+
export const discoverResources = (directory) => discoverClasses(directory, Resource);
|
|
3194
|
+
/** Custom pages (and dashboards) defined in a folder. Resource and authentication pages are excluded. */
|
|
3195
|
+
export const discoverPages = async (directory) => (await discoverClasses(directory, Page)).filter((page) => !page.resource && !(page.prototype instanceof EditTenantProfile));
|
|
3196
|
+
/** Widgets defined in a folder. */
|
|
3197
|
+
export const discoverWidgets = (directory) => discoverClasses(directory, Widget);
|
|
3198
|
+
/** Panels default-exported (`export default Panel.make()…`) by a folder's modules. */
|
|
3199
|
+
export async function discoverPanels(directory) {
|
|
3200
|
+
const panels = [];
|
|
3201
|
+
for (const file of moduleFiles(directory)) {
|
|
3202
|
+
const module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
|
|
3203
|
+
if (module.default instanceof Panel)
|
|
3204
|
+
panels.push(module.default);
|
|
3205
|
+
}
|
|
3206
|
+
return panels;
|
|
3207
|
+
}
|
|
3208
|
+
/** A runtime serves a single panel: each panel has its own Livewire endpoint and components. */
|
|
3164
3209
|
const runtimePanels = new WeakMap();
|
|
3165
|
-
/**
|
|
3210
|
+
/** Reserved segments right after a multi-tenant panel's path (they are not tenant keys). */
|
|
3166
3211
|
const RESERVED_SEGMENTS = new Set(['login', 'logout', 'register', 'password-reset', 'email-verification', 'profile', 'livewire']);
|
|
3167
|
-
/**
|
|
3212
|
+
/** Express router for a panel: assets, runtime endpoint, authentication, tenants, protected pages. */
|
|
3168
3213
|
export function panelRouter(panel, runtime) {
|
|
3169
3214
|
const owner = runtimePanels.get(runtime);
|
|
3170
3215
|
if (owner && owner !== panel)
|
|
3171
|
-
throw new Error(`
|
|
3216
|
+
throw new Error(`This WireRuntime already serves panel "${owner.getId()}": create one WireRuntime per panel.`);
|
|
3172
3217
|
runtimePanels.set(runtime, panel);
|
|
3173
|
-
//
|
|
3218
|
+
// File uploads go through this panel's endpoint (panel session and user).
|
|
3174
3219
|
const runtimeOptions = runtime.options;
|
|
3175
3220
|
runtimeOptions.uploadPrefix ??= panel.getLivewirePrefix();
|
|
3176
|
-
//
|
|
3221
|
+
// One color manager for all panels: each request sets its own panel's colors on it.
|
|
3177
3222
|
useColorManager(sharedColors);
|
|
3178
3223
|
useFormsColorManager(sharedColors);
|
|
3179
3224
|
useTableColorManager(sharedColors);
|
|
3180
3225
|
useInfolistsColorManager(sharedColors);
|
|
3181
|
-
// HTML
|
|
3226
|
+
// HTML rendered by an infolist entry is sanitized through the rich editor schema, which only keeps its own tags.
|
|
3182
3227
|
useHtmlSanitizer((html) => richDocumentToHtml(richHtmlToDocument(html)) ?? '');
|
|
3183
3228
|
views.share({ assetUrl: (file) => panel.assetUrl(file) });
|
|
3184
3229
|
for (const plugin of panel.getPlugins())
|
|
3185
3230
|
plugin.boot?.(panel);
|
|
3186
3231
|
assertAuthCapabilities(panel);
|
|
3187
|
-
//
|
|
3232
|
+
// Tenancy: scoped resources only see the records of the request's tenant.
|
|
3188
3233
|
const ownership = panel.getTenantOwnershipColumn();
|
|
3189
3234
|
if (panel.hasTenancy() && ownership) {
|
|
3190
3235
|
for (const resource of panel.getResources()) {
|
|
@@ -3192,16 +3237,16 @@ export function panelRouter(panel, runtime) {
|
|
|
3192
3237
|
continue;
|
|
3193
3238
|
const adapter = resource.adapter;
|
|
3194
3239
|
if (typeof adapter?.scopeToTenant !== 'function')
|
|
3195
|
-
throw new Error(`Resource ${resource.name}
|
|
3240
|
+
throw new Error(`Resource ${resource.name}: its adapter does not support tenant scoping (scopeToTenant).`);
|
|
3196
3241
|
adapter.scopeToTenant(resource.tenantOwnershipColumn ?? ownership, () => { const tenant = panel.getTenant(); return tenant ? panel.getTenantUrlKey(tenant) : null; });
|
|
3197
3242
|
}
|
|
3198
3243
|
}
|
|
3199
3244
|
const router = express.Router();
|
|
3200
3245
|
const name = (suffix) => `navette.${panel.getId()}.${suffix}`;
|
|
3201
3246
|
const livewire = panel.getLivewirePrefix();
|
|
3202
|
-
//
|
|
3247
|
+
// The notifications mailbox is per browser: it is keyed by the CSRF token.
|
|
3203
3248
|
const context = async (req, res) => ({ req, res, panel, user: await panel.user(req), mailbox: csrfToken(req, res), tenant: null });
|
|
3204
|
-
/**
|
|
3249
|
+
/** Tenant designated by a panel URL, if the user is a member. */
|
|
3205
3250
|
const tenantFromPath = async (path, user) => {
|
|
3206
3251
|
if (!panel.hasTenancy() || !user)
|
|
3207
3252
|
return null;
|
|
@@ -3223,7 +3268,7 @@ export function panelRouter(panel, runtime) {
|
|
|
3223
3268
|
const tenant = await provider.find(key);
|
|
3224
3269
|
return tenant && (await provider.canAccess(user, tenant)) ? tenant : null;
|
|
3225
3270
|
};
|
|
3226
|
-
/**
|
|
3271
|
+
/** User's home: the panel root or, in a multi-tenant panel, their first tenant's root (otherwise tenant registration). */
|
|
3227
3272
|
const homeUrl = async (user) => {
|
|
3228
3273
|
if (!panel.hasTenancy())
|
|
3229
3274
|
return panel.getUrl();
|
|
@@ -3271,9 +3316,9 @@ export function panelRouter(panel, runtime) {
|
|
|
3271
3316
|
for (const manager of resource.relations)
|
|
3272
3317
|
runtime.register(name(`relation-managers.${resource.slug.replace(/\//g, '.')}.${manager.relationship}`), manager);
|
|
3273
3318
|
router.use(express.static(dir('../dist/filament'), { maxAge: '1y' }));
|
|
3274
|
-
//
|
|
3319
|
+
// Navette's own front-end files.
|
|
3275
3320
|
router.use(express.static(dir('../dist/navette')));
|
|
3276
|
-
//
|
|
3321
|
+
// An update's tenant is the one of the page sending it (from the signed snapshot URL), if the user is a member.
|
|
3277
3322
|
const wireContext = async (req, res) => {
|
|
3278
3323
|
const ctx = await context(req, res);
|
|
3279
3324
|
let path = '';
|
|
@@ -3286,7 +3331,7 @@ export function panelRouter(panel, runtime) {
|
|
|
3286
3331
|
ctx.tenant = await tenantFromPath(path, ctx.user);
|
|
3287
3332
|
return ctx;
|
|
3288
3333
|
};
|
|
3289
|
-
//
|
|
3334
|
+
// Notifications sent during the request go into a batch announced to the browser.
|
|
3290
3335
|
router.use(wireRouter(runtime, livewire, wireContext, async (ctx, _req, res, handle) => {
|
|
3291
3336
|
const { result, pending } = await runInRequest(ctx.mailbox, () => panel.withTheme(ctx.req, () => panel.withTenant(ctx.tenant ?? null, handle)));
|
|
3292
3337
|
if (pending.length) {
|
|
@@ -3298,23 +3343,23 @@ export function panelRouter(panel, runtime) {
|
|
|
3298
3343
|
res.setHeader('X-Filament-Notifications-Redirect', 'true');
|
|
3299
3344
|
}
|
|
3300
3345
|
return result;
|
|
3301
|
-
//
|
|
3346
|
+
// Only a signed-in user can upload files.
|
|
3302
3347
|
}, (ctx) => !!ctx.user));
|
|
3303
|
-
//
|
|
3348
|
+
// A page renders entirely with the request theme's colors, for the request's tenant.
|
|
3304
3349
|
const document = (req, res, ctx, options) => panel.withTheme(req, () => panel.withTenant(ctx.tenant ?? null, () => renderDocument(req, res, ctx, options)));
|
|
3305
3350
|
const renderDocument = async (req, res, ctx, options) => {
|
|
3306
3351
|
const request = { path: req.path, method: req.method, ctx };
|
|
3307
3352
|
const mounted = await runtime.mountWithInstance(options.component, options.params ?? {}, request);
|
|
3308
|
-
//
|
|
3353
|
+
// A page that redirects on mount (already logged in, email already verified…): HTTP redirect.
|
|
3309
3354
|
const redirect = mounted.component._effects?.redirect;
|
|
3310
3355
|
if (typeof redirect === 'string')
|
|
3311
3356
|
return void res.redirect(redirect);
|
|
3312
3357
|
const slot = mounted.html;
|
|
3313
|
-
//
|
|
3358
|
+
// The document title comes from the instance (it may depend on the record).
|
|
3314
3359
|
const instanceTitle = mounted.component.getTitle?.();
|
|
3315
3360
|
if (typeof instanceTitle === 'string')
|
|
3316
3361
|
options.title = instanceTitle;
|
|
3317
|
-
//
|
|
3362
|
+
// Layout render hooks use the scopes of the displayed page.
|
|
3318
3363
|
const renderHookScopes = mounted.component.getRenderHookScopes?.() ?? [];
|
|
3319
3364
|
const hasNavigation = panel.hasNavigation();
|
|
3320
3365
|
const body = options.layout === 'simple'
|
|
@@ -3336,7 +3381,7 @@ export function panelRouter(panel, runtime) {
|
|
|
3336
3381
|
notifications: (await runtime.mount('navette.notifications', {}, request)).replace(/^<div /, '<div wire:key="lw-1-0-0" '),
|
|
3337
3382
|
}));
|
|
3338
3383
|
};
|
|
3339
|
-
/**
|
|
3384
|
+
/** Page access errors: 404 and 403 are answered directly, anything else is rethrown. */
|
|
3340
3385
|
const guarded = async (res, render) => {
|
|
3341
3386
|
try {
|
|
3342
3387
|
await render();
|
|
@@ -3349,7 +3394,7 @@ export function panelRouter(panel, runtime) {
|
|
|
3349
3394
|
throw error;
|
|
3350
3395
|
}
|
|
3351
3396
|
};
|
|
3352
|
-
/**
|
|
3397
|
+
/** Guest-only simple page (login, registration, password reset). */
|
|
3353
3398
|
const guestPage = (path, component, title, params = () => ({})) => {
|
|
3354
3399
|
router.get(panel.getBaseUrl(path), async (req, res) => {
|
|
3355
3400
|
const ctx = await context(req, res);
|
|
@@ -3358,7 +3403,7 @@ export function panelRouter(panel, runtime) {
|
|
|
3358
3403
|
await guarded(res, () => document(req, res, ctx, { title, component, layout: 'simple', params: params(req) }));
|
|
3359
3404
|
});
|
|
3360
3405
|
};
|
|
3361
|
-
/**
|
|
3406
|
+
/** Simple page for a signed-in user, outside any tenant (profile, email verification, tenant registration). */
|
|
3362
3407
|
const userPage = (path, component, title, layout = 'simple') => {
|
|
3363
3408
|
router.get(panel.getBaseUrl(path), async (req, res) => {
|
|
3364
3409
|
const ctx = await context(req, res);
|
|
@@ -3371,7 +3416,7 @@ export function panelRouter(panel, runtime) {
|
|
|
3371
3416
|
guestPage('login', name('auth.login'), t('filament-panels::auth/pages/login.title'));
|
|
3372
3417
|
router.post(panel.getLogoutUrl(), express.urlencoded({ extended: false }), (req, res) => {
|
|
3373
3418
|
if (!verifyCsrf(req, req.body?._token))
|
|
3374
|
-
return res.status(419).send('
|
|
3419
|
+
return res.status(419).send('Invalid CSRF token.');
|
|
3375
3420
|
panel.logOut(res);
|
|
3376
3421
|
res.redirect(panel.getLoginUrl());
|
|
3377
3422
|
});
|
|
@@ -3384,7 +3429,7 @@ export function panelRouter(panel, runtime) {
|
|
|
3384
3429
|
}
|
|
3385
3430
|
if (VerificationPrompt) {
|
|
3386
3431
|
userPage('email-verification/prompt', name('auth.email-verification.prompt'), t('filament-panels::auth/pages/email-verification/email-verification-prompt.title'));
|
|
3387
|
-
//
|
|
3432
|
+
// Emailed link: signed, time-limited, and only valid for the signed-in user it designates.
|
|
3388
3433
|
router.get(panel.getBaseUrl('email-verification/verify/:id/:hash'), async (req, res) => {
|
|
3389
3434
|
const ctx = await context(req, res);
|
|
3390
3435
|
if (!ctx.user)
|
|
@@ -3401,7 +3446,7 @@ export function panelRouter(panel, runtime) {
|
|
|
3401
3446
|
userPage(profile.page.slug, name('auth.profile'), profile.page.getLabel(), profile.isSimple || panel.hasTenancy() ? 'simple' : 'index');
|
|
3402
3447
|
if (TenantRegistration)
|
|
3403
3448
|
userPage(TenantRegistration.slug, name('tenancy.registration'), TenantRegistration.getLabel());
|
|
3404
|
-
/**
|
|
3449
|
+
/** Context of a protected page: login, email verification, tenant. Returns `null` after responding. */
|
|
3405
3450
|
const pageContext = async (req, res) => {
|
|
3406
3451
|
const ctx = await context(req, res);
|
|
3407
3452
|
if (!ctx.user) {
|
|
@@ -3423,7 +3468,7 @@ export function panelRouter(panel, runtime) {
|
|
|
3423
3468
|
};
|
|
3424
3469
|
const tenantSegment = panel.hasTenancy() ? ':tenant/' : '';
|
|
3425
3470
|
if (panel.hasTenancy()) {
|
|
3426
|
-
//
|
|
3471
|
+
// Panel root: the user's first tenant, otherwise tenant registration.
|
|
3427
3472
|
router.get(panel.getBaseUrl(), async (req, res) => {
|
|
3428
3473
|
const ctx = await context(req, res);
|
|
3429
3474
|
if (!ctx.user)
|
|
@@ -3435,7 +3480,7 @@ export function panelRouter(panel, runtime) {
|
|
|
3435
3480
|
return void res.status(404).send('Not Found');
|
|
3436
3481
|
res.redirect(url);
|
|
3437
3482
|
});
|
|
3438
|
-
//
|
|
3483
|
+
// Tenant root without a dashboard: the first menu page.
|
|
3439
3484
|
if (!panel.getPages().some((page) => page.slug === '')) {
|
|
3440
3485
|
router.get(panel.getBaseUrl(':tenant'), async (req, res, next) => {
|
|
3441
3486
|
if (RESERVED_SEGMENTS.has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug)
|
|
@@ -3453,7 +3498,7 @@ export function panelRouter(panel, runtime) {
|
|
|
3453
3498
|
for (const PageClass of panel.getPages()) {
|
|
3454
3499
|
const component = name(`pages.${(PageClass.slug || 'dashboard').replace(/[{}]/g, '')}`);
|
|
3455
3500
|
runtime.register(component, PageClass);
|
|
3456
|
-
// `{record}`
|
|
3501
|
+
// `{record}` in the slug becomes a route parameter, passed to mount().
|
|
3457
3502
|
router.get(panel.getBaseUrl(tenantSegment + PageClass.slug).replace(/\{(\w+)\}/g, ':$1'), async (req, res, next) => {
|
|
3458
3503
|
if (panel.hasTenancy() && (RESERVED_SEGMENTS.has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug))
|
|
3459
3504
|
return next();
|
|
@@ -3466,7 +3511,7 @@ export function panelRouter(panel, runtime) {
|
|
|
3466
3511
|
}
|
|
3467
3512
|
return router;
|
|
3468
3513
|
}
|
|
3469
|
-
/**
|
|
3514
|
+
/** Each auth feature requires some auth provider methods: fail at startup rather than on first click. */
|
|
3470
3515
|
function assertAuthCapabilities(panel) {
|
|
3471
3516
|
if (!panel.hasRegistration() && !panel.hasPasswordReset() && !panel.hasEmailVerification() && !panel.hasProfile())
|
|
3472
3517
|
return;
|
|
@@ -3474,14 +3519,14 @@ function assertAuthCapabilities(panel) {
|
|
|
3474
3519
|
const missing = (feature, methods) => {
|
|
3475
3520
|
const absent = methods.filter((method) => typeof auth[method] !== 'function');
|
|
3476
3521
|
if (absent.length)
|
|
3477
|
-
throw new Error(`${feature}
|
|
3522
|
+
throw new Error(`${feature}: the auth provider must implement ${absent.join(', ')} (knexAuthProvider does).`);
|
|
3478
3523
|
};
|
|
3479
3524
|
if (panel.hasRegistration())
|
|
3480
|
-
missing('
|
|
3525
|
+
missing('Registration', ['create', 'findByEmail']);
|
|
3481
3526
|
if (panel.hasPasswordReset())
|
|
3482
|
-
missing('
|
|
3527
|
+
missing('Password reset', ['findByEmail', 'update', 'getPasswordFingerprint']);
|
|
3483
3528
|
if (panel.hasEmailVerification())
|
|
3484
|
-
missing('
|
|
3529
|
+
missing('Email verification', ['hasVerifiedEmail', 'markEmailAsVerified']);
|
|
3485
3530
|
if (panel.hasProfile())
|
|
3486
|
-
missing('
|
|
3531
|
+
missing('Profile', ['update']);
|
|
3487
3532
|
}
|