@navette-js/panels 0.0.0-stage → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +8 -2
- package/THIRD-PARTY-NOTICES.md +12 -0
- package/dist/FILAMENT-LICENSE.md +21 -0
- package/dist/filament/css/filament/filament/app.css +2 -0
- package/dist/filament/fonts/INTER-LICENSE.txt +92 -0
- package/dist/filament/fonts/filament/filament/inter/index.css +1 -0
- package/dist/filament/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-SP7Z6XGK.woff2 +0 -0
- package/dist/filament/fonts/filament/filament/inter/inter-cyrillic-wght-normal-DR6K5BQD.woff2 +0 -0
- package/dist/filament/fonts/filament/filament/inter/inter-greek-ext-wght-normal-A2J6H3EX.woff2 +0 -0
- package/dist/filament/fonts/filament/filament/inter/inter-greek-wght-normal-ZABHMKQG.woff2 +0 -0
- package/dist/filament/fonts/filament/filament/inter/inter-latin-ext-wght-normal-ZIT2UBIY.woff2 +0 -0
- package/dist/filament/fonts/filament/filament/inter/inter-latin-wght-normal-DIHYUR35.woff2 +0 -0
- package/dist/filament/fonts/filament/filament/inter/inter-vietnamese-wght-normal-VWEHJHBA.woff2 +0 -0
- package/dist/filament/js/filament/actions/actions.js +1 -0
- package/dist/filament/js/filament/filament/app.js +1 -0
- package/dist/filament/js/filament/filament/echo.js +13 -0
- package/dist/filament/js/filament/forms/components/builder.js +1 -0
- package/dist/filament/js/filament/forms/components/checkbox-list.js +1 -0
- package/dist/filament/js/filament/forms/components/code-editor.js +38 -0
- package/dist/filament/js/filament/forms/components/color-picker.js +1 -0
- package/dist/filament/js/filament/forms/components/date-time-picker.js +1 -0
- package/dist/filament/js/filament/forms/components/file-upload.js +116 -0
- package/dist/filament/js/filament/forms/components/js-field.js +1 -0
- package/dist/filament/js/filament/forms/components/key-value.js +1 -0
- package/dist/filament/js/filament/forms/components/markdown-editor.js +51 -0
- package/dist/filament/js/filament/forms/components/repeater.js +1 -0
- package/dist/filament/js/filament/forms/components/rich-editor.js +161 -0
- package/dist/filament/js/filament/forms/components/select.js +11 -0
- package/dist/filament/js/filament/forms/components/slider.js +1 -0
- package/dist/filament/js/filament/forms/components/tags-input.js +1 -0
- package/dist/filament/js/filament/forms/components/text-input/password-reveal.js +1 -0
- package/dist/filament/js/filament/forms/components/textarea.js +1 -0
- package/dist/filament/js/filament/forms/components/toggle-buttons.js +1 -0
- package/dist/filament/js/filament/notifications/notifications.js +1 -0
- package/dist/filament/js/filament/schemas/components/actions.js +1 -0
- package/dist/filament/js/filament/schemas/components/js-component.js +1 -0
- package/dist/filament/js/filament/schemas/components/tabs.js +1 -0
- package/dist/filament/js/filament/schemas/components/wizard.js +1 -0
- package/dist/filament/js/filament/schemas/schemas.js +1 -0
- package/dist/filament/js/filament/support/support.js +46 -0
- package/dist/filament/js/filament/tables/components/columns/checkbox.js +1 -0
- package/dist/filament/js/filament/tables/components/columns/select.js +11 -0
- package/dist/filament/js/filament/tables/components/columns/text-input.js +1 -0
- package/dist/filament/js/filament/tables/components/columns/toggle.js +1 -0
- package/dist/filament/js/filament/tables/tables.js +1 -0
- package/dist/filament/js/filament/widgets/components/chart.js +31 -0
- package/dist/filament/js/filament/widgets/components/js-widget.js +1 -0
- package/dist/filament/js/filament/widgets/components/stats-overview/stat/chart.js +20 -0
- package/dist/navette/navette/calendar.css +108 -0
- package/dist/navette/navette/motion.css +101 -0
- package/dist/navette/navette/motion.js +49 -0
- package/lib/auth.d.ts +61 -0
- package/lib/auth.js +142 -0
- package/lib/calendar.d.ts +82 -0
- package/lib/calendar.js +107 -0
- package/lib/index.d.ts +1619 -0
- package/lib/index.js +3487 -0
- package/lib/notifications.d.ts +76 -0
- package/lib/notifications.js +112 -0
- package/lib/topbar.d.ts +90 -0
- package/lib/topbar.js +241 -0
- package/package.json +59 -4
- package/resources/filament/LICENSE.md +21 -0
- package/resources/filament/README.md +2 -0
- package/resources/filament/packages/actions/resources/css/actions.css +30 -0
- package/resources/filament/packages/actions/resources/css/index.css +1 -0
- package/resources/filament/packages/forms/dist/index.css +1 -0
- package/resources/filament/packages/forms/resources/css/components/builder.css +206 -0
- package/resources/filament/packages/forms/resources/css/components/checkbox-list.css +55 -0
- package/resources/filament/packages/forms/resources/css/components/code-editor.css +79 -0
- package/resources/filament/packages/forms/resources/css/components/color-picker.css +17 -0
- package/resources/filament/packages/forms/resources/css/components/date-time-picker.css +127 -0
- package/resources/filament/packages/forms/resources/css/components/field.css +88 -0
- package/resources/filament/packages/forms/resources/css/components/file-upload/filepond-plugin-media-preview.css +104 -0
- package/resources/filament/packages/forms/resources/css/components/file-upload.css +199 -0
- package/resources/filament/packages/forms/resources/css/components/key-value.css +57 -0
- package/resources/filament/packages/forms/resources/css/components/markdown-editor.css +343 -0
- package/resources/filament/packages/forms/resources/css/components/modal-table-select.css +17 -0
- package/resources/filament/packages/forms/resources/css/components/radio.css +41 -0
- package/resources/filament/packages/forms/resources/css/components/repeater.css +401 -0
- package/resources/filament/packages/forms/resources/css/components/rich-editor.css +666 -0
- package/resources/filament/packages/forms/resources/css/components/select.css +13 -0
- package/resources/filament/packages/forms/resources/css/components/slider.css +54 -0
- package/resources/filament/packages/forms/resources/css/components/tags-input.css +33 -0
- package/resources/filament/packages/forms/resources/css/components/text-input.css +20 -0
- package/resources/filament/packages/forms/resources/css/components/textarea.css +26 -0
- package/resources/filament/packages/forms/resources/css/components/toggle-buttons.css +49 -0
- package/resources/filament/packages/forms/resources/css/index.css +32 -0
- package/resources/filament/packages/infolists/resources/css/components/code.css +21 -0
- package/resources/filament/packages/infolists/resources/css/components/color.css +34 -0
- package/resources/filament/packages/infolists/resources/css/components/entry.css +68 -0
- package/resources/filament/packages/infolists/resources/css/components/icon.css +39 -0
- package/resources/filament/packages/infolists/resources/css/components/image.css +161 -0
- package/resources/filament/packages/infolists/resources/css/components/key-value.css +24 -0
- package/resources/filament/packages/infolists/resources/css/components/repeatable.css +136 -0
- package/resources/filament/packages/infolists/resources/css/components/text.css +234 -0
- package/resources/filament/packages/infolists/resources/css/index.css +14 -0
- package/resources/filament/packages/notifications/resources/css/database-notifications.css +56 -0
- package/resources/filament/packages/notifications/resources/css/index.css +3 -0
- package/resources/filament/packages/notifications/resources/css/notification.css +109 -0
- package/resources/filament/packages/notifications/resources/css/notifications.css +29 -0
- package/resources/filament/packages/panels/resources/css/base.css +13 -0
- package/resources/filament/packages/panels/resources/css/components/global-search.css +93 -0
- package/resources/filament/packages/panels/resources/css/components/header.css +56 -0
- package/resources/filament/packages/panels/resources/css/components/layout.css +182 -0
- package/resources/filament/packages/panels/resources/css/components/logo.css +11 -0
- package/resources/filament/packages/panels/resources/css/components/page.css +60 -0
- package/resources/filament/packages/panels/resources/css/components/sidebar.css +311 -0
- package/resources/filament/packages/panels/resources/css/components/tenant-menu.css +49 -0
- package/resources/filament/packages/panels/resources/css/components/theme-switcher.css +15 -0
- package/resources/filament/packages/panels/resources/css/components/topbar.css +89 -0
- package/resources/filament/packages/panels/resources/css/components/user-menu.css +46 -0
- package/resources/filament/packages/panels/resources/css/index.css +40 -0
- package/resources/filament/packages/panels/resources/css/theme.css +2 -0
- package/resources/filament/packages/panels/resources/css/utilities.css +3 -0
- package/resources/filament/packages/panels/resources/css/widgets/account-widget.css +21 -0
- package/resources/filament/packages/panels/resources/css/widgets/filament-info-widget.css +21 -0
- package/resources/filament/packages/schemas/resources/css/components/actions.css +29 -0
- package/resources/filament/packages/schemas/resources/css/components/flex.css +106 -0
- package/resources/filament/packages/schemas/resources/css/components/form.css +7 -0
- package/resources/filament/packages/schemas/resources/css/components/fused-group.css +337 -0
- package/resources/filament/packages/schemas/resources/css/components/icon.css +11 -0
- package/resources/filament/packages/schemas/resources/css/components/image.css +12 -0
- package/resources/filament/packages/schemas/resources/css/components/section.css +24 -0
- package/resources/filament/packages/schemas/resources/css/components/tabs.css +77 -0
- package/resources/filament/packages/schemas/resources/css/components/text.css +77 -0
- package/resources/filament/packages/schemas/resources/css/components/unordered-list.css +15 -0
- package/resources/filament/packages/schemas/resources/css/components/wizard.css +177 -0
- package/resources/filament/packages/schemas/resources/css/index.css +12 -0
- package/resources/filament/packages/schemas/resources/css/schema.css +132 -0
- package/resources/filament/packages/support/dist/index.css +1 -0
- package/resources/filament/packages/support/resources/css/base.css +23 -0
- package/resources/filament/packages/support/resources/css/components/avatar.css +15 -0
- package/resources/filament/packages/support/resources/css/components/badge.css +90 -0
- package/resources/filament/packages/support/resources/css/components/breadcrumbs.css +25 -0
- package/resources/filament/packages/support/resources/css/components/button.css +214 -0
- package/resources/filament/packages/support/resources/css/components/callout.css +57 -0
- package/resources/filament/packages/support/resources/css/components/dropdown/header.css +21 -0
- package/resources/filament/packages/support/resources/css/components/dropdown/index.css +79 -0
- package/resources/filament/packages/support/resources/css/components/dropdown/list/index.css +7 -0
- package/resources/filament/packages/support/resources/css/components/dropdown/list/item.css +74 -0
- package/resources/filament/packages/support/resources/css/components/empty-state.css +67 -0
- package/resources/filament/packages/support/resources/css/components/fieldset.css +23 -0
- package/resources/filament/packages/support/resources/css/components/grid.css +511 -0
- package/resources/filament/packages/support/resources/css/components/icon-button.css +124 -0
- package/resources/filament/packages/support/resources/css/components/icon.css +31 -0
- package/resources/filament/packages/support/resources/css/components/input/checkbox.css +32 -0
- package/resources/filament/packages/support/resources/css/components/input/index.css +55 -0
- package/resources/filament/packages/support/resources/css/components/input/one-time-code.css +7 -0
- package/resources/filament/packages/support/resources/css/components/input/radio.css +11 -0
- package/resources/filament/packages/support/resources/css/components/input/select.css +154 -0
- package/resources/filament/packages/support/resources/css/components/input/wrapper.css +107 -0
- package/resources/filament/packages/support/resources/css/components/link.css +90 -0
- package/resources/filament/packages/support/resources/css/components/loading-indicator.css +3 -0
- package/resources/filament/packages/support/resources/css/components/loading-section.css +3 -0
- package/resources/filament/packages/support/resources/css/components/modal.css +494 -0
- package/resources/filament/packages/support/resources/css/components/pagination.css +160 -0
- package/resources/filament/packages/support/resources/css/components/section.css +234 -0
- package/resources/filament/packages/support/resources/css/components/tabs.css +70 -0
- package/resources/filament/packages/support/resources/css/components/toggle.css +60 -0
- package/resources/filament/packages/support/resources/css/components/tooltip.css +36 -0
- package/resources/filament/packages/support/resources/css/index.css +133 -0
- package/resources/filament/packages/support/resources/css/sortable.css +3 -0
- package/resources/filament/packages/support/resources/css/utilities.css +897 -0
- package/resources/filament/packages/tables/resources/css/actions.css +51 -0
- package/resources/filament/packages/tables/resources/css/cell.css +139 -0
- package/resources/filament/packages/tables/resources/css/column-manager.css +48 -0
- package/resources/filament/packages/tables/resources/css/columns/checkbox.css +54 -0
- package/resources/filament/packages/tables/resources/css/columns/color.css +76 -0
- package/resources/filament/packages/tables/resources/css/columns/icon.css +81 -0
- package/resources/filament/packages/tables/resources/css/columns/image.css +207 -0
- package/resources/filament/packages/tables/resources/css/columns/layout/grid.css +49 -0
- package/resources/filament/packages/tables/resources/css/columns/layout/panel.css +3 -0
- package/resources/filament/packages/tables/resources/css/columns/layout/split.css +35 -0
- package/resources/filament/packages/tables/resources/css/columns/layout/stack.css +29 -0
- package/resources/filament/packages/tables/resources/css/columns/select.css +36 -0
- package/resources/filament/packages/tables/resources/css/columns/summaries/icon-count.css +43 -0
- package/resources/filament/packages/tables/resources/css/columns/summaries/range.css +27 -0
- package/resources/filament/packages/tables/resources/css/columns/summaries/text.css +29 -0
- package/resources/filament/packages/tables/resources/css/columns/summaries/values.css +33 -0
- package/resources/filament/packages/tables/resources/css/columns/text-input.css +36 -0
- package/resources/filament/packages/tables/resources/css/columns/text.css +319 -0
- package/resources/filament/packages/tables/resources/css/columns/toggle.css +54 -0
- package/resources/filament/packages/tables/resources/css/container.css +311 -0
- package/resources/filament/packages/tables/resources/css/content.css +342 -0
- package/resources/filament/packages/tables/resources/css/empty-state.css +31 -0
- package/resources/filament/packages/tables/resources/css/group-header.css +54 -0
- package/resources/filament/packages/tables/resources/css/header-cell.css +224 -0
- package/resources/filament/packages/tables/resources/css/index.css +26 -0
- package/resources/filament/packages/tables/resources/css/row.css +47 -0
- package/resources/filament/packages/tables/resources/css/table.css +394 -0
- package/resources/filament/packages/widgets/resources/css/chart-widget.css +88 -0
- package/resources/filament/packages/widgets/resources/css/index.css +3 -0
- package/resources/filament/packages/widgets/resources/css/stats-overview-widget.css +67 -0
- package/resources/filament/packages/widgets/resources/css/widget.css +3 -0
- package/theme.css +17 -0
- package/views/components/logo.ejs +9 -0
- package/views/components/tenant-menu.ejs +13 -0
- package/views/components/user-menu.ejs +82 -0
- package/views/layout/base.ejs +129 -0
- package/views/layout/index.ejs +41 -0
- package/views/layout/simple.ejs +19 -0
- package/views/livewire/sidebar.ejs +123 -0
- package/views/livewire/topbar.ejs +47 -0
- package/views/pages/page.ejs +143 -0
- package/views/pages/simple.ejs +32 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,3487 @@
|
|
|
1
|
+
// @navette-js/panels — port de filament/panels : Panel, mises en page, pages, navigation, connexion, session.
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { checkVerificationUrl, consoleMailer, readToken, signToken, signVerificationUrl } from "./auth.js";
|
|
5
|
+
export { consoleMailer, fileMailer, hashPassword, knexAuthProvider, knexTenantProvider, mailText, verifyPassword } from "./auth.js";
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import express from 'express';
|
|
9
|
+
import { Action, useColorManager, ActionGroup } from '@navette-js/actions';
|
|
10
|
+
import { Checkbox, Field, Select, TextInput, Wizard, richDocumentToHtml, richHtmlToDocument, fillSchema, fillSchemaFromRecord, getSchemaState, validateSchema, useColorManager as useFormsColorManager } from '@navette-js/forms';
|
|
11
|
+
import { Actions, Component as SchemaComponent, EmbeddedSchema, Form, Group, Html, RenderHook, Schema, StatefulGroup } from '@navette-js/schemas';
|
|
12
|
+
import { WireError } from '@navette-js/wire';
|
|
13
|
+
import { useColorManager as useInfolistsColorManager, useHtmlSanitizer } from '@navette-js/infolists';
|
|
14
|
+
import { Notification, Notifications, publish, runInRequest } from "./notifications.js";
|
|
15
|
+
export { Notification, Notifications } from "./notifications.js";
|
|
16
|
+
import { addDays, addMonths, CALENDAR_LABELS, CALENDAR_VIEWS, isDate, renderCalendar, visibleRange } from "./calendar.js";
|
|
17
|
+
export { addDays, addMonths, CALENDAR_LABELS, dayOf, daysBetween, eventsOn, renderCalendar, renderCalendarEvent, timeOf, visibleRange } from "./calendar.js";
|
|
18
|
+
import { DatabaseNotifications, GlobalSearch, keyBindingSuffix } from "./topbar.js";
|
|
19
|
+
export { DatabaseNotifications, GlobalSearch, knexNotificationStore, timeAgo } from "./topbar.js";
|
|
20
|
+
import { Attributes, ColorManager, HtmlString, RenderHookRegistry, cls, convertToRgb, e, icon, js, loadingIndicator, renderHook, runWithRenderHooks, views } from '@navette-js/support';
|
|
21
|
+
import { t, tc } from '@navette-js/support/i18n';
|
|
22
|
+
import { emptyState } from '@navette-js/support/ui';
|
|
23
|
+
import { REORDER_LIMIT, Table, initializeTableGrouping, updateTableColumnState as updateEditableColumnState, getTableQuery, renderTable, sanitizeColumnState, sortTable, useColorManager as useTableColorManager } from '@navette-js/tables';
|
|
24
|
+
import { Component } from '@navette-js/wire';
|
|
25
|
+
import { csrfToken, verifyCsrf, wireRouter, wireScripts, wireStyles } from '@navette-js/wire/express';
|
|
26
|
+
const dir = (p) => fileURLToPath(new URL(p, import.meta.url));
|
|
27
|
+
views.namespace('filament-panels', dir('../views'));
|
|
28
|
+
views.namespace('filament', fileURLToPath(new URL('../views', import.meta.resolve('@navette-js/support'))));
|
|
29
|
+
views.share({ t, tc });
|
|
30
|
+
export const ASSET_VERSION = '5.11.2.0';
|
|
31
|
+
// ------------------------------------------------------------------ navigation
|
|
32
|
+
export class NavigationItem {
|
|
33
|
+
label;
|
|
34
|
+
_url = '#';
|
|
35
|
+
_icon = null;
|
|
36
|
+
_group = null;
|
|
37
|
+
_sort = 0;
|
|
38
|
+
_isActive = null;
|
|
39
|
+
_badge = null;
|
|
40
|
+
_badgeColor = 'primary';
|
|
41
|
+
constructor(label) { this.label = label; }
|
|
42
|
+
static make(label) { return new NavigationItem(label); }
|
|
43
|
+
url(url) { this._url = url; return this; }
|
|
44
|
+
icon(icon) { this._icon = icon; return this; }
|
|
45
|
+
group(group) { this._group = group; return this; }
|
|
46
|
+
sort(sort) { this._sort = sort; return this; }
|
|
47
|
+
badge(badge, color = 'primary') { this._badge = badge; this._badgeColor = color; return this; }
|
|
48
|
+
/** Par défaut un élément est actif sur son URL exacte, comme une page Filament (les resources fourniront leur propre règle). */
|
|
49
|
+
isActiveWhen(fn) { this._isActive = fn; return this; }
|
|
50
|
+
isActive(path) { return this._isActive ? this._isActive(path) : path === this._url; }
|
|
51
|
+
}
|
|
52
|
+
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
53
|
+
// ------------------------------------------------------------------ panel
|
|
54
|
+
export class Panel {
|
|
55
|
+
_id = 'admin';
|
|
56
|
+
_path = '';
|
|
57
|
+
_brandName = 'Navette';
|
|
58
|
+
_brandLogo = null;
|
|
59
|
+
_darkModeBrandLogo = null;
|
|
60
|
+
_brandLogoHeight = '1.5rem';
|
|
61
|
+
_favicon = null;
|
|
62
|
+
_theme = null;
|
|
63
|
+
_motion = true;
|
|
64
|
+
_themes = [];
|
|
65
|
+
_font = { family: 'Inter Variable', url: null, preconnect: null };
|
|
66
|
+
_login = null;
|
|
67
|
+
_auth = null;
|
|
68
|
+
_key = 'cle-de-developpement-a-remplacer';
|
|
69
|
+
_pages = [];
|
|
70
|
+
_resources = [];
|
|
71
|
+
_navigationItems = [];
|
|
72
|
+
_navigationGroups = [];
|
|
73
|
+
_maxContentWidth = '7xl';
|
|
74
|
+
colors = new ColorManager();
|
|
75
|
+
static make() { return new Panel(); }
|
|
76
|
+
id(id) { this._id = id; return this; }
|
|
77
|
+
path(path) { this._path = path.replace(/^\/|\/$/g, ''); return this; }
|
|
78
|
+
brandName(name) { this._brandName = name; return this; }
|
|
79
|
+
brandLogo(url) { this._brandLogo = url; return this; }
|
|
80
|
+
darkModeBrandLogo(url) { this._darkModeBrandLogo = url; return this; }
|
|
81
|
+
brandLogoHeight(height) { this._brandLogoHeight = height; return this; }
|
|
82
|
+
/** Icône d'onglet du navigateur. */
|
|
83
|
+
favicon(url) { this._favicon = url; return this; }
|
|
84
|
+
getFavicon() { return this._favicon; }
|
|
85
|
+
/**
|
|
86
|
+
* Feuille de thème compilée par l'application (Tailwind) : elle remplace la feuille par défaut de Filament.
|
|
87
|
+
* Équivalent de `->viteTheme()` : le thème importe `@navette-js/panels/theme.css` et déclare ses propres sources.
|
|
88
|
+
*/
|
|
89
|
+
theme(url) { this._theme = url; return this; }
|
|
90
|
+
getTheme() { return this._theme; }
|
|
91
|
+
/**
|
|
92
|
+
* Mouvement et indicateurs de chargement propres à Navette (ressorts, pastille d'activité, roue des zones différées).
|
|
93
|
+
* C'est un écart assumé avec Filament : `motion(false)` rend l'interface d'origine, sans rien d'ajouté.
|
|
94
|
+
*/
|
|
95
|
+
motion(enabled = true) { this._motion = enabled; return this; }
|
|
96
|
+
hasMotion() { return this._motion; }
|
|
97
|
+
/** Thèmes proposés à l'utilisateur ; le premier est le thème par défaut. */
|
|
98
|
+
themes(themes) {
|
|
99
|
+
for (const theme of themes)
|
|
100
|
+
if (!/^[a-z0-9-]{1,40}$/.test(theme.id))
|
|
101
|
+
throw new Error(`Identifiant de thème invalide : ${theme.id}`);
|
|
102
|
+
this._themes = themes;
|
|
103
|
+
return this;
|
|
104
|
+
}
|
|
105
|
+
getThemes() { return this._themes; }
|
|
106
|
+
/** Thème de la requête : celui du cookie s'il est déclaré, sinon le premier. Null si le panel n'a pas de thèmes. */
|
|
107
|
+
resolveTheme(req) {
|
|
108
|
+
if (!this._themes.length)
|
|
109
|
+
return null;
|
|
110
|
+
const id = (req.headers.cookie ?? '').split(';').map((c) => c.trim()).find((c) => c.startsWith(`navette_${this._id}_theme=`))?.split('=')[1];
|
|
111
|
+
return this._themes.find((theme) => theme.id === id) ?? this._themes[0];
|
|
112
|
+
}
|
|
113
|
+
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
|
+
/** Exécute un rendu avec les couleurs du thème de la requête. */
|
|
115
|
+
withTheme(req, fn) {
|
|
116
|
+
const theme = this.resolveTheme(req);
|
|
117
|
+
// Les composants des autres paquets lisent un gestionnaire de couleurs partagé par tous les panels :
|
|
118
|
+
// les couleurs de ce panel (et de son thème) n'y valent que le temps de la requête.
|
|
119
|
+
const withPanelColors = () => sharedColors.runWith(`${this._id}:${theme?.id ?? ''}`, this.colors.getColors(), fn);
|
|
120
|
+
// Les render hooks du panel ne valent que pour ses propres requêtes.
|
|
121
|
+
return runWithRenderHooks(this._renderHooks, () => (theme?.colors ? this.colors.runWith(theme.id, theme.colors, withPanelColors) : withPanelColors()));
|
|
122
|
+
}
|
|
123
|
+
setColors(colors) { this.colors.register(colors); return this; }
|
|
124
|
+
/** Police du panel. Sans URL : fournisseur Bunny Fonts, comme `->font('Albert Sans')` dans Filament. */
|
|
125
|
+
font(family, url) {
|
|
126
|
+
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
|
+
return this;
|
|
128
|
+
}
|
|
129
|
+
login(page = Login) { this._login = page; return this; }
|
|
130
|
+
auth(provider) { this._auth = provider; return this; }
|
|
131
|
+
key(key) { this._key = key; return this; }
|
|
132
|
+
pages(pages) { this._pages.push(...pages); return this; }
|
|
133
|
+
/** Enregistre des resources : leurs pages et leur élément de navigation. */
|
|
134
|
+
resources(resources) {
|
|
135
|
+
for (const resource of resources) {
|
|
136
|
+
resource.panel = this;
|
|
137
|
+
this._resources.push(resource);
|
|
138
|
+
this._pages.push(...Object.values(resource.getPages()));
|
|
139
|
+
}
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
navigationItems(items) { this._navigationItems.push(...items); return this; }
|
|
143
|
+
/** Ordre des groupes de navigation. */
|
|
144
|
+
navigationGroups(groups) { this._navigationGroups = groups; return this; }
|
|
145
|
+
_plugins = [];
|
|
146
|
+
/** Applique un plugin : un objet qui configure le panel (resources, pages, render hooks, thèmes…). */
|
|
147
|
+
plugin(plugin) {
|
|
148
|
+
if (this._plugins.some((p) => p.id === plugin.id))
|
|
149
|
+
throw new Error(`Plugin déjà enregistré : ${plugin.id}`);
|
|
150
|
+
this._plugins.push(plugin);
|
|
151
|
+
plugin.register(this);
|
|
152
|
+
return this;
|
|
153
|
+
}
|
|
154
|
+
plugins(plugins) { for (const plugin of plugins)
|
|
155
|
+
this.plugin(plugin); return this; }
|
|
156
|
+
getPlugins() { return this._plugins; }
|
|
157
|
+
_renderHooks = new RenderHookRegistry();
|
|
158
|
+
/**
|
|
159
|
+
* Insère du HTML à un emplacement nommé de l'interface de ce panel (noms : `PanelsRenderHook`, `TablesRenderHook`, `ActionsRenderHook`, `WidgetsRenderHook`).
|
|
160
|
+
* `scopes` limite le hook à certaines pages ou resources (leur classe). Le HTML rendu est inséré tel quel : échappez toute donnée avec `e()`.
|
|
161
|
+
*/
|
|
162
|
+
renderHook(name, render, scopes = null) { this._renderHooks.register(name, render, scopes); return this; }
|
|
163
|
+
getRenderHooks() { return this._renderHooks; }
|
|
164
|
+
_authorizer = null;
|
|
165
|
+
/** Branche votre propre système de permissions : consulté avant toute politique, pour chaque capacité, page et widget. */
|
|
166
|
+
authorizeUsing(authorizer) { this._authorizer = authorizer; return this; }
|
|
167
|
+
/** Décision d'autorisation : le crochet du panel d'abord, puis la règle fournie ; sans utilisateur, toujours non. */
|
|
168
|
+
async authorize(user, ability, subject = {}) {
|
|
169
|
+
if (!user)
|
|
170
|
+
return false;
|
|
171
|
+
const decided = await this._authorizer?.({ user, ability, resource: subject.resource ?? null, record: subject.record ?? null, page: subject.page ?? null, widget: subject.widget ?? null });
|
|
172
|
+
if (typeof decided === 'boolean')
|
|
173
|
+
return decided;
|
|
174
|
+
return subject.rule ? Boolean(await subject.rule()) : true;
|
|
175
|
+
}
|
|
176
|
+
/** Une page est-elle accessible à cet utilisateur ? Liste : `viewAny` ; création : `create` ; édition : vérifiée sur l'enregistrement ; page libre : `canAccess`. */
|
|
177
|
+
async canAccessPage(PageClass, user) {
|
|
178
|
+
const resource = PageClass.resource;
|
|
179
|
+
if (resource && PageClass.prototype instanceof EditRecord)
|
|
180
|
+
return user !== null;
|
|
181
|
+
if (resource && PageClass.prototype instanceof CreateRecord)
|
|
182
|
+
return resource.can('create', user);
|
|
183
|
+
if (resource)
|
|
184
|
+
return resource.can('viewAny', user);
|
|
185
|
+
return this.authorize(user, 'access', { page: PageClass, rule: () => PageClass.canAccess(user) });
|
|
186
|
+
}
|
|
187
|
+
_widgets = [];
|
|
188
|
+
/** Widgets du panel (tableau de bord, pages libres) : enregistrés comme composants, à afficher par `getHeaderWidgets()` d'une page. */
|
|
189
|
+
widgets(widgets) { this._widgets.push(...widgets); return this; }
|
|
190
|
+
getWidgets() { return this._widgets; }
|
|
191
|
+
maxContentWidth(width) { this._maxContentWidth = width; return this; }
|
|
192
|
+
getId() { return this._id; }
|
|
193
|
+
getBrandName() { return this._brandName; }
|
|
194
|
+
getAuth() { if (!this._auth)
|
|
195
|
+
throw new Error('Aucun fournisseur d\'authentification : panel.auth(...).'); return this._auth; }
|
|
196
|
+
getLoginPage() { return this._login; }
|
|
197
|
+
getPages() { return this._pages; }
|
|
198
|
+
getResources() { return this._resources; }
|
|
199
|
+
// -------------------------------------------------------------- recherche globale
|
|
200
|
+
_globalSearch = true;
|
|
201
|
+
_globalSearchKeyBindings = [];
|
|
202
|
+
_globalSearchDebounce = '500ms';
|
|
203
|
+
_globalSearchSuffix = null;
|
|
204
|
+
/** `false` retire le champ de recherche globale. Il n'apparaît de toute façon que si une resource est cherchable. */
|
|
205
|
+
globalSearch(condition = true) { this._globalSearch = condition; return this; }
|
|
206
|
+
/** Raccourcis qui placent le curseur dans le champ : `['command+k', 'ctrl+k']`. */
|
|
207
|
+
globalSearchKeyBindings(keyBindings) { this._globalSearchKeyBindings = keyBindings.filter((binding) => /^[a-z0-9+]+$/.test(binding)); return this; }
|
|
208
|
+
getGlobalSearchKeyBindings() { return this._globalSearchKeyBindings; }
|
|
209
|
+
/** Délai entre la frappe et la recherche (`500ms` par défaut). */
|
|
210
|
+
globalSearchDebounce(debounce) { if (/^[0-9]+m?s$/.test(debounce))
|
|
211
|
+
this._globalSearchDebounce = debounce; return this; }
|
|
212
|
+
getGlobalSearchDebounce() { return this._globalSearchDebounce; }
|
|
213
|
+
globalSearchFieldSuffix(suffix) { this._globalSearchSuffix = suffix; return this; }
|
|
214
|
+
/** Affiche le premier raccourci dans le champ, écrit selon la plateforme du navigateur (comme Filament : « META+K » hors Mac pour `command+k`). */
|
|
215
|
+
globalSearchFieldKeyBindingSuffix() { this._globalSearchSuffix = (userAgent) => keyBindingSuffix(this._globalSearchKeyBindings, userAgent); return this; }
|
|
216
|
+
getGlobalSearchFieldSuffix(userAgent = '') { return typeof this._globalSearchSuffix === 'function' ? this._globalSearchSuffix(userAgent) : this._globalSearchSuffix; }
|
|
217
|
+
hasGlobalSearch() { return this._globalSearch && this._resources.some((resource) => resource.canGloballySearch()); }
|
|
218
|
+
/** Résultats par resource, dans l'ordre d'enregistrement ; seules les resources que l'utilisateur peut lister sont interrogées. */
|
|
219
|
+
async getGlobalSearchResults(search, user) {
|
|
220
|
+
const categories = [];
|
|
221
|
+
if (!this._globalSearch)
|
|
222
|
+
return categories;
|
|
223
|
+
const ranked = this._resources.map((resource, index) => ({ resource, index })).sort((a, b) => (a.resource.globalSearchSort ?? Infinity) - (b.resource.globalSearchSort ?? Infinity) || a.index - b.index);
|
|
224
|
+
for (const { resource } of ranked) {
|
|
225
|
+
if (!resource.canGloballySearch() || !(await resource.can('viewAny', user)))
|
|
226
|
+
continue;
|
|
227
|
+
const results = await resource.getGlobalSearchResults(search, user);
|
|
228
|
+
if (results.length)
|
|
229
|
+
categories.push({ label: resource.getPluralModelLabel(), results });
|
|
230
|
+
}
|
|
231
|
+
return categories;
|
|
232
|
+
}
|
|
233
|
+
// -------------------------------------------------------------- notifications en base
|
|
234
|
+
_databaseNotifications = null;
|
|
235
|
+
_databaseNotificationsPolling = '30s';
|
|
236
|
+
/** Active la cloche des notifications enregistrées en base, lues et écrites par ce stockage (`knexNotificationStore(knex)`). */
|
|
237
|
+
databaseNotifications(store) { this._databaseNotifications = store; return this; }
|
|
238
|
+
getDatabaseNotifications() { return this._databaseNotifications; }
|
|
239
|
+
/** Intervalle de rafraîchissement (`30s` par défaut) ; `null` pour ne pas interroger le serveur. */
|
|
240
|
+
databaseNotificationsPolling(interval) { this._databaseNotificationsPolling = interval; return this; }
|
|
241
|
+
getDatabaseNotificationsPollingInterval() { return this._databaseNotificationsPolling; }
|
|
242
|
+
// -------------------------------------------------------------- mise en page : barre du haut, navigation, sidebar
|
|
243
|
+
_topbar = true;
|
|
244
|
+
_topNavigation = false;
|
|
245
|
+
_navigation = true;
|
|
246
|
+
_sidebarCollapsibleOnDesktop = false;
|
|
247
|
+
_sidebarFullyCollapsibleOnDesktop = false;
|
|
248
|
+
_positions = { userMenu: null, globalSearch: null, databaseNotifications: null };
|
|
249
|
+
/** Barre du haut (présente par défaut, comme dans Filament) : `topbar(false)` met logo, recherche, cloche et menu dans la sidebar. */
|
|
250
|
+
topbar(condition = true) { this._topbar = condition; return this; }
|
|
251
|
+
hasTopbar() { return this._topbar; }
|
|
252
|
+
/** Navigation dans la barre du haut (menus déroulants par groupe) au lieu de la sidebar. */
|
|
253
|
+
topNavigation(condition = true) { this._topNavigation = condition; return this; }
|
|
254
|
+
hasTopNavigation() { return this._topNavigation; }
|
|
255
|
+
/** `navigation(false)` retire toute navigation (ni sidebar ni menus). */
|
|
256
|
+
navigation(condition = true) { this._navigation = condition; return this; }
|
|
257
|
+
hasNavigation() { return this._navigation; }
|
|
258
|
+
/** Sidebar repliable sur grand écran en une colonne d'icônes. */
|
|
259
|
+
sidebarCollapsibleOnDesktop(condition = true) { this._sidebarCollapsibleOnDesktop = condition; return this; }
|
|
260
|
+
isSidebarCollapsibleOnDesktop() { return this._sidebarCollapsibleOnDesktop; }
|
|
261
|
+
/** Sidebar entièrement masquable sur grand écran. */
|
|
262
|
+
sidebarFullyCollapsibleOnDesktop(condition = true) { this._sidebarFullyCollapsibleOnDesktop = condition; return this; }
|
|
263
|
+
isSidebarFullyCollapsibleOnDesktop() { return this._sidebarFullyCollapsibleOnDesktop; }
|
|
264
|
+
/** Emplacement du menu utilisateur ; par défaut, la barre du haut si elle existe. */
|
|
265
|
+
userMenuPosition(position) { this._positions.userMenu = position; return this; }
|
|
266
|
+
getUserMenuPosition() { return this._positions.userMenu ?? (this._topbar ? 'topbar' : 'sidebar'); }
|
|
267
|
+
globalSearchPosition(position) { this._positions.globalSearch = position; return this; }
|
|
268
|
+
getGlobalSearchPosition() { return this._positions.globalSearch ?? (this._topbar ? 'topbar' : 'sidebar'); }
|
|
269
|
+
databaseNotificationsPosition(position) { this._positions.databaseNotifications = position; return this; }
|
|
270
|
+
getDatabaseNotificationsPosition() { return this._positions.databaseNotifications ?? (this._topbar ? 'topbar' : 'sidebar'); }
|
|
271
|
+
_userMenuItems = {};
|
|
272
|
+
/** Éléments ajoutés au menu utilisateur (actions à adresse ou à modale). `sort()` négatif : au-dessus du choix du thème. Clés `profile` et `logout` : remplacent ceux par défaut. */
|
|
273
|
+
userMenuItems(items) {
|
|
274
|
+
for (const [key, item] of Array.isArray(items) ? items.map((item) => [item.getName(), item]) : Object.entries(items))
|
|
275
|
+
this._userMenuItems[key] = item;
|
|
276
|
+
return this;
|
|
277
|
+
}
|
|
278
|
+
getUserMenuItems() { return this._userMenuItems; }
|
|
279
|
+
// -------------------------------------------------------------- inscription, mot de passe oublié, vérification, profil
|
|
280
|
+
_registration = null;
|
|
281
|
+
_passwordReset = null;
|
|
282
|
+
_emailVerification = null;
|
|
283
|
+
_profile = null;
|
|
284
|
+
_mailer = consoleMailer;
|
|
285
|
+
/** Page d'inscription (`/register`). Le fournisseur d'utilisateurs doit savoir créer (`create`) et chercher par adresse (`findByEmail`). */
|
|
286
|
+
registration(page = Register) { this._registration = page; return this; }
|
|
287
|
+
hasRegistration() { return this._registration !== null; }
|
|
288
|
+
getRegistrationPage() { return this._registration; }
|
|
289
|
+
/** Mot de passe oublié : demande (`/password-reset/request`) et réinitialisation par un lien signé envoyé par e-mail. */
|
|
290
|
+
passwordReset(request = RequestPasswordReset, reset = ResetPassword) { this._passwordReset = { request, reset }; return this; }
|
|
291
|
+
hasPasswordReset() { return this._passwordReset !== null; }
|
|
292
|
+
getPasswordResetPages() { return this._passwordReset; }
|
|
293
|
+
/** Vérification de l'adresse e-mail : un lien signé est envoyé à l'inscription ; tant qu'il n'est pas suivi, seule l'invitation à vérifier est accessible (`isRequired`). */
|
|
294
|
+
emailVerification(prompt = EmailVerificationPrompt, isRequired = true) { this._emailVerification = { prompt, isRequired }; return this; }
|
|
295
|
+
hasEmailVerification() { return this._emailVerification !== null; }
|
|
296
|
+
isEmailVerificationRequired() { return this._emailVerification?.isRequired === true; }
|
|
297
|
+
getEmailVerificationPrompt() { return this._emailVerification?.prompt ?? null; }
|
|
298
|
+
/** Page de profil (`/profile`) : nom, adresse, mot de passe. `isSimple` : mise en page simple (par défaut) ou complète. */
|
|
299
|
+
profile(page = EditProfile, isSimple = true) { this._profile = { page, isSimple }; return this; }
|
|
300
|
+
hasProfile() { return this._profile !== null; }
|
|
301
|
+
getProfile() { return this._profile; }
|
|
302
|
+
/** Envoi des e-mails (réinitialisation, vérification). Par défaut : écrits dans la console. */
|
|
303
|
+
mailer(mailer) { this._mailer = mailer; return this; }
|
|
304
|
+
async sendMail(message) { await this._mailer.send(message); }
|
|
305
|
+
getAuthKey() { return this._key; }
|
|
306
|
+
getRegistrationUrl() { return this.getBaseUrl('register'); }
|
|
307
|
+
getRequestPasswordResetUrl() { return this.getBaseUrl('password-reset/request'); }
|
|
308
|
+
getResetPasswordUrl(user, fingerprint) {
|
|
309
|
+
const token = signToken(this._key, `${this._id}.password-reset`, { uid: user.id, fp: fingerprint }, 60 * 60);
|
|
310
|
+
return `${this.getBaseUrl('password-reset/reset')}?email=${encodeURIComponent(user.email)}&token=${encodeURIComponent(token)}`;
|
|
311
|
+
}
|
|
312
|
+
/** Charge d'un jeton de réinitialisation valide pour ce panel, sinon `null`. */
|
|
313
|
+
readResetPasswordToken(token) { return readToken(this._key, `${this._id}.password-reset`, token); }
|
|
314
|
+
getVerifyEmailUrl(user) {
|
|
315
|
+
const expires = Math.floor(Date.now() / 1000) + 60 * 60;
|
|
316
|
+
const { hash, signature } = signVerificationUrl(`${this._key}.${this._id}`, user.id, user.email, expires);
|
|
317
|
+
return `${this.getBaseUrl(`email-verification/verify/${encodeURIComponent(String(user.id))}/${hash}`)}?expires=${expires}&signature=${encodeURIComponent(signature)}`;
|
|
318
|
+
}
|
|
319
|
+
checkVerifyEmailUrl(id, hash, expires, signature) { return checkVerificationUrl(`${this._key}.${this._id}`, id, hash, expires, signature); }
|
|
320
|
+
getEmailVerificationPromptUrl() { return this.getBaseUrl('email-verification/prompt'); }
|
|
321
|
+
getProfileUrl() { return this._profile ? this.getBaseUrl('profile') : null; }
|
|
322
|
+
/** L'utilisateur doit-il encore vérifier son adresse avant d'utiliser le panel ? */
|
|
323
|
+
async mustVerifyEmail(user) {
|
|
324
|
+
if (!user || !this.isEmailVerificationRequired())
|
|
325
|
+
return false;
|
|
326
|
+
const auth = this.getAuth();
|
|
327
|
+
return auth.hasVerifiedEmail ? !(await auth.hasVerifiedEmail(user)) : false;
|
|
328
|
+
}
|
|
329
|
+
// -------------------------------------------------------------- locataires (multi-tenancy)
|
|
330
|
+
_tenant = null;
|
|
331
|
+
_tenantOwnershipColumn = null;
|
|
332
|
+
_tenantRegistration = null;
|
|
333
|
+
_tenantProfile = null;
|
|
334
|
+
_tenantMenu = true;
|
|
335
|
+
_tenantMenuItems = {};
|
|
336
|
+
_tenantScope = new AsyncLocalStorage();
|
|
337
|
+
/**
|
|
338
|
+
* Rend le panel multi-locataire. `ownershipColumn` : colonne qui rattache un enregistrement à son locataire (`team_id`) ;
|
|
339
|
+
* les resources du panel ne lisent et n'écrivent alors que les enregistrements du locataire courant (voir `Resource.isScopedToTenant`).
|
|
340
|
+
*/
|
|
341
|
+
tenant(provider, options = {}) { this._tenant = provider; this._tenantOwnershipColumn = options.ownershipColumn ?? null; return this; }
|
|
342
|
+
hasTenancy() { return this._tenant !== null; }
|
|
343
|
+
getTenantProvider() { if (!this._tenant)
|
|
344
|
+
throw new Error('Panel sans locataires : panel.tenant(...).'); return this._tenant; }
|
|
345
|
+
getTenantOwnershipColumn() { return this._tenantOwnershipColumn; }
|
|
346
|
+
/** Page de création d'un locataire (`/new`). */
|
|
347
|
+
tenantRegistration(page) { this._tenantRegistration = page; return this; }
|
|
348
|
+
getTenantRegistrationPage() { return this._tenantRegistration; }
|
|
349
|
+
/** Page de profil du locataire (`/<locataire>/profile`). */
|
|
350
|
+
tenantProfile(page) { this._tenantProfile = page; return this; }
|
|
351
|
+
getTenantProfilePage() { return this._tenantProfile; }
|
|
352
|
+
tenantMenu(condition = true) { this._tenantMenu = condition; return this; }
|
|
353
|
+
hasTenantMenu() { return this._tenantMenu; }
|
|
354
|
+
tenantMenuItems(items) {
|
|
355
|
+
for (const [key, item] of Array.isArray(items) ? items.map((item) => [item.getName(), item]) : Object.entries(items))
|
|
356
|
+
this._tenantMenuItems[key] = item;
|
|
357
|
+
return this;
|
|
358
|
+
}
|
|
359
|
+
getTenantMenuItems() { return this._tenantMenuItems; }
|
|
360
|
+
/** Exécute `fn` pour un locataire : les adresses produites (`getUrl`) et les requêtes des resources lui sont rattachées. */
|
|
361
|
+
withTenant(tenant, fn) { return this._tenantScope.run({ tenant }, fn); }
|
|
362
|
+
/** Locataire de la requête en cours (équivalent de `Filament::getTenant()`), ou `null`. */
|
|
363
|
+
getTenant() { return (this._tenantScope.getStore()?.tenant ?? null); }
|
|
364
|
+
getTenantUrlKey(tenant) { return this.getTenantProvider().getKey(tenant); }
|
|
365
|
+
_subNavigationPosition = 'start';
|
|
366
|
+
/** Position par défaut de la sous-navigation des pages (`start`, comme Filament). */
|
|
367
|
+
subNavigationPosition(position) { this._subNavigationPosition = position; return this; }
|
|
368
|
+
getSubNavigationPosition() { return this._subNavigationPosition; }
|
|
369
|
+
_appUrl = null;
|
|
370
|
+
/** Adresse publique de l'application (`https://exemple.fr`), utilisée dans les liens envoyés par e-mail. Sans elle, l'origine de la requête est reprise : à éviter en production (en-tête `Host` falsifiable). */
|
|
371
|
+
appUrl(url) { this._appUrl = url ? url.replace(/\/$/, '') : null; return this; }
|
|
372
|
+
getAppUrl() { return this._appUrl; }
|
|
373
|
+
getMaxContentWidth() { return this._maxContentWidth; }
|
|
374
|
+
/** Adresse dans le panel ; dans un panel à locataires, préfixée par le locataire courant (ou celui donné). */
|
|
375
|
+
getUrl(sub = '', tenant = this.getTenant()) {
|
|
376
|
+
if (this._tenant && tenant)
|
|
377
|
+
return '/' + [this._path, encodeURIComponent(this.getTenantUrlKey(tenant)), sub.replace(/^\/|\/$/g, '')].filter(Boolean).join('/');
|
|
378
|
+
return this.getBaseUrl(sub);
|
|
379
|
+
}
|
|
380
|
+
/** Adresse hors locataire (connexion, inscription, profil, création d'un locataire). */
|
|
381
|
+
getBaseUrl(sub = '') { return '/' + [this._path, sub.replace(/^\/|\/$/g, '')].filter(Boolean).join('/'); }
|
|
382
|
+
getLoginUrl() { return this.getBaseUrl('login'); }
|
|
383
|
+
getLogoutUrl() { return this.getBaseUrl('logout'); }
|
|
384
|
+
/** Préfixe de l'endpoint Livewire de ce panel (`/livewire`, ou `/<chemin>/livewire`) : chaque panel a le sien. */
|
|
385
|
+
getLivewirePrefix() { return this._path ? `/${this._path}/livewire` : '/livewire'; }
|
|
386
|
+
assetUrl(file) { return `/${file}?v=${ASSET_VERSION}`; }
|
|
387
|
+
getBrand() { return { name: this._brandName, logo: this._brandLogo, darkLogo: this._darkModeBrandLogo, height: this._brandLogoHeight }; }
|
|
388
|
+
getFont() { return this._font; }
|
|
389
|
+
/** Avatar par défaut : port de UiAvatarsProvider (service externe ui-avatars.com, initiales du nom). */
|
|
390
|
+
getUserAvatarUrl(user) { return this.getAvatarUrlFor(user.name); }
|
|
391
|
+
/** Avatar d'un locataire : celui de son fournisseur (`getAvatarUrl`), sinon ses initiales. */
|
|
392
|
+
getTenantAvatarUrl(tenant) { return this.getTenantProvider().getAvatarUrl?.(tenant) ?? this.getAvatarUrlFor(this.getTenantProvider().getName(tenant)); }
|
|
393
|
+
_spa = false;
|
|
394
|
+
/** Navigation sans rechargement (`wire:navigate`) entre les pages du panel, comme `->spa()`. */
|
|
395
|
+
spa(condition = true) { this._spa = condition; return this; }
|
|
396
|
+
hasSpaMode() { return this._spa; }
|
|
397
|
+
getAvatarUrlFor(name) {
|
|
398
|
+
const initials = name.trim().split(' ').map((s) => s.replace(/^[^\p{L}\p{N}]+/u, '').slice(0, 1)).join(' ');
|
|
399
|
+
const background = '#' + convertToRgb(this.colors.getColor('gray')[950]).map((n) => n.toString(16).padStart(2, '0')).join('');
|
|
400
|
+
return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials).replace(/%20/g, '+')}&format=svg&color=FFFFFF&background=${encodeURIComponent(background)}`;
|
|
401
|
+
}
|
|
402
|
+
/** Groupes de navigation : pages enregistrées + éléments libres, triés, groupés dans l'ordre déclaré. */
|
|
403
|
+
/** Compteurs de navigation calculés (équivalent de getNavigationBadge()) : adresse de la page → valeur. */
|
|
404
|
+
async resolveNavigationBadges() {
|
|
405
|
+
const badges = new Map();
|
|
406
|
+
for (const page of this._pages)
|
|
407
|
+
if (page.shouldRegisterNavigation && typeof page.getNavigationBadge === 'function')
|
|
408
|
+
badges.set(page.slug, await page.getNavigationBadge());
|
|
409
|
+
return badges;
|
|
410
|
+
}
|
|
411
|
+
/** Adresses des pages du menu que cet utilisateur ne peut pas ouvrir. */
|
|
412
|
+
async resolveInaccessiblePages(user) {
|
|
413
|
+
const hidden = new Set();
|
|
414
|
+
for (const page of this._pages)
|
|
415
|
+
if (page.shouldRegisterNavigation && !(await this.canAccessPage(page, user)))
|
|
416
|
+
hidden.add(page.slug);
|
|
417
|
+
return hidden;
|
|
418
|
+
}
|
|
419
|
+
getNavigation(path, badges = new Map(), hidden = new Set()) {
|
|
420
|
+
const items = [
|
|
421
|
+
...this._pages.filter((p) => p.shouldRegisterNavigation && !hidden.has(p.slug)).map((p) => {
|
|
422
|
+
const item = NavigationItem.make(p.getNavigationLabel()).url(this.getUrl(p.slug)).icon(p.navigationIcon).group(p.navigationGroup).sort(p.navigationSort).badge(badges.has(p.slug) ? badges.get(p.slug) : p.navigationBadge, p.navigationBadgeColor);
|
|
423
|
+
const base = this.getUrl(p.slug);
|
|
424
|
+
return p.resource ? item.isActiveWhen((current) => current === base || current.startsWith(base + '/')) : item;
|
|
425
|
+
}),
|
|
426
|
+
...this._navigationItems,
|
|
427
|
+
].sort((a, b) => a._sort - b._sort);
|
|
428
|
+
const order = [null, ...this._navigationGroups, ...items.map((i) => i._group).filter((g) => g !== null && !this._navigationGroups.includes(g))];
|
|
429
|
+
return [...new Set(order)].map((label) => {
|
|
430
|
+
const own = items.filter((i) => i._group === label).map((i) => ({ label: i.label, url: i._url, icon: i._icon, isActive: i.isActive(path), badge: i._badge, badgeColorClasses: this.colors.getComponentClasses('badge', i._badgeColor) }));
|
|
431
|
+
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
|
+
}).filter((g) => g.items.length);
|
|
433
|
+
}
|
|
434
|
+
// --- session : cookie signé (identifiant d'utilisateur), suffisant pour le jalon 2
|
|
435
|
+
sign(value) { return createHmac('sha256', this._key).update(value).digest('base64url'); }
|
|
436
|
+
logIn(res, user, remember) {
|
|
437
|
+
const value = Buffer.from(JSON.stringify({ uid: user.id })).toString('base64url');
|
|
438
|
+
res.cookie(`navette_${this._id}_session`, `${value}.${this.sign(value)}`, { httpOnly: true, sameSite: 'lax', secure: res.req?.secure === true, ...(remember ? { maxAge: 1000 * 60 * 60 * 24 * 30 } : {}) });
|
|
439
|
+
}
|
|
440
|
+
logOut(res) { res.clearCookie(`navette_${this._id}_session`); }
|
|
441
|
+
async user(req) {
|
|
442
|
+
const raw = (req.headers.cookie ?? '').split(';').map((c) => c.trim()).find((c) => c.startsWith(`navette_${this._id}_session=`))?.split('=')[1];
|
|
443
|
+
if (!raw)
|
|
444
|
+
return null;
|
|
445
|
+
const [value, mac] = raw.split('.');
|
|
446
|
+
const a = Buffer.from(mac ?? ''), b = Buffer.from(this.sign(value));
|
|
447
|
+
if (a.length !== b.length || !timingSafeEqual(a, b))
|
|
448
|
+
return null;
|
|
449
|
+
try {
|
|
450
|
+
return await this.getAuth().find(JSON.parse(Buffer.from(value, 'base64url').toString()).uid);
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// ------------------------------------------------------------------ render hooks
|
|
458
|
+
/**
|
|
459
|
+
* Emplacements de render hook d'un panel — mêmes noms que `Filament\View\PanelsRenderHook`.
|
|
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`.
|
|
462
|
+
*/
|
|
463
|
+
export const PanelsRenderHook = {
|
|
464
|
+
AUTH_LOGIN_FORM_AFTER: 'panels::auth.login.form.after',
|
|
465
|
+
AUTH_LOGIN_FORM_BEFORE: 'panels::auth.login.form.before',
|
|
466
|
+
AUTH_PASSWORD_RESET_REQUEST_FORM_AFTER: 'panels::auth.password-reset.request.form.after',
|
|
467
|
+
AUTH_PASSWORD_RESET_REQUEST_FORM_BEFORE: 'panels::auth.password-reset.request.form.before',
|
|
468
|
+
AUTH_PASSWORD_RESET_RESET_FORM_AFTER: 'panels::auth.password-reset.reset.form.after',
|
|
469
|
+
AUTH_PASSWORD_RESET_RESET_FORM_BEFORE: 'panels::auth.password-reset.reset.form.before',
|
|
470
|
+
AUTH_REGISTER_FORM_AFTER: 'panels::auth.register.form.after',
|
|
471
|
+
AUTH_REGISTER_FORM_BEFORE: 'panels::auth.register.form.before',
|
|
472
|
+
BODY_END: 'panels::body.end',
|
|
473
|
+
BODY_START: 'panels::body.start',
|
|
474
|
+
CONTENT_AFTER: 'panels::content.after',
|
|
475
|
+
CONTENT_BEFORE: 'panels::content.before',
|
|
476
|
+
CONTENT_END: 'panels::content.end',
|
|
477
|
+
CONTENT_START: 'panels::content.start',
|
|
478
|
+
FOOTER: 'panels::footer',
|
|
479
|
+
GLOBAL_SEARCH_AFTER: 'panels::global-search.after',
|
|
480
|
+
GLOBAL_SEARCH_BEFORE: 'panels::global-search.before',
|
|
481
|
+
GLOBAL_SEARCH_END: 'panels::global-search.end',
|
|
482
|
+
GLOBAL_SEARCH_START: 'panels::global-search.start',
|
|
483
|
+
HEAD_END: 'panels::head.end',
|
|
484
|
+
HEAD_START: 'panels::head.start',
|
|
485
|
+
LAYOUT_END: 'panels::layout.end',
|
|
486
|
+
LAYOUT_START: 'panels::layout.start',
|
|
487
|
+
PAGE_END: 'panels::page.end',
|
|
488
|
+
PAGE_FOOTER_WIDGETS_AFTER: 'panels::page.footer-widgets.after',
|
|
489
|
+
PAGE_FOOTER_WIDGETS_BEFORE: 'panels::page.footer-widgets.before',
|
|
490
|
+
PAGE_FOOTER_WIDGETS_END: 'panels::page.footer-widgets.end',
|
|
491
|
+
PAGE_FOOTER_WIDGETS_START: 'panels::page.footer-widgets.start',
|
|
492
|
+
PAGE_HEADER_ACTIONS_AFTER: 'panels::page.header.actions.after',
|
|
493
|
+
PAGE_HEADER_ACTIONS_BEFORE: 'panels::page.header.actions.before',
|
|
494
|
+
PAGE_HEADER_HEADING_AFTER: 'panels::page.header.heading.after',
|
|
495
|
+
PAGE_HEADER_HEADING_BEFORE: 'panels::page.header.heading.before',
|
|
496
|
+
PAGE_HEADER_WIDGETS_AFTER: 'panels::page.header-widgets.after',
|
|
497
|
+
PAGE_HEADER_WIDGETS_BEFORE: 'panels::page.header-widgets.before',
|
|
498
|
+
PAGE_HEADER_WIDGETS_END: 'panels::page.header-widgets.end',
|
|
499
|
+
PAGE_HEADER_WIDGETS_START: 'panels::page.header-widgets.start',
|
|
500
|
+
PAGE_START: 'panels::page.start',
|
|
501
|
+
/** Le nom contient « module-menu » dans Filament : conservé tel quel. */
|
|
502
|
+
PAGE_SUB_NAVIGATION_MOBILE_MENU_AFTER: 'panels::page.sub-navigation.module-menu.after',
|
|
503
|
+
PAGE_SUB_NAVIGATION_MOBILE_MENU_BEFORE: 'panels::page.sub-navigation.module-menu.before',
|
|
504
|
+
PAGE_SUB_NAVIGATION_SIDEBAR_AFTER: 'panels::page.sub-navigation.sidebar.after',
|
|
505
|
+
PAGE_SUB_NAVIGATION_SIDEBAR_BEFORE: 'panels::page.sub-navigation.sidebar.before',
|
|
506
|
+
PAGE_SUB_NAVIGATION_START_AFTER: 'panels::page.sub-navigation.start.after',
|
|
507
|
+
PAGE_SUB_NAVIGATION_START_BEFORE: 'panels::page.sub-navigation.start.before',
|
|
508
|
+
PAGE_SUB_NAVIGATION_TOP_AFTER: 'panels::page.sub-navigation.top.after',
|
|
509
|
+
PAGE_SUB_NAVIGATION_TOP_BEFORE: 'panels::page.sub-navigation.top.before',
|
|
510
|
+
PAGE_SUB_NAVIGATION_END_AFTER: 'panels::page.sub-navigation.end.after',
|
|
511
|
+
PAGE_SUB_NAVIGATION_END_BEFORE: 'panels::page.sub-navigation.end.before',
|
|
512
|
+
RESOURCE_PAGES_LIST_RECORDS_TABLE_AFTER: 'panels::resource.pages.list-records.table.after',
|
|
513
|
+
RESOURCE_PAGES_LIST_RECORDS_TABLE_BEFORE: 'panels::resource.pages.list-records.table.before',
|
|
514
|
+
RESOURCE_PAGES_LIST_RECORDS_TABS_END: 'panels::resource.pages.list-records.tabs.end',
|
|
515
|
+
RESOURCE_PAGES_LIST_RECORDS_TABS_START: 'panels::resource.pages.list-records.tabs.start',
|
|
516
|
+
RESOURCE_PAGES_MANAGE_RELATED_RECORDS_TABLE_AFTER: 'panels::resource.pages.manage-related-records.table.after',
|
|
517
|
+
RESOURCE_PAGES_MANAGE_RELATED_RECORDS_TABLE_BEFORE: 'panels::resource.pages.manage-related-records.table.before',
|
|
518
|
+
RESOURCE_RELATION_MANAGER_AFTER: 'panels::resource.relation-manager.after',
|
|
519
|
+
RESOURCE_RELATION_MANAGER_BEFORE: 'panels::resource.relation-manager.before',
|
|
520
|
+
RESOURCE_TABS_END: 'panels::resource.tabs.end',
|
|
521
|
+
RESOURCE_TABS_START: 'panels::resource.tabs.start',
|
|
522
|
+
SCRIPTS_AFTER: 'panels::scripts.after',
|
|
523
|
+
SCRIPTS_BEFORE: 'panels::scripts.before',
|
|
524
|
+
SIDEBAR_FOOTER: 'panels::sidebar.footer',
|
|
525
|
+
SIDEBAR_LOGO_AFTER: 'panels::sidebar.logo.after',
|
|
526
|
+
SIDEBAR_LOGO_BEFORE: 'panels::sidebar.logo.before',
|
|
527
|
+
SIDEBAR_START: 'panels::sidebar.start',
|
|
528
|
+
SIDEBAR_NAV_END: 'panels::sidebar.nav.end',
|
|
529
|
+
SIDEBAR_NAV_START: 'panels::sidebar.nav.start',
|
|
530
|
+
SIMPLE_LAYOUT_END: 'panels::simple-layout.end',
|
|
531
|
+
SIMPLE_LAYOUT_START: 'panels::simple-layout.start',
|
|
532
|
+
SIMPLE_PAGE_END: 'panels::simple-page.end',
|
|
533
|
+
SIMPLE_PAGE_START: 'panels::simple-page.start',
|
|
534
|
+
STYLES_AFTER: 'panels::styles.after',
|
|
535
|
+
STYLES_BEFORE: 'panels::styles.before',
|
|
536
|
+
TENANT_MENU_AFTER: 'panels::tenant-menu.after',
|
|
537
|
+
TENANT_MENU_BEFORE: 'panels::tenant-menu.before',
|
|
538
|
+
TOPBAR_AFTER: 'panels::topbar.after',
|
|
539
|
+
TOPBAR_BEFORE: 'panels::topbar.before',
|
|
540
|
+
TOPBAR_END: 'panels::topbar.end',
|
|
541
|
+
TOPBAR_LOGO_AFTER: 'panels::topbar.logo.after',
|
|
542
|
+
TOPBAR_LOGO_BEFORE: 'panels::topbar.logo.before',
|
|
543
|
+
TOPBAR_START: 'panels::topbar.start',
|
|
544
|
+
USER_MENU_AFTER: 'panels::user-menu.after',
|
|
545
|
+
USER_MENU_BEFORE: 'panels::user-menu.before',
|
|
546
|
+
USER_MENU_PROFILE_AFTER: 'panels::user-menu.profile.after',
|
|
547
|
+
USER_MENU_PROFILE_BEFORE: 'panels::user-menu.profile.before',
|
|
548
|
+
};
|
|
549
|
+
/** Emplacements de render hook des widgets — mêmes noms que `Filament\Widgets\View\WidgetsRenderHook`. Rendus par `TableWidget`. */
|
|
550
|
+
export const WidgetsRenderHook = {
|
|
551
|
+
TABLE_WIDGET_END: 'widgets::table-widget.end',
|
|
552
|
+
TABLE_WIDGET_START: 'widgets::table-widget.start',
|
|
553
|
+
};
|
|
554
|
+
/**
|
|
555
|
+
* Emplacements déclarés mais rendus nulle part pour l'instant, avec la brique qui leur manque.
|
|
556
|
+
* Les quatre « tabs » ne sont rendus par aucune vue de Filament 5.11.2 non plus.
|
|
557
|
+
*/
|
|
558
|
+
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 ManageRelatedRecords'])),
|
|
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], 'non rendu par Filament 5.11.2'])),
|
|
561
|
+
};
|
|
562
|
+
// ------------------------------------------------------------------ pages
|
|
563
|
+
/** Portée Alpine d'un composant de schéma sans clé (cellule de grille anonyme). */
|
|
564
|
+
const ANONYMOUS_SCHEMA_COMPONENT = `x-data="filamentSchemaComponent(${e(js({ key: null, exposedMethods: [], path: '', containerPath: '' }))})"`;
|
|
565
|
+
/** Base commune : composant à état hébergeant des schémas (`<nom>Schema(schema)`). */
|
|
566
|
+
class BasePage extends Component {
|
|
567
|
+
get panel() { return this._ctx.panel; }
|
|
568
|
+
getSchema(name) {
|
|
569
|
+
const schema = Schema.make(this).key(name);
|
|
570
|
+
if (name === 'form')
|
|
571
|
+
schema.statePath('data');
|
|
572
|
+
return this[`${name}Schema`](schema);
|
|
573
|
+
}
|
|
574
|
+
contentSchema(schema) { return schema.components([]); }
|
|
575
|
+
/** Portées des render hooks de cette page : sa classe (et sa resource pour une page de resource), comme `getRenderHookScopes()` dans Filament. */
|
|
576
|
+
getRenderHookScopes() {
|
|
577
|
+
const resource = this.constructor.resource;
|
|
578
|
+
return resource ? [this.constructor, resource] : [this.constructor];
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/** Page d'un panel, dans la mise en page complète (sidebar, en-tête). */
|
|
582
|
+
export class Page extends BasePage {
|
|
583
|
+
/** Méthodes que le navigateur peut appeler. Une sous-classe ajoute les siennes : `static callable = ['approve']`. */
|
|
584
|
+
static callable = ['mountAction', 'unmountAction', 'callMountedAction', 'callSchemaComponentMethod'];
|
|
585
|
+
static slug = '';
|
|
586
|
+
static title = null;
|
|
587
|
+
static navigationLabel = null;
|
|
588
|
+
static navigationIcon = null;
|
|
589
|
+
static navigationGroup = null;
|
|
590
|
+
static navigationSort = 0;
|
|
591
|
+
static navigationBadge = null;
|
|
592
|
+
static navigationBadgeColor = 'primary';
|
|
593
|
+
static shouldRegisterNavigation = true;
|
|
594
|
+
/** Page réservée à certains utilisateurs : rendre `false` la retire du menu et la refuse (403). */
|
|
595
|
+
static canAccess(_user) { return true; }
|
|
596
|
+
static getTitle() { return this.title ?? this.name.replace(/([a-z])([A-Z])/g, '$1 $2'); }
|
|
597
|
+
static getNavigationLabel() { return this.navigationLabel ?? this.getTitle(); }
|
|
598
|
+
/** Chaque requête, y compris les mises à jour envoyées par le navigateur, exige un utilisateur authentifié : un snapshot conservé après déconnexion ne suffit pas. */
|
|
599
|
+
async boot() {
|
|
600
|
+
const ctx = this._ctx;
|
|
601
|
+
if (!ctx?.user)
|
|
602
|
+
throw new WireError(401, 'Non authentifié.');
|
|
603
|
+
// Dans un panel à locataires, toute page vit chez un locataire dont l'utilisateur est membre (vérifié par le routeur).
|
|
604
|
+
if (ctx.panel.hasTenancy() && !ctx.tenant)
|
|
605
|
+
throw new WireError(404, 'Locataire introuvable.');
|
|
606
|
+
if (await ctx.panel.mustVerifyEmail(ctx.user))
|
|
607
|
+
throw new WireError(403, 'Adresse e-mail à vérifier.');
|
|
608
|
+
// Vérifié à chaque requête, pas seulement à l'affichage : une requête forgée vers une page interdite est refusée.
|
|
609
|
+
if (!(await this.isAccessAuthorized(ctx)))
|
|
610
|
+
throw new WireError(403, 'Accès refusé.');
|
|
611
|
+
}
|
|
612
|
+
isAccessAuthorized(ctx) { return ctx.panel.canAccessPage(this.constructor, ctx.user); }
|
|
613
|
+
getTitle() { return this.constructor.getTitle(); }
|
|
614
|
+
getHeading() { return this.getTitle(); }
|
|
615
|
+
getBreadcrumbs() { return []; }
|
|
616
|
+
getHeaderActions() { return []; }
|
|
617
|
+
getPageClasses() { return []; }
|
|
618
|
+
/** Sous-navigation de la page : vide par défaut. */
|
|
619
|
+
getSubNavigation() { return []; }
|
|
620
|
+
/** Position de la sous-navigation : `start` (colonne avant le contenu), `end` (après) ou `top` (onglets). Par défaut : celle de la resource, sinon du panel. */
|
|
621
|
+
static subNavigationPosition = null;
|
|
622
|
+
getSubNavigationPosition() {
|
|
623
|
+
const own = this.constructor.subNavigationPosition;
|
|
624
|
+
const resource = this.constructor.resource ?? this.constructor.related?.parentResource;
|
|
625
|
+
return own ?? resource?.subNavigationPosition ?? this.panel.getSubNavigationPosition();
|
|
626
|
+
}
|
|
627
|
+
/** Widgets affichés sous le contenu de la page (port de getFooterWidgets). */
|
|
628
|
+
getFooterWidgets() { return []; }
|
|
629
|
+
getFooterWidgetsColumns() { return 2; }
|
|
630
|
+
/** Port de filament-widgets::components.widgets, dans le schéma `footerWidgets` de la page. */
|
|
631
|
+
async renderFooterWidgets() {
|
|
632
|
+
const widgets = await this.filterVisibleWidgets(this.getFooterWidgets());
|
|
633
|
+
if (!widgets.length)
|
|
634
|
+
return '';
|
|
635
|
+
const liberate = (hook) => `<div class="fi-sc-liberated" wire:key="" ${ANONYMOUS_SCHEMA_COMPONENT}>${renderHook(hook, this.getRenderHookScopes())}</div>`;
|
|
636
|
+
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
|
+
}
|
|
638
|
+
/** Widgets affichés au-dessus du contenu de la page, et données passées à chacun (propriétés du widget). */
|
|
639
|
+
getHeaderWidgets() { return []; }
|
|
640
|
+
getHeaderWidgetsColumns() { return 2; }
|
|
641
|
+
getWidgetData() { return {}; }
|
|
642
|
+
/** Port de filament-widgets::components.widgets, dans le schéma `headerWidgets` de la page. */
|
|
643
|
+
async renderHeaderWidgets() {
|
|
644
|
+
const widgets = await this.filterVisibleWidgets(this.getHeaderWidgets());
|
|
645
|
+
if (!widgets.length)
|
|
646
|
+
return '';
|
|
647
|
+
const liberate = (hook) => `<div class="fi-sc-liberated" wire:key="" ${ANONYMOUS_SCHEMA_COMPONENT}>${renderHook(hook, this.getRenderHookScopes())}</div>`;
|
|
648
|
+
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
|
+
}
|
|
650
|
+
/** Widgets que l'utilisateur courant a le droit de voir (port de filterVisibleWidgets). */
|
|
651
|
+
async filterVisibleWidgets(candidates) {
|
|
652
|
+
const viewer = this._ctx.user;
|
|
653
|
+
const widgets = [];
|
|
654
|
+
for (const widget of candidates)
|
|
655
|
+
if (await this.panel.authorize(viewer, 'view', { widget, rule: () => widget.canView(viewer) }))
|
|
656
|
+
widgets.push(widget);
|
|
657
|
+
return widgets;
|
|
658
|
+
}
|
|
659
|
+
/** Grille de widgets d'un schéma (port de Grid + getWidgetsSchemaComponents) : chaque widget est un composant enfant, « lazy » par défaut. */
|
|
660
|
+
async renderWidgetsGrid(widgets, columns, schemaKey) {
|
|
661
|
+
const items = [];
|
|
662
|
+
for (const [index, widget] of widgets.entries()) {
|
|
663
|
+
const key = `${schemaKey}.${widget.componentName}-${index}`;
|
|
664
|
+
const child = await this.mountChild(widget.componentName, this.getWidgetData(), `${this.id}.${key}`, { lazy: widget.isLazy });
|
|
665
|
+
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
|
+
}
|
|
667
|
+
const grid = new Attributes().grid(typeof columns === 'number' ? { lg: columns } : columns).class(['fi-sc', 'fi-sc-has-gap']);
|
|
668
|
+
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
|
+
}
|
|
670
|
+
/** Contenu de la page : par défaut son schéma `content`. */
|
|
671
|
+
async renderContent() { return (await this.getSchema('content')).toHtml(); }
|
|
672
|
+
// --- actions montées (port de InteractsWithActions) : la pile vit dans l'état du composant, les modales sont rendues côté serveur
|
|
673
|
+
mountedActions = [];
|
|
674
|
+
getActionMountContext() { return {}; }
|
|
675
|
+
getActionRecord() { return null; }
|
|
676
|
+
/** Enregistrement visé par une action montée dans un contexte donné (ligne de table…). */
|
|
677
|
+
async resolveActionRecord(_context) { return this.getActionRecord(); }
|
|
678
|
+
/** Actions pouvant être montées dans un contexte donné (en-tête de page par défaut). */
|
|
679
|
+
getMountableActions(context) {
|
|
680
|
+
return typeof context.schemaComponent === 'string' ? this.getSchemaComponentActions(context.schemaComponent) : this.getHeaderActions();
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Actions portées par un composant de schéma (Repeater : ajouter, supprimer, déplacer ; Section : actions d'en-tête).
|
|
684
|
+
* La clé vient du navigateur : elle doit désigner un composant réel du schéma, ou le schéma lui-même.
|
|
685
|
+
*/
|
|
686
|
+
async getSchemaComponentActions(key) {
|
|
687
|
+
const name = key.split('.')[0];
|
|
688
|
+
// Le composant peut appartenir au formulaire d'une action déjà montée (éditeur riche dans une modale).
|
|
689
|
+
const mountedIndex = /^mountedActionSchema(\d+)$/.exec(name);
|
|
690
|
+
if (!mountedIndex && (!/^[A-Za-z]+$/.test(name) || typeof this[`${name}Schema`] !== 'function'))
|
|
691
|
+
return [];
|
|
692
|
+
const schema = mountedIndex ? await this.getMountedActionSchema(Number(mountedIndex[1])) : this.getSchema(name);
|
|
693
|
+
if (!schema)
|
|
694
|
+
return [];
|
|
695
|
+
// Seuls les composants visibles exposent leurs actions : un composant masqué (section, callout…) n'en monte aucune.
|
|
696
|
+
const components = (await schema.getVisibleFlatComponents());
|
|
697
|
+
const exact = components.find((c) => c.getKey() === key && typeof c.getActions === 'function');
|
|
698
|
+
if (exact)
|
|
699
|
+
return exact.getActions();
|
|
700
|
+
return key === schema.getKey() ? components.filter((c) => !c.hasStatePath() && typeof c.getActions === 'function').flatMap((c) => c.getActions()) : [];
|
|
701
|
+
}
|
|
702
|
+
/** Contexte retenu pour une action montée : celui du serveur, jamais celui du navigateur tel quel. */
|
|
703
|
+
resolveMountContext(clientContext) {
|
|
704
|
+
const component = clientContext?.schemaComponent;
|
|
705
|
+
return typeof component === 'string' && component.length <= 200 ? { ...this.getActionMountContext(), schemaComponent: component } : this.getActionMountContext();
|
|
706
|
+
}
|
|
707
|
+
async findMountableAction(name, context = this.getActionMountContext()) {
|
|
708
|
+
const action = (await this.getMountableActions(context)).find((a) => a.getName() === name && a.isMountable());
|
|
709
|
+
if (!action)
|
|
710
|
+
throw new WireError(404, `Action introuvable : ${String(name)}`);
|
|
711
|
+
return action;
|
|
712
|
+
}
|
|
713
|
+
async actionContext(mounted) { return { livewire: this, record: await this.resolveActionRecord(mounted.context), arguments: mounted.arguments, data: emptyToNull(mounted.data ?? {}) }; }
|
|
714
|
+
/** Schéma du formulaire d'une action montée : clé `mountedActionSchema<n>`, état sous `mountedActions.<n>.data` (comme Filament). */
|
|
715
|
+
async getMountedActionSchema(index) {
|
|
716
|
+
const mounted = this.mountedActions[index];
|
|
717
|
+
if (!mounted)
|
|
718
|
+
return null;
|
|
719
|
+
const action = await this.findMountableAction(mounted.name, mounted.context);
|
|
720
|
+
return action.applySchema(Schema.make(this).key(`mountedActionSchema${index}`).statePath(`mountedActions.${index}.data`).operation(mounted.name).record(await this.resolveActionRecord(mounted.context)));
|
|
721
|
+
}
|
|
722
|
+
async renderActionModal(index) {
|
|
723
|
+
const mounted = this.mountedActions[index];
|
|
724
|
+
return (await this.findMountableAction(mounted.name, mounted.context)).toModalHtml(await this.actionContext(mounted), index, (await (await this.getMountedActionSchema(index))?.toHtml()) ?? null);
|
|
725
|
+
}
|
|
726
|
+
/** Annule un rendu partiel de modale préparé par un champ réactif, quand la même requête exécute ensuite une action. */
|
|
727
|
+
cancelModalPartialRender() {
|
|
728
|
+
for (const name of Object.keys(this._effects.partials ?? {}))
|
|
729
|
+
if (name.startsWith('action-modals.')) {
|
|
730
|
+
delete this._effects.partials[name];
|
|
731
|
+
this._skipRender = false;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/** Champ réactif du formulaire d'une action montée : exécute `afterStateUpdated`, puis ne renvoie que la modale concernée (comme Filament). */
|
|
735
|
+
async updatedMountedActions(_value, key) {
|
|
736
|
+
const m = /^(\d+)\.data\.(.+)$/.exec(key ?? '');
|
|
737
|
+
if (!m)
|
|
738
|
+
return;
|
|
739
|
+
const index = Number(m[1]);
|
|
740
|
+
const schema = await this.getMountedActionSchema(index);
|
|
741
|
+
const field = schema?.getFlatComponents().find((c) => c instanceof Field && c.getStatePath() === `mountedActions.${index}.data.${m[2]}`);
|
|
742
|
+
if (!field?.hasAfterStateUpdated())
|
|
743
|
+
return;
|
|
744
|
+
const data = (this.mountedActions[index].data ??= {});
|
|
745
|
+
await field.callAfterStateUpdated((name, value) => { data[name] = value; });
|
|
746
|
+
this.renderPartial(`action-modals.${index}`, await this.renderActionModal(index));
|
|
747
|
+
}
|
|
748
|
+
/** Appel, depuis le JavaScript d'un champ, d'une méthode que ce champ expose (ex. recherche d'un Select). Sans rendu. */
|
|
749
|
+
async callSchemaComponentMethod(componentKey, method, args = []) {
|
|
750
|
+
if (typeof componentKey !== 'string' || typeof method !== 'string') {
|
|
751
|
+
this.skipRender();
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
const name = componentKey.split('.')[0];
|
|
755
|
+
const mountedIndex = /^mountedActionSchema(\d+)$/.exec(name);
|
|
756
|
+
const schema = mountedIndex ? await this.getMountedActionSchema(Number(mountedIndex[1])) : typeof this[`${name}Schema`] === 'function' ? await this.getSchema(name) : null;
|
|
757
|
+
// Un composant masqué n'expose aucune méthode, même si le navigateur en forge l'appel.
|
|
758
|
+
const component = schema ? (await schema.getVisibleFlatComponents()).find((c) => c.getKey() === componentKey) : undefined;
|
|
759
|
+
// Seules les méthodes que le composant déclare exposées sont appelables depuis le navigateur.
|
|
760
|
+
if (!component || !component.getExposedMethods().includes(method) || typeof component[method] !== 'function') {
|
|
761
|
+
this.skipRender();
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
if (component.isRenderlessMethod(method))
|
|
765
|
+
this.skipRender();
|
|
766
|
+
// Arguments nommés (objet), comme les envoie le JavaScript de Filament : passés dans l'ordre de leurs clés.
|
|
767
|
+
return component[method](...(Array.isArray(args) ? args : args && typeof args === 'object' ? Object.values(args) : []));
|
|
768
|
+
}
|
|
769
|
+
/** Une action masquée ne peut être ni montée ni exécutée, même par un appel forgé depuis le navigateur. */
|
|
770
|
+
async assertActionVisible(action, context = {}) {
|
|
771
|
+
const record = await this.resolveActionRecord(context);
|
|
772
|
+
if (context.recordKey !== undefined && record === null)
|
|
773
|
+
throw new WireError(404, 'Enregistrement introuvable');
|
|
774
|
+
const ctx = { livewire: this, component: null, operation: null, record, get: () => undefined, state: undefined };
|
|
775
|
+
if (!(await action.isVisible(ctx)) || (await action.isDisabled(ctx)))
|
|
776
|
+
throw new WireError(403, `Action indisponible : ${action.getName()}`);
|
|
777
|
+
// Une action qui mène à une adresse pour cet enregistrement est un lien : elle ne se monte pas.
|
|
778
|
+
if (action.resolveUrl(ctx) !== null)
|
|
779
|
+
throw new WireError(404, `Action introuvable : ${action.getName()}`);
|
|
780
|
+
}
|
|
781
|
+
syncActionModals() {
|
|
782
|
+
this.dispatch('sync-action-modals', { id: this.id, newActionNestingIndex: this.mountedActions.length ? this.mountedActions.length - 1 : null, shouldOverlayParentActions: false });
|
|
783
|
+
}
|
|
784
|
+
/** Faux pour un composant à table : ses modales sont rendues dans la table (comme Filament). */
|
|
785
|
+
rendersActionModalsInPage() { return true; }
|
|
786
|
+
async renderActionModals() {
|
|
787
|
+
const modals = [];
|
|
788
|
+
for (const index of this.mountedActions.keys())
|
|
789
|
+
modals.push(await this.renderActionModal(index));
|
|
790
|
+
return `<div wire:partial="action-modals" x-data="filamentActionModals({
|
|
791
|
+
livewireId: ${js(this.id)},
|
|
792
|
+
})" style="height: 0">${modals.join('')}</div>`;
|
|
793
|
+
}
|
|
794
|
+
/** Monte une action : sans modale elle s'exécute tout de suite ; avec modale, seul le fragment des modales est renvoyé. */
|
|
795
|
+
async mountAction(name, args = {}, _context = {}) {
|
|
796
|
+
this.cancelModalPartialRender();
|
|
797
|
+
const context = this.resolveMountContext(_context);
|
|
798
|
+
const action = await this.findMountableAction(name, context);
|
|
799
|
+
await this.assertActionVisible(action, context);
|
|
800
|
+
const mounted = { name: String(name), arguments: args && typeof args === 'object' ? args : {}, context };
|
|
801
|
+
if (!action.hasModal()) {
|
|
802
|
+
await action.call(await this.actionContext(mounted));
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
this.mountedActions.push(mounted);
|
|
806
|
+
const schema = await this.getMountedActionSchema(this.mountedActions.length - 1);
|
|
807
|
+
if (schema) {
|
|
808
|
+
// État initial : valeurs par défaut des champs, puis `fillForm()`.
|
|
809
|
+
const data = {};
|
|
810
|
+
mounted.data = data;
|
|
811
|
+
fillSchema(schema, data);
|
|
812
|
+
const fill = await action.getFormFill(await this.actionContext(mounted));
|
|
813
|
+
if (fill)
|
|
814
|
+
fillSchemaFromRecord(schema, data, fill);
|
|
815
|
+
}
|
|
816
|
+
this.renderPartial('action-modals', await this.renderActionModals());
|
|
817
|
+
this.syncActionModals();
|
|
818
|
+
}
|
|
819
|
+
unmountAction(_shouldCancelParentActions = true) {
|
|
820
|
+
this.cancelModalPartialRender();
|
|
821
|
+
this._errors = {};
|
|
822
|
+
this.mountedActions.pop();
|
|
823
|
+
this.syncActionModals();
|
|
824
|
+
}
|
|
825
|
+
async callMountedAction(_args = {}) {
|
|
826
|
+
this.cancelModalPartialRender();
|
|
827
|
+
const mounted = this.mountedActions[this.mountedActions.length - 1];
|
|
828
|
+
if (!mounted)
|
|
829
|
+
return;
|
|
830
|
+
const action = await this.findMountableAction(mounted.name, mounted.context);
|
|
831
|
+
await this.assertActionVisible(action, mounted.context);
|
|
832
|
+
const schema = await this.getMountedActionSchema(this.mountedActions.length - 1);
|
|
833
|
+
if (schema) {
|
|
834
|
+
this._errors = await validateSchema(schema);
|
|
835
|
+
if (Object.keys(this._errors).length) {
|
|
836
|
+
this.dispatch('form-validation-error', { livewireId: this.id });
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
const callContext = await this.actionContext(mounted);
|
|
841
|
+
if (schema)
|
|
842
|
+
callContext.data = await getSchemaState(schema);
|
|
843
|
+
await action.call(callContext);
|
|
844
|
+
// « … & create another » : la modale reste ouverte sur un formulaire remis à zéro.
|
|
845
|
+
if (schema && _args && typeof _args === 'object' && _args.another === true) {
|
|
846
|
+
const data = {};
|
|
847
|
+
mounted.data = data;
|
|
848
|
+
fillSchema(schema, data);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
this.mountedActions.pop();
|
|
852
|
+
this.syncActionModals();
|
|
853
|
+
}
|
|
854
|
+
async render() {
|
|
855
|
+
const ctx = { livewire: this, component: null, operation: null, record: this.getActionRecord(), get: () => undefined, state: undefined };
|
|
856
|
+
const headerActions = [];
|
|
857
|
+
for (const action of this.getHeaderActions())
|
|
858
|
+
if (await action.isVisible(ctx))
|
|
859
|
+
headerActions.push(await action.toHtml(ctx));
|
|
860
|
+
return views.render('filament-panels::pages.page', {
|
|
861
|
+
heading: this.getHeading(), headerWidgets: await this.renderHeaderWidgets(), content: await this.renderContent(), footerWidgets: await this.renderFooterWidgets(), livewireId: this.id, renderHookScopes: this.getRenderHookScopes(),
|
|
862
|
+
subNavigation: await this.getSubNavigation(), subNavigationPosition: this.getSubNavigationPosition(),
|
|
863
|
+
breadcrumbs: this.getBreadcrumbs(), headerActions, pageClasses: this.getPageClasses(), actionModals: this.rendersActionModalsInPage() ? await this.renderActionModals() : '',
|
|
864
|
+
headerActionsKey: createHash('md5').update(this.getHeaderActions().map((a) => a.getName()).join(',')).digest('hex'),
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Tableau de bord (port de Filament\Pages\Dashboard) : une grille de widgets, précédée d'un formulaire de filtres
|
|
870
|
+
* quand la page définit `filtersFormSchema(schema)` (port de HasFiltersForm). Les filtres sont passés aux widgets sous `pageFilters`.
|
|
871
|
+
*/
|
|
872
|
+
export class Dashboard extends Page {
|
|
873
|
+
static slug = '';
|
|
874
|
+
static title = 'Dashboard';
|
|
875
|
+
static navigationIcon = 'o-home';
|
|
876
|
+
static navigationSort = -2;
|
|
877
|
+
/** État du formulaire de filtres. Reste `null` tant que la page n'a pas de filtres. */
|
|
878
|
+
filters = null;
|
|
879
|
+
/** Widgets du tableau de bord. Par défaut : ceux du panel (`panel.widgets([...])`). */
|
|
880
|
+
getWidgets() { return this.panel.getWidgets(); }
|
|
881
|
+
/** Colonnes de la grille : un nombre (au point de rupture `lg`), ou un nombre par point de rupture. */
|
|
882
|
+
getColumns() { return 2; }
|
|
883
|
+
hasFiltersForm() { return typeof this.filtersFormSchema === 'function'; }
|
|
884
|
+
getSchema(name) {
|
|
885
|
+
if (name !== 'filtersForm')
|
|
886
|
+
return super.getSchema(name);
|
|
887
|
+
// Comme getFiltersForm() : grille 2 / 3 / 4 colonnes, état sous `filters`, champs « live ».
|
|
888
|
+
const schema = this.filtersFormSchema(Schema.make(this).key('filtersForm').columns({ md: 2, xl: 3, '2xl': 4 }).statePath('filters').partialName('table-filters-form'));
|
|
889
|
+
for (const component of schema.getFlatComponents())
|
|
890
|
+
if (component instanceof Field && typeof component.live === 'function')
|
|
891
|
+
component.live();
|
|
892
|
+
return schema;
|
|
893
|
+
}
|
|
894
|
+
mount() {
|
|
895
|
+
if (!this.hasFiltersForm())
|
|
896
|
+
return;
|
|
897
|
+
this.filters = {};
|
|
898
|
+
fillSchema(this.getSchema('filtersForm'), this.filters);
|
|
899
|
+
}
|
|
900
|
+
getWidgetData() { return this.hasFiltersForm() ? { pageFilters: this.filters } : {}; }
|
|
901
|
+
async renderContent() {
|
|
902
|
+
const filters = this.hasFiltersForm() ? `<div ${ANONYMOUS_SCHEMA_COMPONENT} class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component">${await this.getSchema('filtersForm').toHtml()}</div></div>` : '';
|
|
903
|
+
const widgets = await this.renderWidgetsGrid(await this.filterVisibleWidgets(this.getWidgets()), this.getColumns(), 'content');
|
|
904
|
+
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
|
+
}
|
|
906
|
+
}
|
|
907
|
+
/** Limiteur de tentatives en mémoire : `max` essais par fenêtre, par clé. Suffisant pour une instance ; à remplacer par un magasin partagé si le panel tourne sur plusieurs. */
|
|
908
|
+
export class Throttle {
|
|
909
|
+
hits = new Map();
|
|
910
|
+
max;
|
|
911
|
+
windowMs;
|
|
912
|
+
constructor(max = 5, windowMs = 60_000) { this.max = max; this.windowMs = windowMs; }
|
|
913
|
+
/** Enregistre un essai ; rend 0 s'il est permis, sinon le nombre de secondes à attendre. */
|
|
914
|
+
hit(key, now = Date.now()) {
|
|
915
|
+
const recent = (this.hits.get(key) ?? []).filter((at) => at > now - this.windowMs);
|
|
916
|
+
if (recent.length >= this.max) {
|
|
917
|
+
this.hits.set(key, recent);
|
|
918
|
+
return Math.max(1, Math.ceil((recent[0] + this.windowMs - now) / 1000));
|
|
919
|
+
}
|
|
920
|
+
recent.push(now);
|
|
921
|
+
this.hits.set(key, recent);
|
|
922
|
+
// Le magasin ne grossit pas indéfiniment.
|
|
923
|
+
if (this.hits.size > 10_000)
|
|
924
|
+
for (const [k, v] of this.hits)
|
|
925
|
+
if (!v.some((at) => at > now - this.windowMs))
|
|
926
|
+
this.hits.delete(k);
|
|
927
|
+
return 0;
|
|
928
|
+
}
|
|
929
|
+
clear(key) { this.hits.delete(key); }
|
|
930
|
+
}
|
|
931
|
+
const loginThrottle = new Throttle();
|
|
932
|
+
/** Page à mise en page « simple » (connexion, inscription…). */
|
|
933
|
+
export class SimplePage extends BasePage {
|
|
934
|
+
getHeading() { return this.getTitle(); }
|
|
935
|
+
/** Sous-titre, en HTML de confiance (composé par la page, données échappées). */
|
|
936
|
+
getSubheading() { return null; }
|
|
937
|
+
hasLogo() { return true; }
|
|
938
|
+
async render() {
|
|
939
|
+
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
|
+
}
|
|
941
|
+
}
|
|
942
|
+
/** Gestionnaire de couleurs lu par les autres paquets, partagé par tous les panels ; chaque requête y pose les couleurs de son panel (`Panel.withTheme`). */
|
|
943
|
+
const sharedColors = new ColorManager();
|
|
944
|
+
/** Lien « fi-link » d'une page d'authentification (port de `Action::make()->link()`). */
|
|
945
|
+
function authLink(url, label, options = {}) {
|
|
946
|
+
const classes = ['fi-ac-link-action', 'fi-link', `fi-size-${options.size ?? 'md'}`, ...sharedColors.getComponentClasses('link', 'primary')];
|
|
947
|
+
return `<a href="${e(url)}" class="${classes.join(' ')}" >${options.iconName ? icon(options.iconName) : ''}<span class="fi-link-label"> ${e(label)} </span></a>`;
|
|
948
|
+
}
|
|
949
|
+
/** Adresse absolue pour un e-mail : celle déclarée par `panel.appUrl()`, sinon l'origine de la requête. */
|
|
950
|
+
function absoluteUrl(panel, req, path) { return `${panel.getAppUrl() ?? `${req.protocol}://${req.get('host')}`}${path}`; }
|
|
951
|
+
const registerThrottle = new Throttle(2);
|
|
952
|
+
const passwordResetThrottle = new Throttle(2);
|
|
953
|
+
const verificationThrottle = new Throttle(2);
|
|
954
|
+
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
|
+
/** Règles de mot de passe de Laravel (`Password::default()` : 8 caractères au moins) et de confirmation (`same`). */
|
|
956
|
+
const passwordRules = (confirmationField, confirmationLabel) => [
|
|
957
|
+
{ name: 'min', passes: (value) => String(value).length >= 8, message: (attribute) => `The ${attribute} field must be at least 8 characters.` },
|
|
958
|
+
{ name: 'same', passes: (value, ctx) => value === ctx.get(confirmationField), message: (attribute) => `The ${attribute} field must match ${confirmationLabel}.` },
|
|
959
|
+
];
|
|
960
|
+
/** E-mail de vérification de l'adresse (texte de la notification `VerifyEmail` de Laravel). */
|
|
961
|
+
async function sendVerificationEmail(panel, req, user) {
|
|
962
|
+
await panel.sendMail({
|
|
963
|
+
to: user.email, subject: 'Verify Email Address',
|
|
964
|
+
lines: ['Please click the button below to verify your email address.'],
|
|
965
|
+
action: { label: 'Verify Email Address', url: absoluteUrl(panel, req, panel.getVerifyEmailUrl(user)) },
|
|
966
|
+
outro: ['If you did not create an account, no further action is required.'],
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
const formContent = (submit, actions, options = {}) => [
|
|
970
|
+
...(options.before ? [RenderHook.make(options.before)] : []),
|
|
971
|
+
Form.make([EmbeddedSchema.make('form')]).id('form').livewireSubmitHandler(submit)
|
|
972
|
+
.footer([Actions.make(actions).alignment(options.alignment ?? null).fullWidth(options.fullWidth ?? true).key('form-actions')]),
|
|
973
|
+
...(options.after ? [RenderHook.make(options.after)] : []),
|
|
974
|
+
];
|
|
975
|
+
/** Inscription (port de Filament\Auth\Pages\Register) : nom, adresse, mot de passe confirmé ; l'utilisateur est connecté, puis invité à vérifier son adresse si le panel l'exige. */
|
|
976
|
+
export class Register extends SimplePage {
|
|
977
|
+
static callable = ['register', 'callSchemaComponentMethod'];
|
|
978
|
+
data = {};
|
|
979
|
+
mount() {
|
|
980
|
+
if (this._ctx.user) {
|
|
981
|
+
this.redirect(this.panel.getUrl());
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
fillSchema(this.getSchema('form'), this.data);
|
|
985
|
+
}
|
|
986
|
+
getTitle() { return t('filament-panels::auth/pages/register.title'); }
|
|
987
|
+
getHeading() { return t('filament-panels::auth/pages/register.heading'); }
|
|
988
|
+
getSubheading() {
|
|
989
|
+
if (!this.panel.getLoginPage())
|
|
990
|
+
return null;
|
|
991
|
+
return `${e(t('filament-panels::auth/pages/register.actions.login.before'))} ${authLink(this.panel.getLoginUrl(), t('filament-panels::auth/pages/register.actions.login.label'))}`;
|
|
992
|
+
}
|
|
993
|
+
formSchema(schema) {
|
|
994
|
+
const auth = this.panel.getAuth();
|
|
995
|
+
return schema.components([
|
|
996
|
+
TextInput.make('name').label(t('filament-panels::auth/pages/register.form.name.label')).required().maxLength(255).autofocus(),
|
|
997
|
+
TextInput.make('email').label(t('filament-panels::auth/pages/register.form.email.label')).email().required().maxLength(255)
|
|
998
|
+
.rule({ name: 'unique', passes: async (value) => !(await auth.findByEmail(String(value))), message: (attribute) => `The ${attribute} has already been taken.` }),
|
|
999
|
+
passwordRules('passwordConfirmation', 'password confirmation').reduce((input, rule) => input.rule(rule), TextInput.make('password').label(t('filament-panels::auth/pages/register.form.password.label')).password().revealable().required()),
|
|
1000
|
+
TextInput.make('passwordConfirmation').label(t('filament-panels::auth/pages/register.form.password_confirmation.label')).password().revealable().required().dehydrated(false),
|
|
1001
|
+
]);
|
|
1002
|
+
}
|
|
1003
|
+
contentSchema(schema) {
|
|
1004
|
+
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
|
+
}
|
|
1006
|
+
/** Données validées, juste avant la création (ajouter une valeur, normaliser l'adresse…). */
|
|
1007
|
+
mutateFormDataBeforeRegister(data) { return data; }
|
|
1008
|
+
/** Création de l'utilisateur : par défaut `create()` du fournisseur d'utilisateurs. */
|
|
1009
|
+
handleRegistration(data) {
|
|
1010
|
+
return this.panel.getAuth().create({ name: String(data.name), email: String(data.email), password: String(data.password) });
|
|
1011
|
+
}
|
|
1012
|
+
async register() {
|
|
1013
|
+
const { req, res, panel } = this._ctx;
|
|
1014
|
+
const wait = registerThrottle.hit(`${panel.getId()}:${req.ip ?? ''}`);
|
|
1015
|
+
if (wait > 0) {
|
|
1016
|
+
throttled('filament-panels::auth/pages/register', wait);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
const form = this.getSchema('form');
|
|
1020
|
+
this._errors = await validateSchema(form);
|
|
1021
|
+
if (Object.keys(this._errors).length) {
|
|
1022
|
+
this.dispatch('form-validation-error', { livewireId: this.id });
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
const user = await this.handleRegistration(await this.mutateFormDataBeforeRegister(await getSchemaState(form)));
|
|
1026
|
+
if (panel.hasEmailVerification())
|
|
1027
|
+
await sendVerificationEmail(panel, req, user);
|
|
1028
|
+
panel.logIn(res, user, false);
|
|
1029
|
+
this.redirect(panel.getUrl());
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
/** Demande de réinitialisation du mot de passe (port de RequestPasswordReset) : un lien signé, valable 60 minutes, part par e-mail. */
|
|
1033
|
+
export class RequestPasswordReset extends SimplePage {
|
|
1034
|
+
static callable = ['request', 'callSchemaComponentMethod'];
|
|
1035
|
+
data = {};
|
|
1036
|
+
mount() {
|
|
1037
|
+
if (this._ctx.user) {
|
|
1038
|
+
this.redirect(this.panel.getUrl());
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
fillSchema(this.getSchema('form'), this.data);
|
|
1042
|
+
}
|
|
1043
|
+
getTitle() { return t('filament-panels::auth/pages/password-reset/request-password-reset.title'); }
|
|
1044
|
+
getHeading() { return t('filament-panels::auth/pages/password-reset/request-password-reset.heading'); }
|
|
1045
|
+
getSubheading() {
|
|
1046
|
+
return this.panel.getLoginPage() ? authLink(this.panel.getLoginUrl(), t('filament-panels::auth/pages/password-reset/request-password-reset.actions.login.label'), { iconName: 'm-arrow-left' }) : null;
|
|
1047
|
+
}
|
|
1048
|
+
formSchema(schema) {
|
|
1049
|
+
return schema.components([TextInput.make('email').label(t('filament-panels::auth/pages/password-reset/request-password-reset.form.email.label')).email().required().autocomplete().autofocus()]);
|
|
1050
|
+
}
|
|
1051
|
+
contentSchema(schema) {
|
|
1052
|
+
return schema.components(formContent('request', [Action.make('request').label(t('filament-panels::auth/pages/password-reset/request-password-reset.form.actions.request.label')).submit('request')], { before: PanelsRenderHook.AUTH_PASSWORD_RESET_REQUEST_FORM_BEFORE, after: PanelsRenderHook.AUTH_PASSWORD_RESET_REQUEST_FORM_AFTER }));
|
|
1053
|
+
}
|
|
1054
|
+
async request() {
|
|
1055
|
+
const { req, panel } = this._ctx;
|
|
1056
|
+
const wait = passwordResetThrottle.hit(`${panel.getId()}:request:${req.ip ?? ''}`);
|
|
1057
|
+
if (wait > 0) {
|
|
1058
|
+
throttled('filament-panels::auth/pages/password-reset/request-password-reset', wait);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const form = this.getSchema('form');
|
|
1062
|
+
this._errors = await validateSchema(form);
|
|
1063
|
+
if (Object.keys(this._errors).length) {
|
|
1064
|
+
this.dispatch('form-validation-error', { livewireId: this.id });
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
const auth = panel.getAuth();
|
|
1068
|
+
const user = await auth.findByEmail(String(this.data.email));
|
|
1069
|
+
if (user) {
|
|
1070
|
+
await panel.sendMail({
|
|
1071
|
+
to: user.email, subject: 'Reset Password Notification',
|
|
1072
|
+
lines: ['You are receiving this email because we received a password reset request for your account.'],
|
|
1073
|
+
action: { label: 'Reset Password', url: absoluteUrl(panel, req, panel.getResetPasswordUrl(user, await auth.getPasswordFingerprint(user))) },
|
|
1074
|
+
outro: ['This password reset link will expire in 60 minutes.', 'If you did not request a password reset, no further action is required.'],
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
// Écart de sécurité assumé : Filament (via Laravel) répond « adresse inconnue » quand aucun compte n'existe, ce qui
|
|
1078
|
+
// révèle quelles adresses sont inscrites. Navette répond toujours de la même façon.
|
|
1079
|
+
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
|
+
this.data = {};
|
|
1081
|
+
fillSchema(form, this.data);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
/** Nouveau mot de passe, depuis le lien reçu par e-mail (port de ResetPassword). Le lien ne sert qu'une fois : il meurt avec l'ancien mot de passe. */
|
|
1085
|
+
export class ResetPassword extends SimplePage {
|
|
1086
|
+
static callable = ['resetPassword', 'callSchemaComponentMethod'];
|
|
1087
|
+
static locked = ['email', 'token'];
|
|
1088
|
+
email = null;
|
|
1089
|
+
password = '';
|
|
1090
|
+
passwordConfirmation = '';
|
|
1091
|
+
token = null;
|
|
1092
|
+
mount(params) {
|
|
1093
|
+
if (this._ctx.user) {
|
|
1094
|
+
this.redirect(this.panel.getUrl());
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
this.token = typeof params.token === 'string' ? params.token : null;
|
|
1098
|
+
this.email = typeof params.email === 'string' ? params.email : null;
|
|
1099
|
+
}
|
|
1100
|
+
/** Comme Filament : le formulaire n'a pas de chemin d'état, ses champs sont des propriétés de la page. */
|
|
1101
|
+
getSchema(name) { return name === 'form' ? this.formSchema(Schema.make(this).key('form')) : super.getSchema(name); }
|
|
1102
|
+
getTitle() { return t('filament-panels::auth/pages/password-reset/reset-password.title'); }
|
|
1103
|
+
getHeading() { return t('filament-panels::auth/pages/password-reset/reset-password.heading'); }
|
|
1104
|
+
formSchema(schema) {
|
|
1105
|
+
return schema.components([
|
|
1106
|
+
TextInput.make('email').label(t('filament-panels::auth/pages/password-reset/reset-password.form.email.label')).disabled().autofocus(),
|
|
1107
|
+
passwordRules('passwordConfirmation', 'password confirmation').reduce((input, rule) => input.rule(rule), TextInput.make('password').label(t('filament-panels::auth/pages/password-reset/reset-password.form.password.label')).password().autocomplete('new-password').revealable().required()),
|
|
1108
|
+
TextInput.make('passwordConfirmation').label(t('filament-panels::auth/pages/password-reset/reset-password.form.password_confirmation.label')).password().autocomplete('new-password').revealable().required().dehydrated(false),
|
|
1109
|
+
]);
|
|
1110
|
+
}
|
|
1111
|
+
contentSchema(schema) {
|
|
1112
|
+
return schema.components(formContent('resetPassword', [Action.make('resetPassword').label(t('filament-panels::auth/pages/password-reset/reset-password.form.actions.reset.label')).submit('resetPassword')], { before: PanelsRenderHook.AUTH_PASSWORD_RESET_RESET_FORM_BEFORE, after: PanelsRenderHook.AUTH_PASSWORD_RESET_RESET_FORM_AFTER }));
|
|
1113
|
+
}
|
|
1114
|
+
async resetPassword() {
|
|
1115
|
+
const { req, panel } = this._ctx;
|
|
1116
|
+
const wait = passwordResetThrottle.hit(`${panel.getId()}:reset:${req.ip ?? ''}`);
|
|
1117
|
+
if (wait > 0) {
|
|
1118
|
+
throttled('filament-panels::auth/pages/password-reset/reset-password', wait);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
this._errors = await validateSchema(this.getSchema('form'));
|
|
1122
|
+
if (Object.keys(this._errors).length) {
|
|
1123
|
+
this.dispatch('form-validation-error', { livewireId: this.id });
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
const auth = panel.getAuth();
|
|
1127
|
+
const payload = panel.readResetPasswordToken(this.token);
|
|
1128
|
+
const user = payload ? await auth.find(payload.uid) : null;
|
|
1129
|
+
// Le jeton désigne l'utilisateur ; l'adresse de l'URL doit être la sienne et son mot de passe ne doit pas avoir changé depuis l'envoi.
|
|
1130
|
+
if (!payload || !user || user.email !== this.email || (await auth.getPasswordFingerprint(user)) !== payload.fp) {
|
|
1131
|
+
Notification.make().danger().title('This password reset token is invalid.').send();
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
await auth.update(user, { password: this.password });
|
|
1135
|
+
Notification.make().success().title('Your password has been reset.').send();
|
|
1136
|
+
this.redirect(panel.getLoginUrl());
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
/** Invitation à vérifier son adresse (port de EmailVerificationPrompt), avec le lien « Resend it. ». */
|
|
1140
|
+
export class EmailVerificationPrompt extends SimplePage {
|
|
1141
|
+
static callable = ['mountAction'];
|
|
1142
|
+
async mount() {
|
|
1143
|
+
const { panel, user } = this._ctx;
|
|
1144
|
+
if (!user || !(await panel.mustVerifyEmail(user)))
|
|
1145
|
+
this.redirect(panel.getUrl());
|
|
1146
|
+
}
|
|
1147
|
+
getTitle() { return t('filament-panels::auth/pages/email-verification/email-verification-prompt.title'); }
|
|
1148
|
+
getHeading() { return t('filament-panels::auth/pages/email-verification/email-verification-prompt.heading'); }
|
|
1149
|
+
contentSchema(schema) {
|
|
1150
|
+
const { user } = this._ctx;
|
|
1151
|
+
const expression = `mountAction('resendNotification')`;
|
|
1152
|
+
const link = `<button type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${['fi-ac-link-action', 'fi-link', 'fi-size-sm', ...sharedColors.getComponentClasses('link', 'primary')].join(' ')}" wire:click="${e(expression)}" >${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': expression }), 'sm')}<span class="fi-link-label"> ${e(t('filament-panels::auth/pages/email-verification/email-verification-prompt.actions.resend_notification.label'))}. </span></button>`;
|
|
1153
|
+
const text = (html) => Html.make(`<span class="fi-sc-text" > ${html} </span>`);
|
|
1154
|
+
return schema.components([
|
|
1155
|
+
text(e(t('filament-panels::auth/pages/email-verification/email-verification-prompt.messages.notification_sent', { email: user?.email ?? '' }))),
|
|
1156
|
+
text(`${e(t('filament-panels::auth/pages/email-verification/email-verification-prompt.messages.notification_not_received'))} ${link}`),
|
|
1157
|
+
]);
|
|
1158
|
+
}
|
|
1159
|
+
/** Seule action de la page : renvoyer l'e-mail (deux fois par minute au plus). */
|
|
1160
|
+
async mountAction(name) {
|
|
1161
|
+
const { req, panel, user } = this._ctx;
|
|
1162
|
+
if (name !== 'resendNotification' || !user)
|
|
1163
|
+
throw new WireError(404, 'Action introuvable.');
|
|
1164
|
+
this.skipRender();
|
|
1165
|
+
const wait = verificationThrottle.hit(`${panel.getId()}:verify:${user.id}`);
|
|
1166
|
+
if (wait > 0) {
|
|
1167
|
+
Notification.make().danger().title(t('filament-panels::auth/pages/email-verification/email-verification-prompt.notifications.notification_resend_throttled.title', { seconds: wait, minutes: Math.ceil(wait / 60) })).body(t('filament-panels::auth/pages/email-verification/email-verification-prompt.notifications.notification_resend_throttled.body', { seconds: wait, minutes: Math.ceil(wait / 60) })).send();
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
if (await panel.mustVerifyEmail(user))
|
|
1171
|
+
await sendVerificationEmail(panel, req, user);
|
|
1172
|
+
Notification.make().success().title(t('filament-panels::auth/pages/email-verification/email-verification-prompt.notifications.notification_resent.title')).send();
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
/** Profil de l'utilisateur (port de EditProfile) : nom, adresse, nouveau mot de passe ; le mot de passe actuel est exigé pour changer l'adresse ou le mot de passe. */
|
|
1176
|
+
export class EditProfile extends SimplePage {
|
|
1177
|
+
static callable = ['save', 'callSchemaComponentMethod'];
|
|
1178
|
+
static slug = 'profile';
|
|
1179
|
+
data = {};
|
|
1180
|
+
static getLabel() { return t('filament-panels::auth/pages/edit-profile.label'); }
|
|
1181
|
+
async mount() {
|
|
1182
|
+
const { user } = this._ctx;
|
|
1183
|
+
if (!user)
|
|
1184
|
+
throw new WireError(401, 'Non authentifié.');
|
|
1185
|
+
this.data = { name: user.name, email: user.email, password: null, passwordConfirmation: null, currentPassword: null };
|
|
1186
|
+
}
|
|
1187
|
+
async boot() { if (!this._ctx?.user)
|
|
1188
|
+
throw new WireError(401, 'Non authentifié.'); }
|
|
1189
|
+
getTitle() { return this.constructor.getLabel(); }
|
|
1190
|
+
hasLogo() { return false; }
|
|
1191
|
+
formSchema(schema) {
|
|
1192
|
+
const { panel, user } = this._ctx;
|
|
1193
|
+
const auth = panel.getAuth();
|
|
1194
|
+
return schema.operation('edit').components([
|
|
1195
|
+
TextInput.make('name').label(t('filament-panels::auth/pages/edit-profile.form.name.label')).required().maxLength(255).autofocus(),
|
|
1196
|
+
TextInput.make('email').label(t('filament-panels::auth/pages/edit-profile.form.email.label')).email().required().maxLength(255).live()
|
|
1197
|
+
.rule({ name: 'unique', passes: async (value) => { const other = await auth.findByEmail?.(String(value)); return !other || String(other.id) === String(user?.id); }, message: (attribute) => `The ${attribute} has already been taken.` }),
|
|
1198
|
+
passwordRules('passwordConfirmation', 'password confirmation').reduce((input, rule) => input.rule(rule), TextInput.make('password').label(t('filament-panels::auth/pages/edit-profile.form.password.label')).password().revealable().autocomplete('new-password').live()),
|
|
1199
|
+
TextInput.make('passwordConfirmation').label(t('filament-panels::auth/pages/edit-profile.form.password_confirmation.label')).password().autocomplete('new-password').revealable().required()
|
|
1200
|
+
.visible(({ get }) => !blankValue(get('password'))).dehydrated(false),
|
|
1201
|
+
TextInput.make('currentPassword').label(t('filament-panels::auth/pages/edit-profile.form.current_password.label')).helperText(t('filament-panels::auth/pages/edit-profile.form.current_password.below_content'))
|
|
1202
|
+
.password().autocomplete('current-password').revealable().required()
|
|
1203
|
+
.rule({ name: 'current_password', passes: async (value) => Boolean(user && (await auth.attempt(user.email, String(value)))), message: () => 'The password is incorrect.' })
|
|
1204
|
+
.visible(({ get }) => !blankValue(get('password')) || get('email') !== user?.email).dehydrated(false),
|
|
1205
|
+
]);
|
|
1206
|
+
}
|
|
1207
|
+
contentSchema(schema) {
|
|
1208
|
+
return schema.components(formContent('save', [
|
|
1209
|
+
Action.make('save').label(t('filament-panels::auth/pages/edit-profile.form.actions.save.label')).submit('save').keyBindings(['mod+s']),
|
|
1210
|
+
Action.make('back').label(t('filament-panels::auth/pages/edit-profile.actions.cancel.label')).color('gray').alpineClickHandler(`document.referrer ? window.history.back() : (window.location.href = ${JSON.stringify(this.panel.getUrl())})`),
|
|
1211
|
+
], { fullWidth: false, alignment: 'start' }));
|
|
1212
|
+
}
|
|
1213
|
+
async save() {
|
|
1214
|
+
const { panel, user } = this._ctx;
|
|
1215
|
+
if (!user)
|
|
1216
|
+
throw new WireError(401, 'Non authentifié.');
|
|
1217
|
+
const form = this.getSchema('form');
|
|
1218
|
+
this._errors = await validateSchema(form);
|
|
1219
|
+
if (Object.keys(this._errors).length) {
|
|
1220
|
+
this.dispatch('form-validation-error', { livewireId: this.id });
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
const data = await getSchemaState(form);
|
|
1224
|
+
await panel.getAuth().update(user, { name: String(data.name), email: String(data.email), password: blankValue(data.password) ? undefined : String(data.password) });
|
|
1225
|
+
this.data = { ...this.data, password: null, passwordConfirmation: null, currentPassword: null };
|
|
1226
|
+
Notification.make().success().title(t('filament-panels::auth/pages/edit-profile.notifications.saved.title')).send();
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
const blankValue = (value) => value === null || value === undefined || value === '';
|
|
1230
|
+
/** Création d'un locataire (port de RegisterTenant) : la sous-classe donne son libellé et son formulaire (`formSchema`). */
|
|
1231
|
+
export class RegisterTenant extends SimplePage {
|
|
1232
|
+
static callable = ['register', 'callSchemaComponentMethod'];
|
|
1233
|
+
static slug = 'new';
|
|
1234
|
+
data = {};
|
|
1235
|
+
static getLabel() { return 'Register tenant'; }
|
|
1236
|
+
/** Le panel accepte-t-il la création ? Par défaut : si le fournisseur de locataires sait créer. */
|
|
1237
|
+
static canView(panel, _user) { return typeof panel.getTenantProvider().create === 'function'; }
|
|
1238
|
+
async boot() {
|
|
1239
|
+
const ctx = this._ctx;
|
|
1240
|
+
if (!ctx?.user)
|
|
1241
|
+
throw new WireError(401, 'Non authentifié.');
|
|
1242
|
+
if (!(await this.constructor.canView(ctx.panel, ctx.user)))
|
|
1243
|
+
throw new WireError(404, 'Not Found');
|
|
1244
|
+
}
|
|
1245
|
+
mount() { fillSchema(this.getSchema('form'), this.data); }
|
|
1246
|
+
getTitle() { return this.constructor.getLabel(); }
|
|
1247
|
+
hasLogo() { return false; }
|
|
1248
|
+
formSchema(schema) { return schema; }
|
|
1249
|
+
contentSchema(schema) {
|
|
1250
|
+
return schema.components(formContent('register', [Action.make('register').label(this.constructor.getLabel()).submit('register')]));
|
|
1251
|
+
}
|
|
1252
|
+
mutateFormDataBeforeRegister(data) { return data; }
|
|
1253
|
+
handleRegistration(data) {
|
|
1254
|
+
const { panel, user } = this._ctx;
|
|
1255
|
+
return panel.getTenantProvider().create(data, user);
|
|
1256
|
+
}
|
|
1257
|
+
async register() {
|
|
1258
|
+
const { panel } = this._ctx;
|
|
1259
|
+
const form = this.getSchema('form');
|
|
1260
|
+
this._errors = await validateSchema(form);
|
|
1261
|
+
if (Object.keys(this._errors).length) {
|
|
1262
|
+
this.dispatch('form-validation-error', { livewireId: this.id });
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
const tenant = await this.handleRegistration(await this.mutateFormDataBeforeRegister(await getSchemaState(form)));
|
|
1266
|
+
this.redirect(panel.getUrl('', tenant));
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
/** Profil du locataire courant (port de EditTenantProfile), dans la mise en page complète. */
|
|
1270
|
+
export class EditTenantProfile extends Page {
|
|
1271
|
+
static callable = [...Page.callable, 'save'];
|
|
1272
|
+
static slug = 'profile';
|
|
1273
|
+
static shouldRegisterNavigation = false;
|
|
1274
|
+
data = {};
|
|
1275
|
+
static getLabel() { return 'Tenant profile'; }
|
|
1276
|
+
static getTitle() { return this.getLabel(); }
|
|
1277
|
+
isAccessAuthorized(ctx) { return ctx.tenant !== undefined && ctx.tenant !== null && typeof ctx.panel.getTenantProvider().update === 'function'; }
|
|
1278
|
+
mount() {
|
|
1279
|
+
const tenant = this.panel.getTenant();
|
|
1280
|
+
fillSchemaFromRecord(this.getSchema('form'), this.data, tenant ?? {});
|
|
1281
|
+
}
|
|
1282
|
+
formSchema(schema) { return schema; }
|
|
1283
|
+
contentSchema(schema) {
|
|
1284
|
+
return schema.components(formContent('save', [Action.make('save').label(t('filament-panels::pages/tenancy/edit-tenant-profile.form.actions.save.label')).submit('save').keyBindings(['mod+s'])], { fullWidth: false, alignment: 'start' }));
|
|
1285
|
+
}
|
|
1286
|
+
async save() {
|
|
1287
|
+
const form = this.getSchema('form');
|
|
1288
|
+
this._errors = await validateSchema(form);
|
|
1289
|
+
if (Object.keys(this._errors).length) {
|
|
1290
|
+
this.dispatch('form-validation-error', { livewireId: this.id });
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
await this.panel.getTenantProvider().update(this.panel.getTenant(), await getSchemaState(form));
|
|
1294
|
+
Notification.make().success().title(t('filament-panels::pages/tenancy/edit-tenant-profile.notifications.saved.title')).send();
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
export class Login extends SimplePage {
|
|
1298
|
+
static callable = ['authenticate', 'callSchemaComponentMethod'];
|
|
1299
|
+
data = {};
|
|
1300
|
+
mount() { fillSchema(this.getSchema('form'), this.data); }
|
|
1301
|
+
getTitle() { return t('filament-panels::auth/pages/login.title'); }
|
|
1302
|
+
getHeading() { return t('filament-panels::auth/pages/login.heading'); }
|
|
1303
|
+
getSubheading() {
|
|
1304
|
+
if (!this.panel.hasRegistration())
|
|
1305
|
+
return null;
|
|
1306
|
+
return `${e(t('filament-panels::auth/pages/login.actions.register.before'))} ${authLink(this.panel.getRegistrationUrl(), t('filament-panels::auth/pages/login.actions.register.label'))}`;
|
|
1307
|
+
}
|
|
1308
|
+
formSchema(schema) {
|
|
1309
|
+
return schema.components([
|
|
1310
|
+
TextInput.make('email').label(t('filament-panels::auth/pages/login.form.email.label')).email().required().autocomplete().autofocus(),
|
|
1311
|
+
TextInput.make('password').label(t('filament-panels::auth/pages/login.form.password.label'))
|
|
1312
|
+
.hint(this.panel.hasPasswordReset() ? new HtmlString(`<a href="${e(this.panel.getRequestPasswordResetUrl())}" class="${[...sharedColors.getComponentClasses('link', 'primary'), 'fi-link', 'fi-size-md'].join(' ')}" tabindex="-1" ><span class="fi-link-label"> ${e(t('filament-panels::auth/pages/login.actions.request_password_reset.label'))} </span></a>`) : null)
|
|
1313
|
+
.password().revealable().autocomplete('current-password').required(),
|
|
1314
|
+
Checkbox.make('remember').label(t('filament-panels::auth/pages/login.form.remember.label')),
|
|
1315
|
+
]);
|
|
1316
|
+
}
|
|
1317
|
+
contentSchema(schema) {
|
|
1318
|
+
return schema.components([
|
|
1319
|
+
RenderHook.make(PanelsRenderHook.AUTH_LOGIN_FORM_BEFORE),
|
|
1320
|
+
Form.make([EmbeddedSchema.make('form')]).id('form').livewireSubmitHandler('authenticate')
|
|
1321
|
+
.footer([Actions.make([Action.make('authenticate').label(t('filament-panels::auth/pages/login.form.actions.authenticate.label')).submit('authenticate')]).fullWidth().key('form-actions')]),
|
|
1322
|
+
// Formulaire de défi multi-facteur : masqué tant que la MFA n'est pas portée (jalon 7).
|
|
1323
|
+
Form.make([]).id('multiFactorChallengeForm').hidden(),
|
|
1324
|
+
RenderHook.make(PanelsRenderHook.AUTH_LOGIN_FORM_AFTER),
|
|
1325
|
+
]);
|
|
1326
|
+
}
|
|
1327
|
+
async authenticate() {
|
|
1328
|
+
this._errors = await validateSchema(this.getSchema('form'));
|
|
1329
|
+
if (Object.keys(this._errors).length) {
|
|
1330
|
+
this.dispatch('form-validation-error', { livewireId: this.id });
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
const { req, res, panel } = this._ctx;
|
|
1334
|
+
// Comme Filament : cinq tentatives par minute et par adresse IP, puis attente.
|
|
1335
|
+
const wait = loginThrottle.hit(`${panel.getId()}:${req.ip ?? ''}`);
|
|
1336
|
+
if (wait > 0) {
|
|
1337
|
+
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();
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
const user = await panel.getAuth().attempt(String(this.data.email), String(this.data.password));
|
|
1341
|
+
if (!user) {
|
|
1342
|
+
this.addError('data.email', t('filament-panels::auth/pages/login.messages.failed'));
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
loginThrottle.clear(`${panel.getId()}:${req.ip ?? ''}`);
|
|
1346
|
+
panel.logIn(res, user, Boolean(this.data.remember));
|
|
1347
|
+
this.redirect(panel.getUrl());
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
/** L'utilisateur du composant hôte a-t-il cette capacité sur la resource (ou le relation manager) du composant ? À utiliser dans `authorize()` de vos actions. */
|
|
1351
|
+
export const can = (livewire, ability, record = null) => canOn(livewire, ability, record);
|
|
1352
|
+
function canOn(livewire, ability, record = null) {
|
|
1353
|
+
const resource = livewire?.resource;
|
|
1354
|
+
return typeof resource?.can === 'function' ? resource.can(ability, livewire._ctx?.user ?? null, record) : true;
|
|
1355
|
+
}
|
|
1356
|
+
// ------------------------------------------------------------------ resources
|
|
1357
|
+
const plural = (s) => (/[^aeiou]y$/.test(s) ? s.slice(0, -1) + 'ies' : /(s|x|z|ch|sh)$/.test(s) ? s + 'es' : s + 's');
|
|
1358
|
+
const ucfirst = (s) => s.replace(/^./, (c) => c.toUpperCase());
|
|
1359
|
+
/** Resource : un modèle, ses pages, sa table et sa navigation (port partiel de Filament\Resources\Resource). */
|
|
1360
|
+
export class Resource {
|
|
1361
|
+
static panel;
|
|
1362
|
+
static slug = '';
|
|
1363
|
+
static modelLabel = 'record';
|
|
1364
|
+
static pluralModelLabel = null;
|
|
1365
|
+
static navigationIcon = null;
|
|
1366
|
+
static navigationGroup = null;
|
|
1367
|
+
static navigationSort = 0;
|
|
1368
|
+
static navigationBadge = null;
|
|
1369
|
+
static adapter;
|
|
1370
|
+
/** Dans un panel à locataires : les enregistrements sont-ils propres à chaque locataire ? (vrai par défaut, comme Filament) */
|
|
1371
|
+
static isScopedToTenant = true;
|
|
1372
|
+
/** Colonne du locataire pour cette resource (par défaut celle du panel : `panel.tenant(provider, { ownershipColumn })`). */
|
|
1373
|
+
static tenantOwnershipColumn = null;
|
|
1374
|
+
/**
|
|
1375
|
+
* Pages de la resource, à remplacer par vos sous-classes pour en changer le comportement :
|
|
1376
|
+
* `static pages = { index: MyList, create: MyCreate, edit: MyEdit }`.
|
|
1377
|
+
*/
|
|
1378
|
+
static pages = {};
|
|
1379
|
+
/** Page de consultation (`ViewRecord`) : vrai pour l'ajouter aux pages de la resource ; la ligne d'une table y mène alors, comme dans Filament. */
|
|
1380
|
+
static hasViewPage = false;
|
|
1381
|
+
/** Qui peut faire quoi sur cette resource. Sans politique, tout utilisateur connecté peut tout. */
|
|
1382
|
+
static policy = null;
|
|
1383
|
+
/** L'utilisateur a-t-il cette capacité (sur cet enregistrement, s'il y en a un) ? */
|
|
1384
|
+
static can(ability, user, record = null) {
|
|
1385
|
+
const rule = this.policy?.[ability];
|
|
1386
|
+
return this.panel.authorize(user, ability, { resource: this, record, rule: rule ? () => rule(user, record) : null });
|
|
1387
|
+
}
|
|
1388
|
+
/** Widgets de la resource (équivalent de getWidgets()) : enregistrés comme composants. */
|
|
1389
|
+
static widgets = [];
|
|
1390
|
+
/** Widgets affichés au-dessus de la table de la page de liste (équivalent de ListRecords::getHeaderWidgets()). */
|
|
1391
|
+
static headerWidgets = [];
|
|
1392
|
+
/** Relation managers affichés sous le formulaire d'édition (équivalent de getRelations()). */
|
|
1393
|
+
static relations = [];
|
|
1394
|
+
/** Onglets de la page de liste (équivalent de ListRecords::getTabs()). */
|
|
1395
|
+
static tabs() { return {}; }
|
|
1396
|
+
/** Resource « simple » (équivalent de ManageRecords) : une seule page, création et édition en modales. */
|
|
1397
|
+
static isSimple = false;
|
|
1398
|
+
static getPluralModelLabel() { return this.pluralModelLabel ?? plural(this.modelLabel); }
|
|
1399
|
+
static getTitleCasePluralModelLabel() { return this.getPluralModelLabel().split(' ').map(ucfirst).join(' '); }
|
|
1400
|
+
static getUrl(page = 'index', record) {
|
|
1401
|
+
const base = this.panel.getUrl(this.slug);
|
|
1402
|
+
if (page in this.relatedPages)
|
|
1403
|
+
return `${base}/${record}/${page}`;
|
|
1404
|
+
return page === 'index' ? base : page === 'create' ? `${base}/create` : page === 'view' ? `${base}/${record}` : `${base}/${record}/edit`;
|
|
1405
|
+
}
|
|
1406
|
+
/** Pages d'enregistrements liés (port de ManageRelatedRecords) : `{ comments: ManagePostComments }` répond sous `<slug>/{record}/comments`. */
|
|
1407
|
+
static relatedPages = {};
|
|
1408
|
+
/** Pages proposées dans la sous-navigation d'un enregistrement, dans l'ordre : `['view', 'edit', 'comments']`. Vide : pas de sous-navigation. */
|
|
1409
|
+
static recordSubNavigation = [];
|
|
1410
|
+
/** Port de getRecordSubNavigation() + generateNavigationItems() : un onglet par page déclarée, celui de la page courante actif. */
|
|
1411
|
+
static getRecordSubNavigation(page) {
|
|
1412
|
+
const pages = this.getPages();
|
|
1413
|
+
const key = page.getRecordKey() ?? '';
|
|
1414
|
+
return this.recordSubNavigation.filter((name) => name in pages).map((name) => {
|
|
1415
|
+
const related = this.relatedPages[name];
|
|
1416
|
+
return {
|
|
1417
|
+
label: related ? related.getNavigationLabel() : name === 'view' ? t('filament-panels::resources/pages/view-record.navigation_label') : t('filament-panels::resources/pages/edit-record.navigation_label'),
|
|
1418
|
+
icon: related ? (related.navigationIcon ?? 'o-rectangle-stack') : name === 'view' ? 'o-eye' : 'o-pencil-square',
|
|
1419
|
+
url: this.getUrl(name, key),
|
|
1420
|
+
active: page instanceof this.getRegisteredPage(name),
|
|
1421
|
+
};
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
static _registered = new WeakMap();
|
|
1425
|
+
/** Classe de page réellement enregistrée pour un nom (les pages sont générées une seule fois par resource). */
|
|
1426
|
+
static getRegisteredPage(name) { return this.getPages()[name]; }
|
|
1427
|
+
static table(table) { return table; }
|
|
1428
|
+
static form(schema) { return schema; }
|
|
1429
|
+
/** Schéma de la page de consultation (équivalent de `infolist()`). */
|
|
1430
|
+
static infolist(schema) { return schema; }
|
|
1431
|
+
// -------------------------------------------------------------- recherche globale
|
|
1432
|
+
/** Colonnes interrogées par la recherche globale ; par défaut, l'attribut de titre. Tableau vide : resource non cherchable. */
|
|
1433
|
+
static globallySearchableAttributes = null;
|
|
1434
|
+
static getGloballySearchableAttributes() { return this.globallySearchableAttributes ?? (this.recordTitleAttribute ? [this.recordTitleAttribute] : []); }
|
|
1435
|
+
static canGloballySearch() { return this.getGloballySearchableAttributes().length > 0; }
|
|
1436
|
+
/** Rang du groupe de cette resource dans les résultats (`$globalSearchSort`) ; sans rang : après les autres, dans l'ordre d'enregistrement. */
|
|
1437
|
+
static globalSearchSort = null;
|
|
1438
|
+
/** Nombre de résultats rendus par resource (50, comme Filament). */
|
|
1439
|
+
static globalSearchResultsLimit = 50;
|
|
1440
|
+
static getGlobalSearchResultTitle(record) { return this.getRecordTitle(record); }
|
|
1441
|
+
/** Précisions affichées sous le titre : `{ Author: 'Jane' }`. Les valeurs vides sont écartées. */
|
|
1442
|
+
static getGlobalSearchResultDetails(_record) { return {}; }
|
|
1443
|
+
/** Adresse d'un résultat : page « View », sinon édition ; pour une resource simple, la liste avec l'action ouverte. Nul si l'utilisateur n'a aucun accès. */
|
|
1444
|
+
static async getGlobalSearchResultUrl(record, user) {
|
|
1445
|
+
const key = this.adapter.getKey(record);
|
|
1446
|
+
const canView = await this.can('view', user, record);
|
|
1447
|
+
if (this.hasViewPage && canView)
|
|
1448
|
+
return this.getUrl('view', key);
|
|
1449
|
+
const canEdit = await this.can('update', user, record);
|
|
1450
|
+
if (!this.isSimple && canEdit)
|
|
1451
|
+
return this.getUrl('edit', key);
|
|
1452
|
+
const action = canView ? 'view' : canEdit ? 'edit' : null;
|
|
1453
|
+
return action ? `${this.getUrl('index')}?tableAction=${action}&tableActionRecord=${encodeURIComponent(key)}` : null;
|
|
1454
|
+
}
|
|
1455
|
+
static async getGlobalSearchResults(search, user) {
|
|
1456
|
+
const page = await this.adapter.paginate({ search, searchColumns: this.getGloballySearchableAttributes(), page: 1, perPage: Math.min(Math.max(this.globalSearchResultsLimit, 1), 200) });
|
|
1457
|
+
const results = [];
|
|
1458
|
+
for (const record of page.records) {
|
|
1459
|
+
const url = await this.getGlobalSearchResultUrl(record, user);
|
|
1460
|
+
if (url === null)
|
|
1461
|
+
continue;
|
|
1462
|
+
const details = {};
|
|
1463
|
+
for (const [label, value] of Object.entries(await this.getGlobalSearchResultDetails(record)))
|
|
1464
|
+
if (value !== null && value !== undefined && value !== '')
|
|
1465
|
+
details[label] = String(value);
|
|
1466
|
+
results.push({ title: this.getGlobalSearchResultTitle(record), url, details });
|
|
1467
|
+
}
|
|
1468
|
+
return results;
|
|
1469
|
+
}
|
|
1470
|
+
/** Attribut affiché comme titre d'un enregistrement (`$recordTitleAttribute`). */
|
|
1471
|
+
static recordTitleAttribute = null;
|
|
1472
|
+
/** Sans attribut de titre : le libellé du modèle, en capitales initiales (« Leave Request »), comme Filament. */
|
|
1473
|
+
static getRecordTitle(record) { return this.recordTitleAttribute ? String(record?.[this.recordTitleAttribute] ?? '') : this.modelLabel.split(' ').map(ucfirst).join(' '); }
|
|
1474
|
+
/** Pages de la resource, générées : liste, création, édition. */
|
|
1475
|
+
static getPages() {
|
|
1476
|
+
const known = Resource._registered.get(this);
|
|
1477
|
+
if (known)
|
|
1478
|
+
return known;
|
|
1479
|
+
const pages = this.buildPages();
|
|
1480
|
+
Resource._registered.set(this, pages);
|
|
1481
|
+
return pages;
|
|
1482
|
+
}
|
|
1483
|
+
static buildPages() {
|
|
1484
|
+
const resource = this;
|
|
1485
|
+
const List = class extends (resource.pages.index ?? ListRecords) {
|
|
1486
|
+
static resource = resource;
|
|
1487
|
+
static slug = resource.slug;
|
|
1488
|
+
static title = resource.getTitleCasePluralModelLabel();
|
|
1489
|
+
static navigationIcon = resource.navigationIcon;
|
|
1490
|
+
static navigationGroup = resource.navigationGroup;
|
|
1491
|
+
static navigationSort = resource.navigationSort;
|
|
1492
|
+
static navigationBadge = resource.navigationBadge;
|
|
1493
|
+
static getNavigationBadge = typeof resource.getNavigationBadge === 'function' ? () => resource.getNavigationBadge() : undefined;
|
|
1494
|
+
};
|
|
1495
|
+
const Create = class extends (resource.pages.create ?? CreateRecord) {
|
|
1496
|
+
static resource = resource;
|
|
1497
|
+
static slug = `${resource.slug}/create`;
|
|
1498
|
+
static shouldRegisterNavigation = false;
|
|
1499
|
+
};
|
|
1500
|
+
const Edit = class extends (resource.pages.edit ?? EditRecord) {
|
|
1501
|
+
static resource = resource;
|
|
1502
|
+
static slug = `${resource.slug}/{record}/edit`;
|
|
1503
|
+
static shouldRegisterNavigation = false;
|
|
1504
|
+
};
|
|
1505
|
+
if (resource.isSimple)
|
|
1506
|
+
return { index: List };
|
|
1507
|
+
const related = {};
|
|
1508
|
+
for (const [name, RelatedPage] of Object.entries(resource.relatedPages)) {
|
|
1509
|
+
related[name] = class extends RelatedPage {
|
|
1510
|
+
static parentResource = resource;
|
|
1511
|
+
static slug = `${resource.slug}/{record}/${name}`;
|
|
1512
|
+
static shouldRegisterNavigation = false;
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
if (!resource.hasViewPage)
|
|
1516
|
+
return { index: List, create: Create, edit: Edit, ...related };
|
|
1517
|
+
const View = class extends (resource.pages.view ?? ViewRecord) {
|
|
1518
|
+
static resource = resource;
|
|
1519
|
+
static slug = `${resource.slug}/{record}`;
|
|
1520
|
+
static shouldRegisterNavigation = false;
|
|
1521
|
+
};
|
|
1522
|
+
return { index: List, create: Create, edit: Edit, view: View, ...related };
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
/** Widget : composant affiché dans une grille de page (port de Filament\Widgets\Widget). */
|
|
1526
|
+
export class Widget extends Component {
|
|
1527
|
+
static componentName;
|
|
1528
|
+
/** Largeur dans la grille de widgets : un nombre de colonnes, ou `'full'`. */
|
|
1529
|
+
static columnSpan = 1;
|
|
1530
|
+
/** Chargé quand il devient visible (comme `$isLazy`). */
|
|
1531
|
+
static isLazy = true;
|
|
1532
|
+
get panel() { return this._ctx.panel; }
|
|
1533
|
+
/** Widget réservé à certains utilisateurs : rendre `false` le retire de la page et refuse son chargement. */
|
|
1534
|
+
static canView(_user) { return true; }
|
|
1535
|
+
async boot() {
|
|
1536
|
+
const ctx = this._ctx;
|
|
1537
|
+
if (!ctx?.user)
|
|
1538
|
+
throw new WireError(401, 'Non authentifié.');
|
|
1539
|
+
const WidgetClass = this.constructor;
|
|
1540
|
+
if (!(await ctx.panel.authorize(ctx.user, 'view', { widget: WidgetClass, rule: () => WidgetClass.canView(ctx.user) })))
|
|
1541
|
+
throw new WireError(403, 'Accès refusé.');
|
|
1542
|
+
}
|
|
1543
|
+
/** Classes et style de colonne de grille, au point de rupture `lg` comme Filament. */
|
|
1544
|
+
gridColumn() {
|
|
1545
|
+
const span = this.constructor.columnSpan;
|
|
1546
|
+
// Comme Filament : la portée est toujours écrite, même pour une seule colonne.
|
|
1547
|
+
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
|
+
}
|
|
1549
|
+
placeholder() {
|
|
1550
|
+
const column = this.gridColumn();
|
|
1551
|
+
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
|
+
}
|
|
1553
|
+
/** Enveloppe commune (port de filament-widgets::components.widget). */
|
|
1554
|
+
wrap(content, classes = []) {
|
|
1555
|
+
const column = this.gridColumn();
|
|
1556
|
+
return `<div${column.style ? ` style="${column.style}"` : ''} class="${cls(['fi-wi-widget', ...column.classes, ...classes])}">${content}</div>`;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Calendrier (propre à Navette) : vues mois, semaine et liste, navigation rendue par le serveur, sans bibliothèque JavaScript.
|
|
1561
|
+
*
|
|
1562
|
+
* Le minimum : étendre la classe et rendre les événements de la période affichée.
|
|
1563
|
+
*
|
|
1564
|
+
* class LeaveCalendar extends CalendarWidget {
|
|
1565
|
+
* static heading = 'Leave';
|
|
1566
|
+
* async getEvents({ start, end }) { return [{ id: 1, title: 'Ada — vacation', start: '2026-11-12', end: '2026-11-14', color: 'success' }]; }
|
|
1567
|
+
* }
|
|
1568
|
+
*
|
|
1569
|
+
* Tout le reste se redéfinit méthode par méthode : vues proposées, premier jour de la semaine, langue, libellés, nombre
|
|
1570
|
+
* d'événements par case, réaction au clic sur un jour (`onDateClick`) ou sur un événement (`onEventClick`), rendu d'un
|
|
1571
|
+
* événement (`renderEvent`), pied de case (`renderDayFooter`), boutons de la barre d'outils (`renderToolbarEnd`), et
|
|
1572
|
+
* l'apparence par les variables CSS de `.nv-cal` (`getExtraClasses`, `getStyle`).
|
|
1573
|
+
*/
|
|
1574
|
+
export class CalendarWidget extends Widget {
|
|
1575
|
+
static columnSpan = 'full';
|
|
1576
|
+
/** Titre affiché avant la période (« Leave — November 2026 »). */
|
|
1577
|
+
static heading = null;
|
|
1578
|
+
static callable = ['previousPeriod', 'nextPeriod', 'goToToday', 'setView', 'selectDate', 'selectEvent', 'showDay'];
|
|
1579
|
+
/** La vue et la date ne changent que par les méthodes ci-dessous, qui les valident. */
|
|
1580
|
+
static locked = ['view', 'date'];
|
|
1581
|
+
view = 'month';
|
|
1582
|
+
/** Date d'ancrage (`YYYY-MM-DD`) : le mois ou la semaine affichés la contiennent. Vide : aujourd'hui. */
|
|
1583
|
+
date = '';
|
|
1584
|
+
/** Vues proposées, la première étant celle d'arrivée. Une seule vue : le sélecteur disparaît. */
|
|
1585
|
+
getViews() { return ['month', 'week', 'list']; }
|
|
1586
|
+
/** 0 = dimanche, 1 = lundi… */
|
|
1587
|
+
getFirstDayOfWeek() { return 1; }
|
|
1588
|
+
/** Langue des noms de mois et de jours (`Intl`). */
|
|
1589
|
+
getLocale() { return 'en-US'; }
|
|
1590
|
+
getLabels() { return CALENDAR_LABELS; }
|
|
1591
|
+
/** En vue mois, au-delà de ce nombre, la case affiche « +N more » (qui ouvre la liste sur ce jour). */
|
|
1592
|
+
getMaxEventsPerDay() { return 3; }
|
|
1593
|
+
getHeading() { return this.constructor.heading; }
|
|
1594
|
+
/** Date du jour, `YYYY-MM-DD` (UTC par défaut ; à redéfinir pour un autre fuseau). */
|
|
1595
|
+
getToday() { return new Date().toISOString().slice(0, 10); }
|
|
1596
|
+
/** Clic sur un numéro de jour. Redéfinir cette méthode rend les jours cliquables : ouvrir une page (`this.redirect`), notifier, changer de vue… */
|
|
1597
|
+
onDateClick(_date) { }
|
|
1598
|
+
/** Clic sur un événement sans adresse. L'événement est relu côté serveur : `event.extra` est disponible. */
|
|
1599
|
+
onEventClick(_event) { }
|
|
1600
|
+
/** HTML d'un événement (déjà échappé), ou `null` pour le rendu par défaut (`renderCalendarEvent`). */
|
|
1601
|
+
renderEvent(_event, _context) { return null; }
|
|
1602
|
+
/** HTML ajouté au bas de chaque case (déjà échappé). */
|
|
1603
|
+
renderDayFooter(_day, _events) { return ''; }
|
|
1604
|
+
/** HTML placé à droite de la barre d'outils (déjà échappé) : un lien « New », un filtre… */
|
|
1605
|
+
renderToolbarEnd() { return ''; }
|
|
1606
|
+
/** Classes ajoutées au calendrier, pour y accrocher vos styles ou redéfinir ses variables CSS. */
|
|
1607
|
+
getExtraClasses() { return []; }
|
|
1608
|
+
/** Variables CSS posées sur le calendrier : `{ '--nv-cal-day-min-height': '9rem' }`. */
|
|
1609
|
+
getStyle() { return {}; }
|
|
1610
|
+
// ---------------------------------------------------------- navigation
|
|
1611
|
+
anchor() { return isDate(this.date) ? this.date : this.getToday(); }
|
|
1612
|
+
currentView() { const views = this.getViews().filter((view) => CALENDAR_VIEWS.includes(view)); return views.includes(this.view) ? this.view : (views[0] ?? 'month'); }
|
|
1613
|
+
shift(direction) { this.date = this.currentView() === 'week' ? addDays(this.anchor(), 7 * direction) : addMonths(this.anchor().slice(0, 8) + '01', direction); }
|
|
1614
|
+
previousPeriod() { this.shift(-1); }
|
|
1615
|
+
nextPeriod() { this.shift(1); }
|
|
1616
|
+
goToToday() { this.date = this.getToday(); }
|
|
1617
|
+
setView(view) { if (typeof view === 'string' && this.getViews().includes(view) && CALENDAR_VIEWS.includes(view))
|
|
1618
|
+
this.view = view; }
|
|
1619
|
+
/** « +N more » : la liste, ancrée sur ce jour (ou la semaine si la liste n'est pas proposée). */
|
|
1620
|
+
showDay(date) {
|
|
1621
|
+
if (!isDate(date))
|
|
1622
|
+
return;
|
|
1623
|
+
this.date = date;
|
|
1624
|
+
const views = this.getViews();
|
|
1625
|
+
this.view = views.includes('list') ? 'list' : views.includes('week') ? 'week' : this.currentView();
|
|
1626
|
+
}
|
|
1627
|
+
datesClickable() { return this.onDateClick !== CalendarWidget.prototype.onDateClick; }
|
|
1628
|
+
eventsClickable() { return this.onEventClick !== CalendarWidget.prototype.onEventClick; }
|
|
1629
|
+
async selectDate(date) { if (isDate(date) && this.datesClickable())
|
|
1630
|
+
await this.onDateClick(date); }
|
|
1631
|
+
/** L'identifiant vient du navigateur : l'événement est recherché parmi ceux de la période affichée, jamais cru sur parole. */
|
|
1632
|
+
async selectEvent(id) {
|
|
1633
|
+
if ((typeof id !== 'string' && typeof id !== 'number') || !this.eventsClickable())
|
|
1634
|
+
return;
|
|
1635
|
+
const view = this.currentView();
|
|
1636
|
+
const event = (await this.getEvents({ ...visibleRange(view, this.anchor(), this.getFirstDayOfWeek()), view })).find((candidate) => String(candidate.id) === String(id));
|
|
1637
|
+
if (event)
|
|
1638
|
+
await this.onEventClick(event);
|
|
1639
|
+
}
|
|
1640
|
+
async render() {
|
|
1641
|
+
const view = this.currentView();
|
|
1642
|
+
this.view = view;
|
|
1643
|
+
const anchor = this.anchor();
|
|
1644
|
+
const firstDay = ((Math.floor(this.getFirstDayOfWeek()) % 7) + 7) % 7;
|
|
1645
|
+
const events = await this.getEvents({ ...visibleRange(view, anchor, firstDay), view });
|
|
1646
|
+
const calendar = renderCalendar({
|
|
1647
|
+
heading: this.getHeading(), view, views: this.getViews().filter((candidate) => CALENDAR_VIEWS.includes(candidate)), anchor, today: this.getToday(), firstDay, locale: this.getLocale(), events,
|
|
1648
|
+
maxEventsPerDay: this.getMaxEventsPerDay(), datesClickable: this.datesClickable(), eventsClickable: this.eventsClickable(), labels: this.getLabels(),
|
|
1649
|
+
renderEvent: (event, context) => this.renderEvent(event, context), renderDayFooter: (day, dayEvents) => this.renderDayFooter(day, dayEvents), toolbarEnd: this.renderToolbarEnd(),
|
|
1650
|
+
});
|
|
1651
|
+
const extra = this.getExtraClasses().filter((name) => /^[A-Za-z0-9_:-]+$/.test(name));
|
|
1652
|
+
const style = Object.entries(this.getStyle()).filter(([name, value]) => /^--[a-z0-9-]+$/.test(name) && /^[^;{}<>]+$/.test(value)).map(([name, value]) => `${name}: ${value};`).join(' ');
|
|
1653
|
+
const decorated = calendar.replace('<div class="nv-cal ', `<div${style ? ` style="${e(style)}"` : ''} class="nv-cal ${extra.length ? `${extra.join(' ')} ` : ''}`);
|
|
1654
|
+
return this.wrap(widgetSection(decorated), ['nv-wi-calendar']);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Page calendrier (propre à Navette) : le même calendrier que `CalendarWidget`, sur une page entière, avec les actions de
|
|
1659
|
+
* Filament — donc des modales, des formulaires, des confirmations, des panneaux latéraux.
|
|
1660
|
+
*
|
|
1661
|
+
* class Planning extends CalendarPage {
|
|
1662
|
+
* static slug = 'planning';
|
|
1663
|
+
* async getEvents({ start, end }) { return db('shifts').where('day', '>=', start).where('day', '<=', end)… }
|
|
1664
|
+
* // Clic sur un jour : une modale avec un formulaire, prérempli avec la date.
|
|
1665
|
+
* dateAction() {
|
|
1666
|
+
* return Action.make('createShift').modalHeading('New shift')
|
|
1667
|
+
* .schema([Select.make('employee_id').options(…).required(), DatePicker.make('day').required()])
|
|
1668
|
+
* .fillForm(({ arguments: args }) => ({ day: CalendarPage.dateOf(args) }))
|
|
1669
|
+
* .action(async ({ data }) => { await db('shifts').insert(data); Notification.make().success().title('Saved').send(); });
|
|
1670
|
+
* }
|
|
1671
|
+
* // Clic sur un événement : une autre action ; l'événement est relu côté serveur.
|
|
1672
|
+
* eventAction() {
|
|
1673
|
+
* return Action.make('editShift').schema([…])
|
|
1674
|
+
* .fillForm(async ({ livewire, arguments: args }) => (await livewire.findEvent(args.event))?.extra ?? {})
|
|
1675
|
+
* .action(async ({ livewire, arguments: args, data }) => { const event = await livewire.findEvent(args.event); if (event) await db('shifts').where({ id: event.id }).update(data); });
|
|
1676
|
+
* }
|
|
1677
|
+
* }
|
|
1678
|
+
*
|
|
1679
|
+
* Trois façons de réagir à un clic, au choix : une **adresse** sur l'événement (`url` : page dédiée), une **action** (modale),
|
|
1680
|
+
* ou du **code libre** en redéfinissant `onDateClick()` / `onEventClick()` (rediriger, notifier, écrire en base, monter l'action
|
|
1681
|
+
* de son choix avec `this.mountAction(nom, arguments)`). `getHeaderActions()` ajoute des boutons en tête de page, et
|
|
1682
|
+
* `getCalendarActions()` déclare d'autres actions montables depuis votre propre rendu (`renderEvent`, `renderToolbarEnd`).
|
|
1683
|
+
*/
|
|
1684
|
+
export class CalendarPage extends Page {
|
|
1685
|
+
static callable = ['mountAction', 'unmountAction', 'callMountedAction', 'callSchemaComponentMethod', 'previousPeriod', 'nextPeriod', 'goToToday', 'setView', 'selectDate', 'selectEvent', 'showDay'];
|
|
1686
|
+
static locked = ['view', 'date'];
|
|
1687
|
+
view = 'month';
|
|
1688
|
+
date = '';
|
|
1689
|
+
getViews() { return ['month', 'week', 'list']; }
|
|
1690
|
+
getFirstDayOfWeek() { return 1; }
|
|
1691
|
+
getLocale() { return 'en-US'; }
|
|
1692
|
+
getLabels() { return CALENDAR_LABELS; }
|
|
1693
|
+
getMaxEventsPerDay() { return 3; }
|
|
1694
|
+
getCalendarHeading() { return null; }
|
|
1695
|
+
getToday() { return new Date().toISOString().slice(0, 10); }
|
|
1696
|
+
renderEvent(_event, _context) { return null; }
|
|
1697
|
+
renderDayFooter(_day, _events) { return ''; }
|
|
1698
|
+
renderToolbarEnd() { return ''; }
|
|
1699
|
+
getExtraClasses() { return []; }
|
|
1700
|
+
getStyle() { return {}; }
|
|
1701
|
+
/** Action ouverte au clic sur un jour ; elle reçoit `arguments.date`. Nul : les jours ne sont pas cliquables (sauf `onDateClick` redéfini). */
|
|
1702
|
+
dateAction() { return null; }
|
|
1703
|
+
/** Action ouverte au clic sur un événement sans adresse ; elle reçoit `arguments.event` (identifiant, à relire avec `findEvent`). */
|
|
1704
|
+
eventAction() { return null; }
|
|
1705
|
+
/** Autres actions montables depuis votre propre HTML : `wire:click="mountAction('nom', { … })"`. */
|
|
1706
|
+
getCalendarActions() { return []; }
|
|
1707
|
+
/** Par défaut : ouvre `dateAction()`. À redéfinir pour faire autre chose (rediriger vers une page de création, par exemple). */
|
|
1708
|
+
async onDateClick(date) { const action = this.dateAction(); if (action)
|
|
1709
|
+
await this.mountAction(action.getName(), { date }); }
|
|
1710
|
+
/** Par défaut : ouvre `eventAction()`. L'événement a été retrouvé côté serveur avant l'appel. */
|
|
1711
|
+
async onEventClick(event) { const action = this.eventAction(); if (action)
|
|
1712
|
+
await this.mountAction(action.getName(), { event: String(event.id) }); }
|
|
1713
|
+
/** Date d'un clic, relue dans les arguments d'une action : ils viennent du navigateur, seule une vraie date est rendue. */
|
|
1714
|
+
static dateOf(args) { return isDate(args?.date) ? args.date : null; }
|
|
1715
|
+
/** Événement de la période affichée portant cet identifiant, ou nul : ne jamais agir sur un identifiant reçu sans passer par ici. */
|
|
1716
|
+
async findEvent(id) {
|
|
1717
|
+
if (typeof id !== 'string' && typeof id !== 'number')
|
|
1718
|
+
return null;
|
|
1719
|
+
const view = this.currentView();
|
|
1720
|
+
return (await this.getEvents({ ...visibleRange(view, this.anchor(), this.firstDay()), view })).find((candidate) => String(candidate.id) === String(id)) ?? null;
|
|
1721
|
+
}
|
|
1722
|
+
async getMountableActions(context) {
|
|
1723
|
+
return [...(await super.getMountableActions(context)), ...[this.dateAction(), this.eventAction(), ...this.getCalendarActions()].filter((action) => action !== null)];
|
|
1724
|
+
}
|
|
1725
|
+
// ---------------------------------------------------------- navigation
|
|
1726
|
+
firstDay() { return ((Math.floor(this.getFirstDayOfWeek()) % 7) + 7) % 7; }
|
|
1727
|
+
anchor() { return isDate(this.date) ? this.date : this.getToday(); }
|
|
1728
|
+
currentView() { const views = this.getViews().filter((view) => CALENDAR_VIEWS.includes(view)); return views.includes(this.view) ? this.view : (views[0] ?? 'month'); }
|
|
1729
|
+
shift(direction) { this.date = this.currentView() === 'week' ? addDays(this.anchor(), 7 * direction) : addMonths(this.anchor().slice(0, 8) + '01', direction); }
|
|
1730
|
+
previousPeriod() { this.shift(-1); }
|
|
1731
|
+
nextPeriod() { this.shift(1); }
|
|
1732
|
+
goToToday() { this.date = this.getToday(); }
|
|
1733
|
+
setView(view) { if (typeof view === 'string' && this.getViews().includes(view) && CALENDAR_VIEWS.includes(view))
|
|
1734
|
+
this.view = view; }
|
|
1735
|
+
showDay(date) {
|
|
1736
|
+
if (!isDate(date))
|
|
1737
|
+
return;
|
|
1738
|
+
this.date = date;
|
|
1739
|
+
const views = this.getViews();
|
|
1740
|
+
this.view = views.includes('list') ? 'list' : views.includes('week') ? 'week' : this.currentView();
|
|
1741
|
+
}
|
|
1742
|
+
datesClickable() { return this.dateAction() !== null || this.onDateClick !== CalendarPage.prototype.onDateClick; }
|
|
1743
|
+
eventsClickable() { return this.eventAction() !== null || this.onEventClick !== CalendarPage.prototype.onEventClick; }
|
|
1744
|
+
async selectDate(date) { if (isDate(date) && this.datesClickable())
|
|
1745
|
+
await this.onDateClick(date); }
|
|
1746
|
+
async selectEvent(id) {
|
|
1747
|
+
if (!this.eventsClickable())
|
|
1748
|
+
return;
|
|
1749
|
+
const event = await this.findEvent(id);
|
|
1750
|
+
if (event)
|
|
1751
|
+
await this.onEventClick(event);
|
|
1752
|
+
}
|
|
1753
|
+
async renderContent() {
|
|
1754
|
+
const view = this.currentView();
|
|
1755
|
+
this.view = view;
|
|
1756
|
+
const anchor = this.anchor();
|
|
1757
|
+
const events = await this.getEvents({ ...visibleRange(view, anchor, this.firstDay()), view });
|
|
1758
|
+
const calendar = renderCalendar({
|
|
1759
|
+
heading: this.getCalendarHeading(), view, views: this.getViews().filter((candidate) => CALENDAR_VIEWS.includes(candidate)), anchor, today: this.getToday(), firstDay: this.firstDay(), locale: this.getLocale(), events,
|
|
1760
|
+
maxEventsPerDay: this.getMaxEventsPerDay(), datesClickable: this.datesClickable(), eventsClickable: this.eventsClickable(), labels: this.getLabels(),
|
|
1761
|
+
renderEvent: (event, context) => this.renderEvent(event, context), renderDayFooter: (day, dayEvents) => this.renderDayFooter(day, dayEvents), toolbarEnd: this.renderToolbarEnd(),
|
|
1762
|
+
});
|
|
1763
|
+
const extra = this.getExtraClasses().filter((name) => /^[A-Za-z0-9_:-]+$/.test(name));
|
|
1764
|
+
const style = Object.entries(this.getStyle()).filter(([name, value]) => /^--[a-z0-9-]+$/.test(name) && /^[^;{}<>]+$/.test(value)).map(([name, value]) => `${name}: ${value};`).join(' ');
|
|
1765
|
+
return widgetSection(calendar.replace('<div class="nv-cal ', `<div${style ? ` style="${e(style)}"` : ''} class="nv-cal ${extra.length ? `${extra.join(' ')} ` : ''}`));
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
/** Section sans en-tête des widgets fournis (port de `<x-filament::section>`). */
|
|
1769
|
+
const widgetSection = (content) => `<section x-data="{
|
|
1770
|
+
isCollapsed: false ,
|
|
1771
|
+
}" class="fi-section"><div class="fi-section-content-ctn"><div class="fi-section-content">${content}</div></div></section>`;
|
|
1772
|
+
/** Compte de l'utilisateur connecté : avatar, nom, déconnexion (port de Filament\Widgets\AccountWidget). */
|
|
1773
|
+
export class AccountWidget extends Widget {
|
|
1774
|
+
static isLazy = false;
|
|
1775
|
+
render() {
|
|
1776
|
+
const { req, res, panel, user } = this._ctx;
|
|
1777
|
+
const logout = t('filament-panels::widgets/account-widget.actions.logout.label');
|
|
1778
|
+
const loading = 'wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled"';
|
|
1779
|
+
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
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
/** Version de Navette et liens utiles (pendant de Filament\Widgets\FilamentInfoWidget, dont il reprend le balisage et les classes). */
|
|
1783
|
+
export class NavetteInfoWidget extends Widget {
|
|
1784
|
+
static isLazy = false;
|
|
1785
|
+
static homeUrl = 'https://github.com/NavetteJS';
|
|
1786
|
+
static documentationUrl = 'https://github.com/NavetteJS';
|
|
1787
|
+
static githubUrl = 'https://github.com/NavetteJS';
|
|
1788
|
+
static version = JSON.parse(readFileSync(dir('../package.json'), 'utf8')).version;
|
|
1789
|
+
render() {
|
|
1790
|
+
const WidgetClass = this.constructor;
|
|
1791
|
+
const link = (url, iconHtml, label) => `<a href="${e(url)}" target="_blank" class="fi-link fi-size-md" rel="noopener noreferrer">${iconHtml}<span class="fi-link-label">${e(label)}</span></a>`;
|
|
1792
|
+
const github = '<span class="fi-icon fi-size-md"><svg viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" fill="currentColor" fill-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"></path></svg></span>';
|
|
1793
|
+
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
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
/** Propriétés de table qu'une page de liste expose à ses widgets (port de InteractsWithPageTable / ExposesTableToWidgets). */
|
|
1797
|
+
const PAGE_TABLE_PROPERTIES = ['activeTab', 'paginators', 'tableColumnSearches', 'tableFilters', 'tableRecordsPerPage', 'tableSearch', 'tableSort'];
|
|
1798
|
+
/** Une statistique d'un StatsOverviewWidget (port de Stat). */
|
|
1799
|
+
export class Stat {
|
|
1800
|
+
_description = null;
|
|
1801
|
+
_descriptionIcon = null;
|
|
1802
|
+
_descriptionIconPosition = 'after';
|
|
1803
|
+
_color = null;
|
|
1804
|
+
_icon = null;
|
|
1805
|
+
_chart = null;
|
|
1806
|
+
_url = null;
|
|
1807
|
+
label;
|
|
1808
|
+
value;
|
|
1809
|
+
constructor(label, value) { this.label = label; this.value = value; }
|
|
1810
|
+
static make(label, value) { return new Stat(label, value); }
|
|
1811
|
+
description(text) { this._description = text; return this; }
|
|
1812
|
+
descriptionIcon(name, position = 'after') { this._descriptionIcon = name; this._descriptionIconPosition = position; return this; }
|
|
1813
|
+
/** Couleur de la description et de la courbe. */
|
|
1814
|
+
color(color) { this._color = color; return this; }
|
|
1815
|
+
icon(name) { this._icon = name; return this; }
|
|
1816
|
+
/** Courbe de tendance : une liste de valeurs, ou des valeurs par libellé. */
|
|
1817
|
+
chart(values) { this._chart = values; return this; }
|
|
1818
|
+
url(url) { this._url = url; return this; }
|
|
1819
|
+
hasChart() { return this._chart !== null; }
|
|
1820
|
+
toHtml(panel, key) {
|
|
1821
|
+
const tag = this._url ? 'a' : 'div';
|
|
1822
|
+
const color = this._color ?? 'gray';
|
|
1823
|
+
const filled = this.value !== null && this.value !== undefined && this.value !== '';
|
|
1824
|
+
const descriptionIcon = this._descriptionIcon ? icon(this._descriptionIcon) : '';
|
|
1825
|
+
const description = this._description
|
|
1826
|
+
? `<div class="${cls([...(color !== 'gray' ? panel.colors.getComponentClasses('stat-description', color) : []), 'fi-wi-stats-overview-stat-description'])}">${this._descriptionIconPosition === 'before' ? descriptionIcon : ''}<span>${e(this._description)}</span>${this._descriptionIconPosition === 'after' ? descriptionIcon : ''}</div>`
|
|
1827
|
+
: '';
|
|
1828
|
+
const chart = this._chart
|
|
1829
|
+
? `<div x-data="{ statsOverviewStatChart() {} }"><div x-load x-load-src="${e(panel.assetUrl('js/filament/widgets/components/stats-overview/stat/chart.js'))}" wire:ignore x-data="statsOverviewStatChart({
|
|
1830
|
+
key: ${e(js(key))},
|
|
1831
|
+
labels: ${e(js(Array.isArray(this._chart) ? this._chart.map((_, i) => i) : Object.keys(this._chart)))},
|
|
1832
|
+
values: ${e(js(Object.values(this._chart)))},
|
|
1833
|
+
})" class="${cls([...(color !== 'gray' ? panel.colors.getComponentClasses('stat-chart', color) : []), 'fi-wi-stats-overview-stat-chart'])}"><canvas x-ref="canvas" aria-hidden="true"></canvas><span aria-hidden="true" x-ref="backgroundColorElement" class="fi-wi-stats-overview-stat-chart-bg-color"></span><span aria-hidden="true" x-ref="borderColorElement" class="fi-wi-stats-overview-stat-chart-border-color"></span></div></div>`
|
|
1834
|
+
: '';
|
|
1835
|
+
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
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
/** Rangée de statistiques (port de StatsOverviewWidget). */
|
|
1839
|
+
export class StatsOverviewWidget extends Widget {
|
|
1840
|
+
static columnSpan = 'full';
|
|
1841
|
+
/** Intervalle de rafraîchissement (`'5s'` dans Filament par défaut), ou null pour ne pas rafraîchir. */
|
|
1842
|
+
static pollingInterval = '5s';
|
|
1843
|
+
/** Colonnes de la rangée : comme Filament, trois, ou quatre quand le nombre de statistiques s'y prête. */
|
|
1844
|
+
getColumns(count) { return count < 3 ? 3 : count % 3 !== 1 ? 3 : 4; }
|
|
1845
|
+
async render() {
|
|
1846
|
+
const stats = await this.getStats();
|
|
1847
|
+
const columns = this.getColumns(stats.length);
|
|
1848
|
+
const anonymous = `x-data="filamentSchemaComponent(${e(js({ key: null, exposedMethods: [], path: '', containerPath: '' }))})"`;
|
|
1849
|
+
// Comme Filament : une statistique à courbe reçoit un identifiant (`stats-overview-stat-<n>`), donc une clé de composant ; les autres restent anonymes.
|
|
1850
|
+
const cells = stats.map((stat, index) => {
|
|
1851
|
+
if (!stat.hasChart())
|
|
1852
|
+
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>`;
|
|
1853
|
+
const id = `stats-overview-stat-${index}`, key = `content.${id}`;
|
|
1854
|
+
return `<div wire:partial="schema-component::${key}" x-data="filamentSchemaComponent(${e(js({ key, exposedMethods: [], path: '', containerPath: '' }))})" wire:key="${e(`${this.id}.${key}`)}" class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component">${stat.toHtml(this.panel, id)}</div></div>`;
|
|
1855
|
+
}).join('');
|
|
1856
|
+
const interval = this.constructor.pollingInterval;
|
|
1857
|
+
const poll = interval && /^[0-9]+(ms|s)$/.test(interval) ? ` wire:poll.${interval}` : '';
|
|
1858
|
+
return this.wrap(`<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'content' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));"><div ${anonymous} class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component fi-grid-ctn"><div${poll} class="fi-sc-section"><section x-data="{
|
|
1859
|
+
isCollapsed: false,
|
|
1860
|
+
}" class="fi-section fi-section-not-contained"><div class="fi-section-content-ctn"><div class="fi-section-content fi-grid @xl:fi-grid-cols !@lg:fi-grid-cols fi-sc fi-sc-has-gap" style="--cols-cxl: repeat(${columns}, minmax(0, 1fr)); --cols-nclg: repeat(${columns}, minmax(0, 1fr)); --cols-default: repeat(1, minmax(0, 1fr));">${cells}</div></div></section></div></div></div></div>`, ['fi-wi-stats-overview']);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Graphique Chart.js (port de Filament\Widgets\ChartWidget). Une sous-classe donne `getType()` (`'line'`, `'bar'`, `'pie'`,
|
|
1865
|
+
* `'doughnut'`, `'radar'`, `'polarArea'`, `'scatter'`, `'bubble'`) et `getData()`. Le rendu est fait dans le navigateur par le
|
|
1866
|
+
* composant `chart.js` de Filament ; le serveur n'envoie de nouvelles données que si elles ont changé (somme de contrôle).
|
|
1867
|
+
*/
|
|
1868
|
+
export class ChartWidget extends Widget {
|
|
1869
|
+
static callable = ['updateChartData'];
|
|
1870
|
+
static locked = ['dataChecksum'];
|
|
1871
|
+
/** Intervalle de rafraîchissement (`'5s'` par défaut dans Filament), ou null pour ne pas rafraîchir. */
|
|
1872
|
+
static pollingInterval = '5s';
|
|
1873
|
+
static heading = null;
|
|
1874
|
+
static description = null;
|
|
1875
|
+
/** Couleur des séries qui n'en précisent pas. */
|
|
1876
|
+
static color = 'primary';
|
|
1877
|
+
/** Hauteur maximale du graphique (`'300px'`). */
|
|
1878
|
+
static maxHeight = null;
|
|
1879
|
+
static isCollapsible = false;
|
|
1880
|
+
dataChecksum = null;
|
|
1881
|
+
/** Filtre choisi, parmi `getFilters()`. */
|
|
1882
|
+
filter = null;
|
|
1883
|
+
_cachedData = null;
|
|
1884
|
+
getData() { return {}; }
|
|
1885
|
+
/** Options de Chart.js. */
|
|
1886
|
+
getOptions() { return null; }
|
|
1887
|
+
/** Filtres proposés dans l'en-tête : valeur vers libellé. */
|
|
1888
|
+
getFilters() { return null; }
|
|
1889
|
+
getHeading() { return this.constructor.heading; }
|
|
1890
|
+
getDescription() { return this.constructor.description; }
|
|
1891
|
+
getColor() { return this.constructor.color; }
|
|
1892
|
+
getMaxHeight() { return this.constructor.maxHeight; }
|
|
1893
|
+
/** Texte lu par les lecteurs d'écran à la place du graphique. */
|
|
1894
|
+
getChartAssistiveContent() { return null; }
|
|
1895
|
+
async getCachedData() { return (this._cachedData ??= await this.getData()); }
|
|
1896
|
+
async generateDataChecksum() { return createHash('md5').update(JSON.stringify(await this.getCachedData())).digest('hex'); }
|
|
1897
|
+
async mount() { this.dataChecksum = await this.generateDataChecksum(); }
|
|
1898
|
+
/** Appelé par le rafraîchissement périodique et avant chaque rendu : n'émet les données que si elles ont changé. */
|
|
1899
|
+
async updateChartData() {
|
|
1900
|
+
const checksum = await this.generateDataChecksum();
|
|
1901
|
+
if (checksum === this.dataChecksum)
|
|
1902
|
+
return;
|
|
1903
|
+
this.dataChecksum = checksum;
|
|
1904
|
+
this.dispatch('updateChartData', { data: await this.getCachedData() });
|
|
1905
|
+
}
|
|
1906
|
+
async render() {
|
|
1907
|
+
await this.updateChartData();
|
|
1908
|
+
const WidgetClass = this.constructor;
|
|
1909
|
+
const heading = this.getHeading(), description = this.getDescription(), filters = this.getFilters(), type = this.getType(), maxHeight = this.getMaxHeight();
|
|
1910
|
+
const hasMaxHeight = maxHeight !== null && maxHeight !== '' && maxHeight !== '100%';
|
|
1911
|
+
const data = await this.getCachedData();
|
|
1912
|
+
const isEmpty = Object.keys(data).length === 0;
|
|
1913
|
+
const assistive = isEmpty ? null : this.getChartAssistiveContent();
|
|
1914
|
+
const label = [heading, description].filter((value) => value !== null && value !== '').join('. ').trim();
|
|
1915
|
+
const collapsible = WidgetClass.isCollapsible;
|
|
1916
|
+
const interval = WidgetClass.pollingInterval;
|
|
1917
|
+
const poll = interval && /^[0-9]+(ms|s)$/.test(interval) ? ` wire:poll.${interval}="updateChartData"` : '';
|
|
1918
|
+
const filter = filters
|
|
1919
|
+
? `<div class="fi-input-wrp fi-wi-chart-filter"><div wire:loading.delay.default.flex wire:target="filter" wire:key="${e(createHash('md5').update(`${this.id}.filter.loading`).digest('hex').slice(0, 16))}" class="fi-input-wrp-prefix fi-inline">${loadingIndicator(new Attributes({ class: 'fi-color fi-color-gray' }))}</div><div wire:loading.delay.default.class.remove="fi-input-wrp-content-ctn-ps" wire:target="filter" class="fi-input-wrp-content-ctn fi-input-wrp-content-ctn-ps"><select aria-label="${e(t('filament-widgets::chart.filter.label'))}" wire:model.live="filter" class="fi-select-input fi-select-input-has-inline-prefix">${Object.entries(filters).map(([value, text]) => `<option value="${e(value)}">${e(text)}</option>`).join('')}</select></div></div>`
|
|
1920
|
+
: '';
|
|
1921
|
+
const hasText = (heading !== null && heading !== '') || (description !== null && description !== '');
|
|
1922
|
+
const hasHeader = hasText || filter !== '';
|
|
1923
|
+
const header = hasHeader
|
|
1924
|
+
? `<header${collapsible ? ` x-on:click="if (! $event.target.closest('.fi-section-header-after-ctn')) isCollapsed = ! isCollapsed"` : ''} class="fi-section-header"><div class="${cls(['fi-section-header-content', { 'fi-section-header-content-has-heading': hasText }])}">${hasText ? `<div class="fi-section-header-text-ctn">${heading ? `<h2 class="fi-section-header-heading">${e(heading)}</h2>` : ''}${description ? `<p class="fi-section-header-description">${e(description)}</p>` : ''}</div>` : ''}${filter ? `<div class="fi-section-header-after-ctn">${filter}</div>` : ''}</div>${collapsible ? `<button aria-expanded="true" x-bind:aria-expanded="(! isCollapsed).toString()" x-bind:aria-controls="$id('fi-section-content')" x-bind:aria-label="isCollapsed ? ${e(js(t('filament::components/section.actions.expand.label')))} : ${e(js(t('filament::components/section.actions.collapse.label')))}" aria-label="${e(t('filament::components/section.actions.collapse.label'))}" type="button" x-on:click.stop="isCollapsed = ! isCollapsed" class="fi-icon-btn fi-size-md fi-section-collapse-btn">${icon('m-chevron-up')}</button>` : ''}</header>`
|
|
1925
|
+
: '';
|
|
1926
|
+
const colors = ['bg', 'border', 'grid', 'text', 'tooltip-bg', 'tooltip-text', 'tooltip-border'];
|
|
1927
|
+
const refs = ['background', 'border', 'grid', 'text', 'tooltipBackground', 'tooltipText', 'tooltipBorder'];
|
|
1928
|
+
const frame = `<div x-load x-load-src="${e(this.panel.assetUrl('js/filament/widgets/components/chart.js'))}" wire:ignore data-chart-type="${e(type)}" x-data="chart({
|
|
1929
|
+
cachedData: ${e(js(data))},
|
|
1930
|
+
options: ${e(js(this.getOptions()))},
|
|
1931
|
+
type: ${e(js(type))},
|
|
1932
|
+
})" class="${cls([...this.panel.colors.getComponentClasses('stat-chart', this.getColor()), 'fi-wi-chart-frame', 'fi-wi-chart-canvas-ctn', { 'fi-wi-chart-frame-no-aspect-ratio': hasMaxHeight }])}"><canvas x-ref="canvas" aria-hidden="true" style="width: 100%; ${hasMaxHeight ? `max-height: ${e(maxHeight)}` : 'height: 100%; max-height: 100%'}"></canvas>${colors.map((name, index) => `<span aria-hidden="true" x-ref="${refs[index]}ColorElement" class="fi-wi-chart-${name}-color"></span>`).join('')}</div>`;
|
|
1933
|
+
const empty = isEmpty
|
|
1934
|
+
? `<div class="${cls(['fi-wi-chart-frame', { 'fi-wi-chart-frame-no-aspect-ratio': hasMaxHeight }])}"${hasMaxHeight ? ` style="min-height: ${e(maxHeight)}"` : ''}>${emptyState({ heading: t('filament-widgets::chart.empty.heading'), icon: 'o-x-mark', iconColor: 'gray', contained: false })}</div>`
|
|
1935
|
+
: '';
|
|
1936
|
+
const body = `<div${poll}${isEmpty ? ' style="display: none"' : ''}><div class="fi-wi-chart-image"${label ? ` role="img" aria-label="${e(label)}"` : ''}>${frame}</div>${assistive ? `<div class="fi-wi-chart-assistive-content fi-sr-only"><p>${e(assistive)}</p></div>` : ''}</div>${empty}`;
|
|
1937
|
+
return this.wrap(`<section x-data="{
|
|
1938
|
+
isCollapsed: false ,
|
|
1939
|
+
}"${collapsible ? ` x-id="['fi-section-content']" x-on:collapse-section.window="if ($event.detail.id == (null ?? $el.id)) isCollapsed = true" x-on:expand="isCollapsed = false" x-on:expand-section.window="if ($event.detail.id == (null ?? $el.id)) isCollapsed = false" x-on:open-section.window="if ($event.detail.id == (null ?? $el.id)) isCollapsed = false" x-on:toggle-section.window="if ($event.detail.id == (null ?? $el.id)) isCollapsed = ! isCollapsed" x-bind:class="isCollapsed && 'fi-collapsed'"` : ''} class="${cls(['fi-section', { 'fi-section-has-header': hasHeader, 'fi-collapsible': collapsible }])}">${header}<div${collapsible ? ` x-bind:id="$id('fi-section-content')"` : ''} class="fi-section-content-ctn"><div class="fi-section-content">${body}</div></div></section>`, ['fi-wi-chart']);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* Table dans un widget (port de Filament\Widgets\TableWidget) : recherche, tri, pagination simple (précédent / suivant).
|
|
1944
|
+
* Une sous-classe donne `table(table)` et `getDataAdapterForTable()`. Pas d'actions ni de filtres pour l'instant.
|
|
1945
|
+
*/
|
|
1946
|
+
export class TableWidget extends Widget {
|
|
1947
|
+
static columnSpan = 'full';
|
|
1948
|
+
static callable = ['sortTable', 'gotoPage', 'nextPage', 'previousPage', 'applyTableColumnManager', 'resetTableColumnManager'];
|
|
1949
|
+
/** Titre de la table ; par défaut tiré du nom de la classe (`FlaggedOrders` : « Flagged Orders »). */
|
|
1950
|
+
static heading = null;
|
|
1951
|
+
tableSearch = '';
|
|
1952
|
+
tableSort = null;
|
|
1953
|
+
tableColumnSearches = {};
|
|
1954
|
+
tableRecordsPerPage = 10;
|
|
1955
|
+
paginators = { page: 1 };
|
|
1956
|
+
tableColumns = [];
|
|
1957
|
+
getTableHeading() {
|
|
1958
|
+
const WidgetClass = this.constructor;
|
|
1959
|
+
return WidgetClass.heading ?? WidgetClass.name.replace(/Widget$/, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
1960
|
+
}
|
|
1961
|
+
getTable() { return this.table(new Table().heading(this.getTableHeading()).paginationMode('simple')); }
|
|
1962
|
+
mount() {
|
|
1963
|
+
const table = this.getTable();
|
|
1964
|
+
this.tableRecordsPerPage = table.getDefaultPageOption();
|
|
1965
|
+
this.tableColumns = table.getDefaultColumnState();
|
|
1966
|
+
initializeTableGrouping(table, this);
|
|
1967
|
+
}
|
|
1968
|
+
tableGrouping = null;
|
|
1969
|
+
sortTable(column = null, direction = null) { sortTable(this, column, direction); }
|
|
1970
|
+
gotoPage(page, _pageName = 'page') { this.paginators.page = Math.max(1, Math.floor(Number(page)) || 1); }
|
|
1971
|
+
nextPage(_pageName = 'page') { this.paginators.page = (Number(this.paginators.page) || 1) + 1; }
|
|
1972
|
+
previousPage(_pageName = 'page') { this.paginators.page = Math.max(1, (Number(this.paginators.page) || 1) - 1); }
|
|
1973
|
+
applyTableColumnManager(state = null, _wasReordered = false) { if (state)
|
|
1974
|
+
this.tableColumns = sanitizeColumnState(this.getTable(), state); }
|
|
1975
|
+
resetTableColumnManager() { this.tableColumns = this.getTable().getDefaultColumnState(); }
|
|
1976
|
+
updatedTableSearch() { this.paginators.page = 1; }
|
|
1977
|
+
updatedTableColumnSearches() { this.paginators.page = 1; }
|
|
1978
|
+
updatedTableRecordsPerPage() { this.paginators.page = 1; }
|
|
1979
|
+
updatedTableSort() { this.paginators.page = 1; }
|
|
1980
|
+
async render() {
|
|
1981
|
+
const scopes = [this.constructor];
|
|
1982
|
+
return this.wrap(`${renderHook(WidgetsRenderHook.TABLE_WIDGET_START, scopes)}${await renderTable(this.getTable(), this, this.getDataAdapterForTable(), { bulkActions: [], after: `<div wire:partial="action-modals" x-data="filamentActionModals({
|
|
1983
|
+
livewireId: ${js(this.id)},
|
|
1984
|
+
})" style="height: 0"></div>` })}${renderHook(WidgetsRenderHook.TABLE_WIDGET_END, scopes)}`, ['fi-wi-table']);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
/** Widgets qui reçoivent les filtres du tableau de bord (port de InteractsWithPageFilters) : `static reactive = PAGE_FILTERS` et une propriété `pageFilters`. */
|
|
1988
|
+
export const PAGE_FILTERS = ['pageFilters'];
|
|
1989
|
+
/**
|
|
1990
|
+
* Widget de statistiques lié à la table d'une page de liste (port de InteractsWithPageTable) : il reçoit l'état de la table
|
|
1991
|
+
* (recherche, filtres, onglet…) et peut agréger exactement les enregistrements qu'elle affiche.
|
|
1992
|
+
*/
|
|
1993
|
+
export class PageTableStatsWidget extends StatsOverviewWidget {
|
|
1994
|
+
/** Page de liste dont la table est observée (équivalent de getTablePage()). */
|
|
1995
|
+
static tablePage;
|
|
1996
|
+
static reactive = PAGE_TABLE_PROPERTIES;
|
|
1997
|
+
activeTab = null;
|
|
1998
|
+
paginators = { page: 1 };
|
|
1999
|
+
tableColumnSearches = {};
|
|
2000
|
+
tableFilters = {};
|
|
2001
|
+
tableRecordsPerPage = 10;
|
|
2002
|
+
tableSearch = '';
|
|
2003
|
+
tableSort = null;
|
|
2004
|
+
/** Agrégats sur la requête courante de la table. L'état reçu passe par la même validation que sur la page. */
|
|
2005
|
+
async aggregatePageTable(columns = {}, modify = null) {
|
|
2006
|
+
const PageClass = this.constructor.tablePage();
|
|
2007
|
+
const page = new PageClass();
|
|
2008
|
+
for (const property of PAGE_TABLE_PROPERTIES)
|
|
2009
|
+
page[property] = this[property];
|
|
2010
|
+
const query = getTableQuery(page.getTable(), page);
|
|
2011
|
+
// Restriction supplémentaire propre à une statistique (« parmi les lignes affichées, celles qui… »).
|
|
2012
|
+
if (modify)
|
|
2013
|
+
(query.modifiers ??= []).push(modify);
|
|
2014
|
+
return PageClass.resource.adapter.aggregate(query, columns);
|
|
2015
|
+
}
|
|
2016
|
+
/** Tendance mensuelle sur la requête courante de la table (voir `ObjectionAdapter.trendPerMonth`). */
|
|
2017
|
+
async trendPageTable(options) {
|
|
2018
|
+
const PageClass = this.constructor.tablePage();
|
|
2019
|
+
const page = new PageClass();
|
|
2020
|
+
for (const property of PAGE_TABLE_PROPERTIES)
|
|
2021
|
+
page[property] = this[property];
|
|
2022
|
+
return PageClass.resource.adapter.trendPerMonth(getTableQuery(page.getTable(), page), options);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
/** Composant de schéma qui intègre la table du composant hôte (port de EmbeddedTable). */
|
|
2026
|
+
export class EmbeddedTable extends SchemaComponent {
|
|
2027
|
+
static make() { return new EmbeddedTable(); }
|
|
2028
|
+
async toHtml() { return this.livewire.renderTable(); }
|
|
2029
|
+
}
|
|
2030
|
+
/** Onglets des relation managers d'une page d'édition : seul le manager de l'onglet actif est monté, en composant enfant « lazy » (port de RelationManagers + Tabs). */
|
|
2031
|
+
class RelationManagerTabs extends SchemaComponent {
|
|
2032
|
+
constructor() { super(); this._keyName = 'relationManagerTabs'; }
|
|
2033
|
+
getExposedMethods() { return ['getLazyLoadedTabBadges', 'getDeferredTabBadges']; }
|
|
2034
|
+
getLazyLoadedTabBadges() { return {}; }
|
|
2035
|
+
getDeferredTabBadges() { return {}; }
|
|
2036
|
+
async toHtml() {
|
|
2037
|
+
const page = this.livewire;
|
|
2038
|
+
const managers = page.resource.relations;
|
|
2039
|
+
const key = this.getKey();
|
|
2040
|
+
const id = page.id;
|
|
2041
|
+
const active = Math.min(Math.max(Math.floor(Number(page.activeRelationManager)) || 0, 0), managers.length - 1);
|
|
2042
|
+
const tabId = (index) => `fi-sc-tab-${createHash('md5').update(`${key}.${index}`).digest('hex')}`;
|
|
2043
|
+
const tabs = managers.map((manager, index) => `<button id="${tabId(index)}" role="tab" aria-controls="${key}.${index}" aria-selected="${index === active ? 'true' : 'false'}" x-bind:aria-selected="isTabSelected('${index}')" tabindex="${index === active ? '0' : '-1'}" x-bind:tabindex="getTabIndex('${index}')" data-tab-key="${index}" x-bind:data-tab-available="Boolean(true).toString()" x-show="true" x-on:click.capture="if (! selectTab('${index}', $event)) { $event.preventDefault(); $event.stopImmediatePropagation() }" x-bind:class="{ 'fi-active': isTabSelected('${index}') }" type="button" wire:click="$set('activeRelationManager', '${index}')" class="${cls(['fi-tabs-item', { 'fi-active': index === active }])}"><span class="fi-tabs-item-label">${e(manager.getTitle())}</span></button>`);
|
|
2044
|
+
const panels = [];
|
|
2045
|
+
for (const [index, manager] of managers.entries()) {
|
|
2046
|
+
let content = '';
|
|
2047
|
+
if (index === active) {
|
|
2048
|
+
const childKey = `${key}.${index}.${manager.componentName}`;
|
|
2049
|
+
const child = await page.mountChild(manager.componentName, { ownerRecord: page.recordKey }, `${id}.${childKey}`, { lazy: true });
|
|
2050
|
+
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
|
+
}
|
|
2052
|
+
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>`);
|
|
2053
|
+
}
|
|
2054
|
+
return `<div x-data="tabsSchemaComponent({
|
|
2055
|
+
activeTab: ${active + 1},
|
|
2056
|
+
hasTabPanels: true,
|
|
2057
|
+
isScrollable: true,
|
|
2058
|
+
isVertical: false,
|
|
2059
|
+
livewireId: ${e(js(id))},
|
|
2060
|
+
livewireProperty: 'activeRelationManager',
|
|
2061
|
+
tab: '${active}',
|
|
2062
|
+
})" 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
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
/** Onglet d'une page de liste : restreint la requête de la table et peut afficher un compteur (port de Tab). */
|
|
2066
|
+
export class Tab {
|
|
2067
|
+
_label;
|
|
2068
|
+
_badge = null;
|
|
2069
|
+
_badgeColor = 'primary';
|
|
2070
|
+
_hasBadgeColor = false;
|
|
2071
|
+
_query = null;
|
|
2072
|
+
constructor(label = null) { this._label = label; }
|
|
2073
|
+
static make(label = null) { return new Tab(label); }
|
|
2074
|
+
/** Compteur, calculé après l'affichage de la page (comme `lazyLoadedBadge()`). */
|
|
2075
|
+
badge(resolver) { this._badge = resolver; return this; }
|
|
2076
|
+
badgeColor(color) { this._badgeColor = color; this._hasBadgeColor = true; return this; }
|
|
2077
|
+
query(modifier) { this._query = modifier; return this; }
|
|
2078
|
+
getLabel(key) { return this._label ?? ucfirst(key.replace(/[_-]+/g, ' ')); }
|
|
2079
|
+
hasBadge() { return this._badge !== null; }
|
|
2080
|
+
async getBadge() { return this._badge ? this._badge() : null; }
|
|
2081
|
+
getBadgeColor() { return this._badgeColor; }
|
|
2082
|
+
/** Faux tant que `badgeColor()` n'a pas été appelé : un badge chargé après coup ne reçoit alors aucune classe de couleur, comme dans Filament. */
|
|
2083
|
+
hasBadgeColor() { return this._hasBadgeColor; }
|
|
2084
|
+
getQuery() { return this._query; }
|
|
2085
|
+
}
|
|
2086
|
+
/** Onglets de la page de liste (port de ResourceTabs) : masqués quand la page n'en déclare pas. */
|
|
2087
|
+
class ResourceTabs extends SchemaComponent {
|
|
2088
|
+
constructor() { super(); this._keyName = 'resourceTabs'; this._hidden = ({ livewire }) => Object.keys(livewire.getTabs()).length === 0; }
|
|
2089
|
+
getExposedMethods() { return ['getLazyLoadedTabBadges', 'getDeferredTabBadges']; }
|
|
2090
|
+
getDeferredTabBadges() { return {}; }
|
|
2091
|
+
/** Compteurs des onglets, demandés par le navigateur une fois la page affichée. */
|
|
2092
|
+
async getLazyLoadedTabBadges() {
|
|
2093
|
+
const page = this.livewire;
|
|
2094
|
+
const badges = {};
|
|
2095
|
+
for (const [key, tab] of Object.entries(page.getTabs())) {
|
|
2096
|
+
if (!tab.hasBadge())
|
|
2097
|
+
continue;
|
|
2098
|
+
badges[key] = { badge: await tab.getBadge(), badgeColorClasses: tab.hasBadgeColor() ? page.panel.colors.getComponentClasses('badge', tab.getBadgeColor()).join(' ') : '', badgeColorStyles: '', badgeTooltip: null, badgeIconHtml: null, badgeIconPosition: 'before' };
|
|
2099
|
+
}
|
|
2100
|
+
return badges;
|
|
2101
|
+
}
|
|
2102
|
+
async toHtml() {
|
|
2103
|
+
const page = this.livewire;
|
|
2104
|
+
const tabs = Object.entries(page.getTabs());
|
|
2105
|
+
const key = this.getKey();
|
|
2106
|
+
const id = page.id;
|
|
2107
|
+
const active = page.getActiveTabKey();
|
|
2108
|
+
const q = (k) => `'${k.replace(/[^A-Za-z0-9_-]/g, '')}'`;
|
|
2109
|
+
const buttons = tabs.map(([k, tab]) => {
|
|
2110
|
+
const d = `deferredBadges[${q(k)}]`;
|
|
2111
|
+
const badge = tab.hasBadge() ? `<span x-show="isLoadingDeferredBadges" x-cloak class="fi-tabs-item-badge-placeholder">${loadingIndicator(new Attributes(), 'sm')}</span><template x-if="
|
|
2112
|
+
! isLoadingDeferredBadges &&
|
|
2113
|
+
${d}?.badge != null
|
|
2114
|
+
"><span x-bind:class="
|
|
2115
|
+
'fi-badge fi-size-sm ' +
|
|
2116
|
+
(${d}?.badgeColorClasses ?? '')
|
|
2117
|
+
" x-bind:style="${d}?.badgeColorStyles ?? ''" x-init="
|
|
2118
|
+
let tooltip = ${d}?.badgeTooltip
|
|
2119
|
+
if (tooltip) {
|
|
2120
|
+
window.tippy?.($el, {
|
|
2121
|
+
content: tooltip,
|
|
2122
|
+
theme: $store.theme,
|
|
2123
|
+
})
|
|
2124
|
+
}
|
|
2125
|
+
"><template x-if="
|
|
2126
|
+
${d}?.badgeIconHtml &&
|
|
2127
|
+
${d}?.badgeIconPosition !== 'after'
|
|
2128
|
+
"><span x-html="${d}.badgeIconHtml"></span></template><span class="fi-badge-label-ctn"><span class="fi-badge-label" x-text="${d}?.badge"></span></span><template x-if="
|
|
2129
|
+
${d}?.badgeIconHtml &&
|
|
2130
|
+
${d}?.badgeIconPosition === 'after'
|
|
2131
|
+
"><span x-html="${d}.badgeIconHtml"></span></template></span></template>` : '';
|
|
2132
|
+
return `<button id="fi-sc-tab-${createHash('md5').update(`${key}.${k}`).digest('hex')}" aria-pressed="${k === active ? 'true' : 'false'}" x-bind:aria-pressed="isTabSelected(${q(k)})" tabindex="0" x-bind:tabindex="getTabIndex(${q(k)})" data-tab-key="${e(k)}" x-bind:data-tab-available="Boolean(true).toString()" x-show="true" x-on:click.capture="if (! selectTab(${q(k)}, $event)) { $event.preventDefault(); $event.stopImmediatePropagation() }" x-bind:class="{ 'fi-active': isTabSelected(${q(k)}) }" type="button" wire:click="$set('activeTab', ${k === '' ? 'null' : q(k)})" class="${cls(['fi-tabs-item', { 'fi-active': k === active }])}"><span class="fi-tabs-item-label">${e(tab.getLabel(k))}</span>${badge}</button>`;
|
|
2133
|
+
});
|
|
2134
|
+
return `<div x-data="tabsSchemaComponent({
|
|
2135
|
+
activeTab: ${Math.max(tabs.findIndex(([k]) => k === active), 0) + 1},
|
|
2136
|
+
hasTabPanels: false,
|
|
2137
|
+
isScrollable: true,
|
|
2138
|
+
isVertical: false,
|
|
2139
|
+
livewireId: ${e(js(id))},
|
|
2140
|
+
livewireProperty: 'activeTab',
|
|
2141
|
+
tab: ${q(active)},
|
|
2142
|
+
})" 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="${e(active)}" /><div x-data="{
|
|
2143
|
+
deferredBadges: {},
|
|
2144
|
+
isLoadingDeferredBadges: true,
|
|
2145
|
+
async init() {
|
|
2146
|
+
try {
|
|
2147
|
+
const badges = await this.$getLazyLoadedTabBadges()
|
|
2148
|
+
this.deferredBadges = badges ?? {}
|
|
2149
|
+
} finally {
|
|
2150
|
+
this.isLoadingDeferredBadges = false
|
|
2151
|
+
}
|
|
2152
|
+
},
|
|
2153
|
+
}" 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
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
/** Page de liste d'une resource : état de table et méthodes appelées par le navigateur (port de ListRecords + InteractsWithTable). */
|
|
2157
|
+
export class ListRecords extends Page {
|
|
2158
|
+
static callable = ['updateTableColumnState', 'sortTable', 'gotoPage', 'nextPage', 'previousPage', 'applyTableFilters', 'resetTableFiltersForm', 'removeTableFilters', 'removeTableFilter', 'applyTableColumnManager', 'resetTableColumnManager', 'toggleTableReordering', 'reorderTable'];
|
|
2159
|
+
static resource;
|
|
2160
|
+
tableSearch = '';
|
|
2161
|
+
tableSort = null;
|
|
2162
|
+
tableColumnSearches = {};
|
|
2163
|
+
tableRecordsPerPage = 10;
|
|
2164
|
+
paginators = { page: 1 };
|
|
2165
|
+
// Sélection : tenue par le navigateur, envoyée avec l'appel de l'action groupée (port de CanSelectRecords).
|
|
2166
|
+
selectedTableRecords = [];
|
|
2167
|
+
deselectedTableRecords = [];
|
|
2168
|
+
isTrackingDeselectedTableRecords = false;
|
|
2169
|
+
// Filtres : `tableDeferredFilters` est l'état du formulaire, `tableFilters` l'état appliqué (filtres différés, comme Filament par défaut).
|
|
2170
|
+
tableFilters = {};
|
|
2171
|
+
tableDeferredFilters = {};
|
|
2172
|
+
tableColumns = [];
|
|
2173
|
+
isTableReordering = false;
|
|
2174
|
+
/** Regroupement courant de la table (`identifiant:direction`), choisi dans la barre d'outils. */
|
|
2175
|
+
tableGrouping = null;
|
|
2176
|
+
updatedTableGrouping() { this.paginators.page = 1; }
|
|
2177
|
+
/** Entre dans le mode « tri manuel » ou en sort (port de toggleTableReordering). */
|
|
2178
|
+
async toggleTableReordering() { if (this.getTable().getReorderColumn() !== null && (await canOn(this, 'reorder'))) {
|
|
2179
|
+
this.isTableReordering = !this.isTableReordering;
|
|
2180
|
+
this.deselectAllTableRecords();
|
|
2181
|
+
} }
|
|
2182
|
+
/** Enregistre le nouvel ordre : seules les clés de la table sont retenues, chacune reçoit son rang (port de reorderTable). */
|
|
2183
|
+
async reorderTable(order, _draggedRecordKey = null) {
|
|
2184
|
+
const column = this.getTable().getReorderColumn();
|
|
2185
|
+
if (column === null || !this.isTableReordering || !Array.isArray(order) || order.length > REORDER_LIMIT || !(await canOn(this, 'reorder')))
|
|
2186
|
+
return;
|
|
2187
|
+
const adapter = this.getDataAdapterForTable();
|
|
2188
|
+
const allowed = new Set(await adapter.keysInScope(order.filter((key) => typeof key === 'string' || typeof key === 'number').map(String)));
|
|
2189
|
+
let rank = 0;
|
|
2190
|
+
for (const key of order.map(String))
|
|
2191
|
+
if (allowed.delete(key))
|
|
2192
|
+
await adapter.update(key, { [column]: ++rank });
|
|
2193
|
+
}
|
|
2194
|
+
async mount(_params = {}) {
|
|
2195
|
+
const table = this.getTable();
|
|
2196
|
+
this.tableFilters = table.getDefaultFilterState();
|
|
2197
|
+
this.tableDeferredFilters = table.getDefaultFilterState();
|
|
2198
|
+
this.tableColumns = table.getDefaultColumnState();
|
|
2199
|
+
// Groupe par défaut présélectionné, comme Filament.
|
|
2200
|
+
initializeTableGrouping(table, this);
|
|
2201
|
+
// `?tableAction=view&tableActionRecord=12` (lien d'un résultat de recherche globale) : l'action de ligne s'ouvre à l'arrivée, avec les contrôles habituels.
|
|
2202
|
+
const query = (this._ctx?.req?.query ?? {});
|
|
2203
|
+
if (typeof query.tableAction === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(query.tableAction) && typeof query.tableActionRecord === 'string' && query.tableActionRecord.length <= 64) {
|
|
2204
|
+
try {
|
|
2205
|
+
await this.mountAction(query.tableAction, {}, { table: true, recordKey: query.tableActionRecord });
|
|
2206
|
+
}
|
|
2207
|
+
catch (error) {
|
|
2208
|
+
if (!(error instanceof WireError))
|
|
2209
|
+
throw error;
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
/** Formulaire des filtres : un groupe par filtre, lié à `tableDeferredFilters.<filtre>`. */
|
|
2214
|
+
tableFiltersFormSchema(schema) {
|
|
2215
|
+
const table = this.getTable();
|
|
2216
|
+
// Au-dessus du contenu, les filtres se répartissent sur plusieurs colonnes (comme Filament) ; dans le menu déroulant, sur une seule.
|
|
2217
|
+
const columns = table.getFiltersLayout() === 'above-content-collapsible' ? { sm: 2, lg: 3, xl: 4, '2xl': 5 } : 1;
|
|
2218
|
+
return schema.statePath('tableDeferredFilters').columns(columns).partiallyRendered().components(table.getFilters().map((filter) => {
|
|
2219
|
+
// Un filtre peut fournir son propre formulaire (constructeur de requêtes) ; sinon c'est un sélecteur.
|
|
2220
|
+
const custom = filter.getFormComponents?.();
|
|
2221
|
+
if (custom)
|
|
2222
|
+
return StatefulGroup.make(filter.name, custom).columnSpan(filter.getColumnSpan?.() ?? { default: 1, lg: 1 });
|
|
2223
|
+
const definition = filter.getFormDefinition();
|
|
2224
|
+
return StatefulGroup.make(filter.name, [Select.make('value').label(filter.getLabel()).placeholder(definition.placeholder).options(filter.getFormOptions ? () => filter.getFormOptions() : definition.options)]).columnSpan({ default: 1, lg: 1 });
|
|
2225
|
+
}));
|
|
2226
|
+
}
|
|
2227
|
+
applyTableFilters() {
|
|
2228
|
+
// Seuls les filtres déclarés par la table passent dans l'état appliqué : chacun nettoie son état, ou à défaut seules ses clés connues sont reprises.
|
|
2229
|
+
this.tableFilters = Object.fromEntries(this.getTable().getFilters().map((filter) => {
|
|
2230
|
+
const deferred = this.tableDeferredFilters?.[filter.name];
|
|
2231
|
+
if (filter.sanitizeState)
|
|
2232
|
+
return [filter.name, filter.sanitizeState(deferred)];
|
|
2233
|
+
return [filter.name, Object.fromEntries(Object.keys(filter.getDefaultState()).map((key) => [key, deferred?.[key] ?? null]))];
|
|
2234
|
+
}));
|
|
2235
|
+
this.paginators.page = 1;
|
|
2236
|
+
}
|
|
2237
|
+
/** Retrait d'un indicateur de filtre actif : le champ visé est effacé de l'état appliqué et du formulaire. */
|
|
2238
|
+
removeTableFilter(filterName, field = null) {
|
|
2239
|
+
const filter = this.getTable().getFilters().find((candidate) => candidate.name === filterName);
|
|
2240
|
+
if (!filter)
|
|
2241
|
+
return;
|
|
2242
|
+
const current = this.tableFilters?.[filter.name];
|
|
2243
|
+
const next = filter.removeIndicator && typeof field === 'string' && current && typeof current === 'object' ? filter.removeIndicator(current, field) : filter.getDefaultState();
|
|
2244
|
+
this.tableFilters = { ...this.tableFilters, [filter.name]: next };
|
|
2245
|
+
this.tableDeferredFilters = { ...this.tableDeferredFilters, [filter.name]: JSON.parse(JSON.stringify(next)) };
|
|
2246
|
+
this.paginators.page = 1;
|
|
2247
|
+
}
|
|
2248
|
+
/** Champ réactif du formulaire des filtres (changement d'opérateur d'une règle) : exécute son `afterStateUpdated`. */
|
|
2249
|
+
async updatedTableDeferredFilters(_value, key) {
|
|
2250
|
+
if (!key)
|
|
2251
|
+
return;
|
|
2252
|
+
const path = `tableDeferredFilters.${key}`;
|
|
2253
|
+
const field = this.getSchema('tableFiltersForm').getFlatComponents().find((c) => c instanceof Field && c.getStatePath() === path);
|
|
2254
|
+
if (!field?.hasAfterStateUpdated())
|
|
2255
|
+
return;
|
|
2256
|
+
const container = path.split('.').slice(0, -1);
|
|
2257
|
+
await field.callAfterStateUpdated((name, value) => {
|
|
2258
|
+
if (!/^[A-Za-z0-9_]+$/.test(name))
|
|
2259
|
+
return;
|
|
2260
|
+
let node = this;
|
|
2261
|
+
for (const segment of container)
|
|
2262
|
+
node = node?.[segment];
|
|
2263
|
+
if (node && typeof node === 'object')
|
|
2264
|
+
node[name] = value;
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
resetTableFiltersForm() {
|
|
2268
|
+
this.tableFilters = this.getTable().getDefaultFilterState();
|
|
2269
|
+
this.tableDeferredFilters = this.getTable().getDefaultFilterState();
|
|
2270
|
+
this.paginators.page = 1;
|
|
2271
|
+
this.dispatch('deselectAllTableRecords', [], { self: true });
|
|
2272
|
+
}
|
|
2273
|
+
removeTableFilters() { this.resetTableFiltersForm(); }
|
|
2274
|
+
applyTableColumnManager(state = null, _wasReordered = false) { if (state)
|
|
2275
|
+
this.tableColumns = sanitizeColumnState(this.getTable(), state); }
|
|
2276
|
+
resetTableColumnManager() { this.tableColumns = this.getTable().getDefaultColumnState(); }
|
|
2277
|
+
// --- onglets de liste : la clé vide est l'onglet par défaut (`null` côté navigateur)
|
|
2278
|
+
activeTab = null;
|
|
2279
|
+
getTabs() { return this.resource.tabs?.() ?? {}; }
|
|
2280
|
+
/** Onglet actif, ramené à une clé déclarée (une valeur inconnue envoyée par le navigateur vaut l'onglet par défaut). */
|
|
2281
|
+
getActiveTabKey() {
|
|
2282
|
+
const tabs = this.getTabs();
|
|
2283
|
+
const key = this.activeTab ?? '';
|
|
2284
|
+
return Object.prototype.hasOwnProperty.call(tabs, key) ? key : (Object.keys(tabs)[0] ?? '');
|
|
2285
|
+
}
|
|
2286
|
+
updatedActiveTab() { this.paginators.page = 1; this.deselectAllTableRecords(); }
|
|
2287
|
+
modifyTableQuery(query) {
|
|
2288
|
+
const tab = this.getTabs()[this.getActiveTabKey()];
|
|
2289
|
+
const modifier = tab?.getQuery();
|
|
2290
|
+
if (modifier)
|
|
2291
|
+
(query.modifiers ??= []).push(modifier);
|
|
2292
|
+
}
|
|
2293
|
+
get resource() { return this.constructor.resource; }
|
|
2294
|
+
getHeaderWidgets() { return this.resource.headerWidgets ?? []; }
|
|
2295
|
+
/** Port de ExposesTableToWidgets : l'état de la table est passé aux widgets, qui peuvent refaire la même requête. */
|
|
2296
|
+
getWidgetData() { return Object.fromEntries(PAGE_TABLE_PROPERTIES.map((property) => [property, this[property]])); }
|
|
2297
|
+
rendersActionModalsInPage() { return false; }
|
|
2298
|
+
getBulkActions() { return this.getTable().getBulkActions() ?? [DeleteBulkAction.make()]; }
|
|
2299
|
+
/** Actions de ligne déclarées par la table, groupes dépliés. */
|
|
2300
|
+
getRecordActions() { return this.getTable().getRecordActions().flatMap((a) => (a instanceof ActionGroup ? a.getActions() : [a])); }
|
|
2301
|
+
getMountableActions(context) {
|
|
2302
|
+
if (typeof context.schemaComponent === 'string')
|
|
2303
|
+
return this.getSchemaComponentActions(context.schemaComponent);
|
|
2304
|
+
if (context.table === true && context.bulk === true)
|
|
2305
|
+
return this.getBulkActions();
|
|
2306
|
+
if (context.table === true && typeof context.recordKey === 'string')
|
|
2307
|
+
return this.getRecordActions();
|
|
2308
|
+
if (context.table === true)
|
|
2309
|
+
return this.getTable().getHeaderActions();
|
|
2310
|
+
return this.getHeaderActions();
|
|
2311
|
+
}
|
|
2312
|
+
/** Seules les formes de contexte connues sont retenues ; la clé d'enregistrement est revérifiée en base au montage. */
|
|
2313
|
+
resolveMountContext(clientContext) {
|
|
2314
|
+
const c = clientContext && typeof clientContext === 'object' ? clientContext : {};
|
|
2315
|
+
if (typeof c.schemaComponent === 'string' && c.schemaComponent.length <= 200)
|
|
2316
|
+
return { schemaComponent: c.schemaComponent };
|
|
2317
|
+
if (c.table === true && c.bulk === true)
|
|
2318
|
+
return { table: true, bulk: true };
|
|
2319
|
+
if (c.table === true && (typeof c.recordKey === 'string' || typeof c.recordKey === 'number'))
|
|
2320
|
+
return { table: true, recordKey: String(c.recordKey) };
|
|
2321
|
+
if (c.table === true)
|
|
2322
|
+
return { table: true };
|
|
2323
|
+
return {};
|
|
2324
|
+
}
|
|
2325
|
+
async resolveActionRecord(context) {
|
|
2326
|
+
return typeof context.recordKey === 'string' ? ((await this.resource.adapter.find(context.recordKey)) ?? null) : null;
|
|
2327
|
+
}
|
|
2328
|
+
/**
|
|
2329
|
+
* Écriture d'une cellule modifiable (port de `updateTableColumnState`). Tout vient du navigateur : la colonne doit exister, être
|
|
2330
|
+
* modifiable et visible, l'enregistrement être dans la portée de la table et modifiable par l'utilisateur, la valeur être valide.
|
|
2331
|
+
*/
|
|
2332
|
+
async updateTableColumnState(columnName, recordKey, input) {
|
|
2333
|
+
// Toutes les vérifications (colonne visible et modifiable, portée, désactivation, autorisation, validation, crochets) sont dans le paquet tables.
|
|
2334
|
+
return updateEditableColumnState(this.getTable(), this, this.getDataAdapterForTable(), columnName, recordKey, input, { authorize: (record) => canOn(this, 'update', record) });
|
|
2335
|
+
}
|
|
2336
|
+
/** Clés sélectionnées : la liste envoyée, ou — en mode « tout sélectionner » — tous les enregistrements de la requête courante sauf ceux désélectionnés. */
|
|
2337
|
+
async getSelectedRecordKeys() {
|
|
2338
|
+
const clean = (list) => (Array.isArray(list) ? list.filter((k) => typeof k === 'string' || typeof k === 'number').map(String) : []);
|
|
2339
|
+
// La sélection vient du navigateur : elle est ramenée aux enregistrements que cette table peut réellement afficher.
|
|
2340
|
+
if (!this.isTrackingDeselectedTableRecords)
|
|
2341
|
+
return this.resource.adapter.keysInScope(clean(this.selectedTableRecords));
|
|
2342
|
+
const excluded = new Set(clean(this.deselectedTableRecords));
|
|
2343
|
+
return (await this.resource.adapter.listKeys(getTableQuery(this.getTable(), this))).filter((k) => !excluded.has(k));
|
|
2344
|
+
}
|
|
2345
|
+
deselectAllTableRecords() {
|
|
2346
|
+
this.selectedTableRecords = [];
|
|
2347
|
+
this.deselectedTableRecords = [];
|
|
2348
|
+
this.isTrackingDeselectedTableRecords = false;
|
|
2349
|
+
this.dispatch('deselectAllTableRecords', [], { self: true });
|
|
2350
|
+
}
|
|
2351
|
+
getTable() {
|
|
2352
|
+
const resource = this.resource;
|
|
2353
|
+
const table = new Table().pluralModelLabel(resource.getPluralModelLabel());
|
|
2354
|
+
// Resource simple : la ligne ouvre l'édition en modale ; sinon elle mène à la page d'édition.
|
|
2355
|
+
return resource.table(resource.isSimple ? table.recordAction('edit') : table.recordUrl((record) => resource.getUrl(resource.hasViewPage ? 'view' : 'edit', resource.adapter.getKey(record))));
|
|
2356
|
+
}
|
|
2357
|
+
async renderTable() {
|
|
2358
|
+
const resource = this.resource;
|
|
2359
|
+
const ctx = { livewire: this, component: null, operation: null, record: null, get: () => undefined, state: undefined };
|
|
2360
|
+
const bulkActions = [];
|
|
2361
|
+
for (const action of this.getBulkActions())
|
|
2362
|
+
if (await action.isVisible(ctx))
|
|
2363
|
+
bulkActions.push(await action.toGroupedHtml(ctx, { table: true, bulk: true }));
|
|
2364
|
+
const table = this.getTable();
|
|
2365
|
+
return renderTable(table, this, resource.adapter, {
|
|
2366
|
+
bulkActions, headerActions: await this.renderTableHeaderActions(ctx), after: await this.renderActionModals(),
|
|
2367
|
+
filtersForm: table.getFilters().length ? await this.getSchema('tableFiltersForm').toHtml() : null,
|
|
2368
|
+
rowActionsHtml: table.getRecordActions().length ? async (record) => {
|
|
2369
|
+
const key = resource.adapter.getKey(record);
|
|
2370
|
+
const recordCtx = { ...ctx, record };
|
|
2371
|
+
const hash = createHash('md5').update(key).digest('hex');
|
|
2372
|
+
const parts = [];
|
|
2373
|
+
for (const action of table.getRecordActions()) {
|
|
2374
|
+
if (action instanceof ActionGroup)
|
|
2375
|
+
parts.push(await action.toHtml(recordCtx, { recordKey: key, table: true }, (a) => `${this.id}.actions.${a.getName()}.${hash}`));
|
|
2376
|
+
else if (await action.isVisible(recordCtx))
|
|
2377
|
+
parts.push(await action.toLinkHtml(recordCtx, { recordKey: key, table: true }, `${this.id}.actions.${action.getName()}.${hash}`));
|
|
2378
|
+
}
|
|
2379
|
+
return parts.join('');
|
|
2380
|
+
} : undefined,
|
|
2381
|
+
canReorder: table.getReorderColumn() !== null ? await canOn(this, 'reorder') : undefined,
|
|
2382
|
+
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
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
/** Actions affichées dans l'en-tête de la table (aucune pour une page de liste : elles sont dans l'en-tête de page). */
|
|
2386
|
+
async renderTableHeaderActions(ctx) {
|
|
2387
|
+
const html = [];
|
|
2388
|
+
for (const action of this.getTable().getHeaderActions())
|
|
2389
|
+
if (await action.isVisible(ctx))
|
|
2390
|
+
html.push(await action.toHtml(ctx, { table: true }));
|
|
2391
|
+
return html;
|
|
2392
|
+
}
|
|
2393
|
+
sortTable(column = null, direction = null) { sortTable(this, column, direction); }
|
|
2394
|
+
gotoPage(page, _pageName = 'page') { this.paginators.page = Math.max(1, Math.floor(Number(page)) || 1); }
|
|
2395
|
+
nextPage(_pageName = 'page') { this.paginators.page = (Number(this.paginators.page) || 1) + 1; }
|
|
2396
|
+
previousPage(_pageName = 'page') { this.paginators.page = Math.max(1, (Number(this.paginators.page) || 1) - 1); }
|
|
2397
|
+
updatedTableSearch() { this.paginators.page = 1; }
|
|
2398
|
+
getDataAdapterForTable() { return this.resource.adapter; }
|
|
2399
|
+
getDataAdapter() { return this.resource.adapter; }
|
|
2400
|
+
updatedTableColumnSearches() { this.paginators.page = 1; }
|
|
2401
|
+
updatedTableRecordsPerPage() { this.paginators.page = 1; }
|
|
2402
|
+
updatedTableSort() { this.paginators.page = 1; }
|
|
2403
|
+
getBreadcrumbs() {
|
|
2404
|
+
if (this.resource.isSimple)
|
|
2405
|
+
return [{ label: '', url: null }];
|
|
2406
|
+
return [{ label: this.resource.getTitleCasePluralModelLabel(), url: this.resource.getUrl() }, { label: t('filament-panels::resources/pages/list-records.breadcrumb'), url: null }];
|
|
2407
|
+
}
|
|
2408
|
+
getHeaderActions() {
|
|
2409
|
+
// Resource simple : création en modale ; sinon lien vers la page de création.
|
|
2410
|
+
if (this.resource.isSimple)
|
|
2411
|
+
return [CreateAction.make(this.resource)];
|
|
2412
|
+
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'))];
|
|
2413
|
+
}
|
|
2414
|
+
getPageClasses() { return ['fi-resource-list-records-page', `fi-resource-${this.resource.slug.replace(/\//g, '-')}`]; }
|
|
2415
|
+
contentSchema(schema) {
|
|
2416
|
+
return schema.components([new ResourceTabs(), RenderHook.make(PanelsRenderHook.RESOURCE_PAGES_LIST_RECORDS_TABLE_BEFORE), EmbeddedTable.make(), RenderHook.make(PanelsRenderHook.RESOURCE_PAGES_LIST_RECORDS_TABLE_AFTER)]);
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
/**
|
|
2420
|
+
* Table des enregistrements liés à un autre (port de RelationManager) : composant enfant de la page d'édition, chargé quand il devient visible.
|
|
2421
|
+
* Les sous-classes déclarent `relationship`, `adapterFor()`, `table()` et `form()`.
|
|
2422
|
+
*/
|
|
2423
|
+
export class RelationManager extends ListRecords {
|
|
2424
|
+
static relationship = '';
|
|
2425
|
+
static modelLabel = 'record';
|
|
2426
|
+
static pluralModelLabel = null;
|
|
2427
|
+
static recordTitleAttribute = null;
|
|
2428
|
+
/** Adaptateur limité aux enregistrements liés au propriétaire donné. */
|
|
2429
|
+
static adapterFor(_ownerKey) { throw new Error('RelationManager.adapterFor() doit être défini.'); }
|
|
2430
|
+
/** Politique des enregistrements liés (mêmes capacités que celle d'une resource). */
|
|
2431
|
+
static policy = null;
|
|
2432
|
+
isAccessAuthorized(ctx) { return this.resource.can('viewAny', ctx.user); }
|
|
2433
|
+
static table(table) { return table; }
|
|
2434
|
+
static form(schema) { return schema; }
|
|
2435
|
+
static getTitle() { return this.title ?? ucfirst(this.relationship.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase()); }
|
|
2436
|
+
/** Clé de l'enregistrement propriétaire : fixée au montage par la page, jamais modifiable depuis le navigateur. */
|
|
2437
|
+
ownerRecord = '';
|
|
2438
|
+
static locked = ['ownerRecord'];
|
|
2439
|
+
_facade = null;
|
|
2440
|
+
/** Ce que ListRecords attend d'une resource, fourni ici par le relation manager. */
|
|
2441
|
+
get resource() {
|
|
2442
|
+
const manager = this.constructor;
|
|
2443
|
+
return (this._facade ??= {
|
|
2444
|
+
isSimple: true, slug: '', modelLabel: manager.modelLabel, relations: [], adapter: manager.adapterFor(this.ownerRecord),
|
|
2445
|
+
can: (ability, user, record = null) => { const rule = manager.policy?.[ability]; return this.panel.authorize(user, ability, { resource: manager, record, rule: rule ? () => rule(user, record) : null }); },
|
|
2446
|
+
getPluralModelLabel: () => manager.pluralModelLabel ?? plural(manager.modelLabel),
|
|
2447
|
+
getTitleCasePluralModelLabel: () => (manager.pluralModelLabel ?? plural(manager.modelLabel)).split(' ').map(ucfirst).join(' '),
|
|
2448
|
+
getRecordTitle: (record) => (manager.recordTitleAttribute ? String(record?.[manager.recordTitleAttribute] ?? '') : manager.modelLabel),
|
|
2449
|
+
table: (table) => manager.table(table), form: (schema) => manager.form(schema.columns(2)), getUrl: () => '',
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
2452
|
+
getTable() {
|
|
2453
|
+
const manager = this.constructor;
|
|
2454
|
+
const table = manager.table(new Table().pluralModelLabel(this.resource.getPluralModelLabel()).heading(manager.getTitle()));
|
|
2455
|
+
// Comme Filament : s'il existe une action de ligne « edit », cliquer la ligne l'ouvre.
|
|
2456
|
+
const hasEdit = table.getRecordActions().flatMap((a) => (a instanceof ActionGroup ? a.getActions() : [a])).some((a) => a.getName() === 'edit');
|
|
2457
|
+
return hasEdit ? table.recordAction('edit') : table;
|
|
2458
|
+
}
|
|
2459
|
+
// --- relations plusieurs-à-plusieurs : à définir par le manager qui propose Attach / Detach
|
|
2460
|
+
/** Rattache un enregistrement existant au propriétaire. */
|
|
2461
|
+
static attach(_ownerKey, _recordKey) { throw new Error('RelationManager.attach() doit être défini.'); }
|
|
2462
|
+
/** Détache des enregistrements du propriétaire, sans les supprimer. */
|
|
2463
|
+
static detach(_ownerKey, _recordKeys) { throw new Error('RelationManager.detach() doit être défini.'); }
|
|
2464
|
+
/** Enregistrements rattachables correspondant à la recherche : [clé, libellé]. */
|
|
2465
|
+
static searchAttachable(_ownerKey, _search) { throw new Error('RelationManager.searchAttachable() doit être défini.'); }
|
|
2466
|
+
/** Libellé d'un enregistrement rattachable, ou null s'il ne l'est pas (sert aussi de validation). */
|
|
2467
|
+
static attachableLabel(_ownerKey, _recordKey) { throw new Error('RelationManager.attachableLabel() doit être défini.'); }
|
|
2468
|
+
getHeaderActions() { return []; }
|
|
2469
|
+
placeholder() {
|
|
2470
|
+
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
|
+
}
|
|
2472
|
+
/** Comme Filament : un relation manager a ses propres emplacements, pas ceux de la page de liste. */
|
|
2473
|
+
contentSchema(schema) {
|
|
2474
|
+
return schema.components([new ResourceTabs(), RenderHook.make(PanelsRenderHook.RESOURCE_RELATION_MANAGER_BEFORE), EmbeddedTable.make(), RenderHook.make(PanelsRenderHook.RESOURCE_RELATION_MANAGER_AFTER)]);
|
|
2475
|
+
}
|
|
2476
|
+
async render() {
|
|
2477
|
+
return `<div class="fi-resource-relation-manager">${await this.getSchema('content').toHtml()}</div>`;
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
/**
|
|
2481
|
+
* Page des enregistrements liés à un enregistrement (port de ManageRelatedRecords) : une table, avec création, consultation et
|
|
2482
|
+
* édition en modales. Déclarée par `static relatedPages` de la resource propriétaire, elle répond sous `<slug>/{record}/<nom>`.
|
|
2483
|
+
*/
|
|
2484
|
+
export class ManageRelatedRecords extends ListRecords {
|
|
2485
|
+
/** Resource propriétaire : fixée à l'enregistrement de la page. */
|
|
2486
|
+
static parentResource;
|
|
2487
|
+
static relationship = '';
|
|
2488
|
+
static modelLabel = 'record';
|
|
2489
|
+
static pluralModelLabel = null;
|
|
2490
|
+
static recordTitleAttribute = null;
|
|
2491
|
+
static policy = null;
|
|
2492
|
+
/** Adaptateur limité aux enregistrements liés au propriétaire donné. */
|
|
2493
|
+
static adapterFor(_ownerKey) { throw new Error('ManageRelatedRecords.adapterFor() doit être défini.'); }
|
|
2494
|
+
static table(table) { return table; }
|
|
2495
|
+
static form(schema) { return schema; }
|
|
2496
|
+
static infolist(schema) { return schema; }
|
|
2497
|
+
static getTitle() { return this.title ?? ucfirst(this.relationship.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase()); }
|
|
2498
|
+
static getNavigationLabel() { return this.navigationLabel ?? this.getTitle(); }
|
|
2499
|
+
/** Clé de l'enregistrement propriétaire : tirée de l'adresse au montage, jamais modifiable depuis le navigateur. */
|
|
2500
|
+
recordKey = '';
|
|
2501
|
+
static locked = ['recordKey'];
|
|
2502
|
+
_owner = null;
|
|
2503
|
+
_facade = null;
|
|
2504
|
+
get related() { return this.constructor; }
|
|
2505
|
+
async mount(params) {
|
|
2506
|
+
this.recordKey = String(params.record ?? '');
|
|
2507
|
+
await this.hydrate();
|
|
2508
|
+
await ListRecords.prototype.mount?.call(this, params);
|
|
2509
|
+
}
|
|
2510
|
+
/** Le propriétaire est relu et son accès vérifié à chaque requête. */
|
|
2511
|
+
async hydrate() {
|
|
2512
|
+
const parent = this.related.parentResource;
|
|
2513
|
+
this._owner = await parent.adapter.find(this.recordKey);
|
|
2514
|
+
if (!this._owner)
|
|
2515
|
+
throw new WireError(404, 'Enregistrement introuvable.');
|
|
2516
|
+
if (!(await parent.can('view', this._ctx?.user ?? null, this._owner)))
|
|
2517
|
+
throw new WireError(403, 'Accès refusé.');
|
|
2518
|
+
await ListRecords.prototype.hydrate?.call(this);
|
|
2519
|
+
}
|
|
2520
|
+
getRecordKey() { return this.recordKey; }
|
|
2521
|
+
/** Ce que ListRecords attend d'une resource, fourni ici par la page. */
|
|
2522
|
+
get resource() {
|
|
2523
|
+
const page = this.related;
|
|
2524
|
+
return (this._facade ??= {
|
|
2525
|
+
isSimple: true, hasViewPage: false, slug: page.parentResource.slug, modelLabel: page.modelLabel, relations: [], recordTitleAttribute: page.recordTitleAttribute, adapter: page.adapterFor(this.recordKey),
|
|
2526
|
+
can: (ability, user, record = null) => { const rule = page.policy?.[ability]; return this.panel.authorize(user, ability, { resource: page, record, rule: rule ? () => rule(user, record) : null }); },
|
|
2527
|
+
getPluralModelLabel: () => page.pluralModelLabel ?? plural(page.modelLabel),
|
|
2528
|
+
getTitleCasePluralModelLabel: () => (page.pluralModelLabel ?? plural(page.modelLabel)).split(' ').map(ucfirst).join(' '),
|
|
2529
|
+
getRecordTitle: (record) => (page.recordTitleAttribute ? String(record?.[page.recordTitleAttribute] ?? '') : page.modelLabel),
|
|
2530
|
+
table: (table) => page.table(table), form: (schema) => page.form(schema), infolist: (schema) => page.infolist(schema), getUrl: () => '',
|
|
2531
|
+
});
|
|
2532
|
+
}
|
|
2533
|
+
getTable() { return this.related.table(new Table().pluralModelLabel(this.resource.getPluralModelLabel())); }
|
|
2534
|
+
getHeaderActions() { return []; }
|
|
2535
|
+
/** Comme Filament : le contenu de la page est un groupe (onglets éventuels, puis la table). */
|
|
2536
|
+
contentSchema(schema) {
|
|
2537
|
+
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
|
+
}
|
|
2539
|
+
getTitle() { return t('filament-panels::resources/pages/manage-related-records.title', { label: this.related.parentResource.getRecordTitle(this._owner), relationship: this.related.getTitle() }); }
|
|
2540
|
+
getSubNavigation() { return this.related.parentResource.getRecordSubNavigation(this); }
|
|
2541
|
+
getBreadcrumbs() {
|
|
2542
|
+
const parent = this.related.parentResource;
|
|
2543
|
+
return [
|
|
2544
|
+
{ label: parent.getTitleCasePluralModelLabel(), url: parent.getUrl() },
|
|
2545
|
+
{ label: parent.getRecordTitle(this._owner), url: parent.getUrl(parent.hasViewPage ? 'view' : 'edit', this.recordKey) },
|
|
2546
|
+
{ label: this.related.getTitle(), url: null },
|
|
2547
|
+
];
|
|
2548
|
+
}
|
|
2549
|
+
getPageClasses() { return ['fi-resource-manage-related-records-page', `fi-resource-${this.related.parentResource.slug.replace(/\//g, '-')}`, `fi-resource-record-${this.recordKey}`]; }
|
|
2550
|
+
}
|
|
2551
|
+
/** Adresse de la page précédente si elle appartient à ce site, sinon la racine du site (sans barre finale, comme Laravel). */
|
|
2552
|
+
function previousUrlOf(req) {
|
|
2553
|
+
if (!req)
|
|
2554
|
+
return '';
|
|
2555
|
+
const origin = `${req.protocol}://${req.get('host')}`;
|
|
2556
|
+
try {
|
|
2557
|
+
const referer = new URL(String(req.get('referer') ?? ''));
|
|
2558
|
+
if (referer.origin === origin)
|
|
2559
|
+
return `${origin}${referer.pathname === '/' ? '' : referer.pathname}${referer.search}`;
|
|
2560
|
+
}
|
|
2561
|
+
catch { /* pas de Referer exploitable */ }
|
|
2562
|
+
return origin;
|
|
2563
|
+
}
|
|
2564
|
+
/** Couleur représentative d'un thème (nuance 500 de sa couleur primaire), pour la pastille du menu. */
|
|
2565
|
+
function swatchOf(theme, panel) {
|
|
2566
|
+
const primary = theme.colors?.primary;
|
|
2567
|
+
return (typeof primary === 'string' ? primary : primary?.[500]) ?? panel.colors.getColor('primary')?.[500] ?? 'currentColor';
|
|
2568
|
+
}
|
|
2569
|
+
/** Police d'un thème : sans adresse, elle est servie par Bunny Fonts comme celle du panel. */
|
|
2570
|
+
function fontOf(font) {
|
|
2571
|
+
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
|
+
}
|
|
2573
|
+
function emptyToNull(data) { return Object.fromEntries(Object.entries(data).map(([k, v]) => [k, v === '' ? null : v])); }
|
|
2574
|
+
/** Base des pages à formulaire d'une resource. */
|
|
2575
|
+
class ResourceFormPage extends Page {
|
|
2576
|
+
/** Champ réactif du formulaire (`live()` + `afterStateUpdated()`) : `set(nom, valeur)` écrit à côté du champ modifié. */
|
|
2577
|
+
async updatedData(_value, key) {
|
|
2578
|
+
if (!key)
|
|
2579
|
+
return;
|
|
2580
|
+
const path = `data.${key}`;
|
|
2581
|
+
// Le champ modifié d'abord, puis ses champs parents (un Repeater recalcule un total quand une de ses lignes change), comme Filament.
|
|
2582
|
+
const fields = this.getSchema('form').getFlatComponents().filter((c) => c instanceof Field && c.hasAfterStateUpdated() && (c.getStatePath() === path || path.startsWith(`${c.getStatePath()}.`)))
|
|
2583
|
+
.sort((a, b) => b.getStatePath().length - a.getStatePath().length);
|
|
2584
|
+
for (const field of fields) {
|
|
2585
|
+
const container = field.getStatePath().split('.').slice(0, -1);
|
|
2586
|
+
await field.callAfterStateUpdated((name, value) => {
|
|
2587
|
+
if (!/^[A-Za-z0-9_]+$/.test(name))
|
|
2588
|
+
return;
|
|
2589
|
+
let node = this;
|
|
2590
|
+
for (const segment of container)
|
|
2591
|
+
node = node?.[segment];
|
|
2592
|
+
if (node && typeof node === 'object')
|
|
2593
|
+
node[name] = value;
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
static resource;
|
|
2598
|
+
data = {};
|
|
2599
|
+
get resource() { return this.constructor.resource; }
|
|
2600
|
+
getDataAdapter() { return this.resource.adapter; }
|
|
2601
|
+
getRecordKey() { return null; }
|
|
2602
|
+
getRecord() { return null; }
|
|
2603
|
+
/** Deux colonnes par défaut à partir de `lg`, comme `defaultForm()` de Filament ; la resource peut en décider autrement. */
|
|
2604
|
+
formSchema(schema) {
|
|
2605
|
+
const form = this.resource.form(schema.columns(2).operation(this.getOperation()).record(this.getRecord()));
|
|
2606
|
+
const steps = this.getSteps();
|
|
2607
|
+
// Assistant : les étapes remplacent les composants du formulaire de la resource (comme le trait HasWizard).
|
|
2608
|
+
return steps === null ? form : form.columns(null).components([this.getWizardComponent(steps)]);
|
|
2609
|
+
}
|
|
2610
|
+
/**
|
|
2611
|
+
* Étapes d'un assistant (équivalent du trait HasWizard de Filament) : dès que la méthode en retourne, la page présente son formulaire
|
|
2612
|
+
* étape par étape, avec « Back » / « Next » et le bouton d'enregistrement sur la dernière étape.
|
|
2613
|
+
*/
|
|
2614
|
+
getSteps() { return null; }
|
|
2615
|
+
/** Vrai : les étapes sont accessibles dans n'importe quel ordre, sans validation au passage. */
|
|
2616
|
+
hasSkippableSteps() { return false; }
|
|
2617
|
+
/** Étape affichée à l'ouverture (à partir de 1). */
|
|
2618
|
+
getStartStep() { return 1; }
|
|
2619
|
+
getWizardComponent(steps) {
|
|
2620
|
+
const [submit] = this.getFormActions();
|
|
2621
|
+
return Wizard.make(steps).startOnStep(this.getStartStep()).cancelAction(this.cancelAction())
|
|
2622
|
+
.submitAction(submit.submit(null).action(this.getSubmitMethod()))
|
|
2623
|
+
.alpineSubmitHandler(`$wire.${this.getSubmitMethod()}()`).skippable(this.hasSkippableSteps()).contained(false);
|
|
2624
|
+
}
|
|
2625
|
+
contentSchema(schema) {
|
|
2626
|
+
// Assistant : pas de balise <form> autour, chaque étape a la sienne et l'assistant porte les actions.
|
|
2627
|
+
if (this.getSteps() !== null)
|
|
2628
|
+
return schema.components([EmbeddedSchema.make('form')]);
|
|
2629
|
+
return schema.components([
|
|
2630
|
+
Form.make([EmbeddedSchema.make('form')]).id('form').livewireSubmitHandler(this.getSubmitMethod())
|
|
2631
|
+
.footer([Actions.make(this.getFormActions()).alignment('start').key('form-actions')]),
|
|
2632
|
+
]);
|
|
2633
|
+
}
|
|
2634
|
+
/** Destination de « Cancel » quand le navigateur n'a pas d'historique : la liste, sauf si la page en décide autrement. */
|
|
2635
|
+
getCancelUrl() { return this.resource.getUrl(); }
|
|
2636
|
+
cancelAction() {
|
|
2637
|
+
return Action.make('cancel').label(t(`filament-panels::resources/pages/${this.getOperation()}-record.form.actions.cancel.label`)).color('gray')
|
|
2638
|
+
.alpineClickHandler(`document.referrer ? window.history.back() : Livewire.navigate(${js(this.getCancelUrl())})`);
|
|
2639
|
+
}
|
|
2640
|
+
/** Valide le formulaire ; en cas d'erreur, les pose sur le composant et prévient le navigateur. */
|
|
2641
|
+
async validateForm() {
|
|
2642
|
+
this._errors = await validateSchema(this.getSchema('form'));
|
|
2643
|
+
if (!Object.keys(this._errors).length)
|
|
2644
|
+
return true;
|
|
2645
|
+
this.dispatch('form-validation-error', { livewireId: this.id });
|
|
2646
|
+
return false;
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
/** Page de création d'un enregistrement (port de CreateRecord). */
|
|
2650
|
+
export class CreateRecord extends ResourceFormPage {
|
|
2651
|
+
static callable = ['create', 'createAnother'];
|
|
2652
|
+
mount() { fillSchema(this.getSchema('form'), this.data); }
|
|
2653
|
+
getOperation() { return 'create'; }
|
|
2654
|
+
getSubmitMethod() { return 'create'; }
|
|
2655
|
+
getTitle() { return t('filament-panels::resources/pages/create-record.title', { label: this.resource.modelLabel.split(' ').map(ucfirst).join(' ') }); }
|
|
2656
|
+
getBreadcrumbs() {
|
|
2657
|
+
return [{ label: this.resource.getTitleCasePluralModelLabel(), url: this.resource.getUrl() }, { label: t('filament-panels::resources/pages/create-record.breadcrumb'), url: null }];
|
|
2658
|
+
}
|
|
2659
|
+
getPageClasses() { return ['fi-resource-create-record-page', `fi-resource-${this.resource.slug.replace(/\//g, '-')}`]; }
|
|
2660
|
+
getFormActions() {
|
|
2661
|
+
return [
|
|
2662
|
+
Action.make('create').label(t('filament-panels::resources/pages/create-record.form.actions.create.label')).submit('create').keyBindings(['mod+s']),
|
|
2663
|
+
Action.make('createAnother').label(t('filament-panels::resources/pages/create-record.form.actions.create_another.label')).color('gray').action('createAnother').keyBindings(['mod+shift+s']),
|
|
2664
|
+
this.cancelAction(),
|
|
2665
|
+
];
|
|
2666
|
+
}
|
|
2667
|
+
// --- crochets de cycle de vie, à surcharger dans une sous-classe (aucun n'est appelable depuis le navigateur)
|
|
2668
|
+
/** Transforme les données validées avant la création (ajouter l'auteur, calculer un champ…). */
|
|
2669
|
+
mutateFormDataBeforeCreate(data) { return data; }
|
|
2670
|
+
/** Juste avant la création ; lever une erreur l'annule. */
|
|
2671
|
+
beforeCreate(_data) { }
|
|
2672
|
+
/** La création elle-même : par défaut, l'adaptateur de la resource. */
|
|
2673
|
+
handleRecordCreation(data) { return this.getDataAdapter().create(data); }
|
|
2674
|
+
/** Après la création (journal, envoi d'un message…). */
|
|
2675
|
+
afterCreate(_record) { }
|
|
2676
|
+
/** Où aller ensuite : par défaut, la page d'édition du nouvel enregistrement. */
|
|
2677
|
+
getRedirectUrl(record) { return this.resource.getUrl('edit', this.getDataAdapter().getKey(record)); }
|
|
2678
|
+
/** Titre de la notification de succès ; `null` pour ne rien afficher. */
|
|
2679
|
+
getCreatedNotificationTitle() { return t('filament-panels::resources/pages/create-record.notifications.created.title'); }
|
|
2680
|
+
async create(another = false) {
|
|
2681
|
+
if (!(await this.validateForm()))
|
|
2682
|
+
return;
|
|
2683
|
+
const data = await this.mutateFormDataBeforeCreate(await getSchemaState(this.getSchema('form')));
|
|
2684
|
+
await this.beforeCreate(data);
|
|
2685
|
+
const record = await this.handleRecordCreation(data);
|
|
2686
|
+
await this.afterCreate(record);
|
|
2687
|
+
const title = this.getCreatedNotificationTitle();
|
|
2688
|
+
if (title !== null)
|
|
2689
|
+
Notification.make().success().title(title).send();
|
|
2690
|
+
if (another === true) {
|
|
2691
|
+
this.data = {};
|
|
2692
|
+
fillSchema(this.getSchema('form'), this.data);
|
|
2693
|
+
return;
|
|
2694
|
+
}
|
|
2695
|
+
this.redirect(this.getRedirectUrl(record), true);
|
|
2696
|
+
}
|
|
2697
|
+
createAnother() { return this.create(true); }
|
|
2698
|
+
}
|
|
2699
|
+
/** Action de suppression d'un enregistrement, avec confirmation (port de Filament\Actions\DeleteAction dans une page de resource). */
|
|
2700
|
+
export class DeleteAction {
|
|
2701
|
+
/** Sur une page d'édition : supprime l'enregistrement de la page puis revient à la liste. Sur une ligne de table : supprime la ligne. */
|
|
2702
|
+
static make() {
|
|
2703
|
+
return Action.make('delete').authorize(({ livewire, record }) => canOn(livewire, 'delete', record))
|
|
2704
|
+
.label(t('filament-actions::delete.single.label')).color('danger').keyBindings(['mod+d']).tableIcon('c-trash').groupedIcon('m-trash')
|
|
2705
|
+
.requiresConfirmation().modalIcon('o-trash')
|
|
2706
|
+
// Masquée pour un enregistrement déjà supprimé (suppression douce).
|
|
2707
|
+
.hidden(({ livewire, record }) => { const target = record ?? livewire._record; return target ? livewire.getDataAdapter().isTrashed(target) : false; })
|
|
2708
|
+
.modalHeading(({ livewire, record }) => t('filament-actions::delete.single.modal.heading', { label: livewire.resource.getRecordTitle(record ?? livewire._record) }))
|
|
2709
|
+
.modalSubmitActionLabel(t('filament-actions::delete.single.modal.actions.delete.label'))
|
|
2710
|
+
.action(async ({ livewire, record }) => {
|
|
2711
|
+
const adapter = livewire.getDataAdapter();
|
|
2712
|
+
const inTable = typeof livewire.getTable === 'function';
|
|
2713
|
+
await adapter.delete(inTable ? adapter.getKey(record) : livewire.recordKey);
|
|
2714
|
+
Notification.make().success().title(t('filament-actions::delete.single.notifications.deleted.title')).send();
|
|
2715
|
+
if (!inTable)
|
|
2716
|
+
livewire.redirect(livewire.resource.getUrl(), true);
|
|
2717
|
+
});
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
/** Duplication d'une ligne de table (port de ReplicateAction) : la copie reprend les attributs de l'enregistrement, sauf sa clé, ses dates et ceux exclus. */
|
|
2721
|
+
export class ReplicateAction {
|
|
2722
|
+
static make() {
|
|
2723
|
+
let excluded = [];
|
|
2724
|
+
const action = Action.make('replicate').authorize(({ livewire, record }) => canOn(livewire, 'replicate', record))
|
|
2725
|
+
.label(t('filament-actions::replicate.single.label')).tableIcon('c-square-2-stack').groupedIcon('m-square-2-stack')
|
|
2726
|
+
.modalHeading(({ livewire, record }) => t('filament-actions::replicate.single.modal.heading', { label: livewire.resource.getRecordTitle(record) }))
|
|
2727
|
+
.modalSubmitActionLabel(t('filament-actions::replicate.single.modal.actions.replicate.label'))
|
|
2728
|
+
.action(async ({ livewire, record }) => {
|
|
2729
|
+
const adapter = livewire.getDataAdapter();
|
|
2730
|
+
const source = typeof record?.toJSON === 'function' ? record.toJSON() : { ...record };
|
|
2731
|
+
// Comme Model::replicate() : ni clé primaire ni dates de création et de modification.
|
|
2732
|
+
for (const attribute of ['id', 'created_at', 'updated_at', ...excluded])
|
|
2733
|
+
delete source[attribute];
|
|
2734
|
+
await adapter.create(source);
|
|
2735
|
+
Notification.make().success().title(t('filament-actions::replicate.single.notifications.replicated.title')).send();
|
|
2736
|
+
});
|
|
2737
|
+
action.excludeAttributes = (attributes) => { excluded = attributes; return action; };
|
|
2738
|
+
return action;
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
/** Création en modale avec le formulaire de la resource (port de CreateAction). */
|
|
2742
|
+
export class CreateAction {
|
|
2743
|
+
/** Sans argument, la resource (ou le relation manager) est celle du composant hôte. */
|
|
2744
|
+
static make(resource = null) {
|
|
2745
|
+
const of = (livewire) => resource ?? livewire.resource;
|
|
2746
|
+
return Action.make('create').authorize(({ livewire, record }) => canOn(livewire, 'create', record))
|
|
2747
|
+
.label(({ livewire }) => t('filament-actions::create.single.label', { label: of(livewire).modelLabel }))
|
|
2748
|
+
.modalHeading(({ livewire }) => t('filament-actions::create.single.modal.heading', { label: String(of(livewire).modelLabel).split(' ').map(ucfirst).join(' ') }))
|
|
2749
|
+
.modalSubmitActionLabel(t('filament-actions::create.single.modal.actions.create.label'))
|
|
2750
|
+
.extraModalFooterActions([Action.make('createAnother').label(t('filament-actions::create.single.modal.actions.create_another.label')).color('gray').action(`callMountedAction(${js({ another: true })})`)])
|
|
2751
|
+
.schema((schema) => of(schema.livewire).form(schema))
|
|
2752
|
+
.action(async ({ livewire, data }) => {
|
|
2753
|
+
await of(livewire).adapter.create(data);
|
|
2754
|
+
Notification.make().success().title(t('filament-actions::create.single.notifications.created.title')).send();
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
/** Édition en modale d'une ligne de table, avec le formulaire de la resource (port de EditAction). */
|
|
2759
|
+
export class EditAction {
|
|
2760
|
+
/** Sans argument, la resource (ou le relation manager) est celle du composant hôte. */
|
|
2761
|
+
static make(resource = null) {
|
|
2762
|
+
const of = (livewire) => resource ?? livewire.resource;
|
|
2763
|
+
return Action.make('edit').authorize(({ livewire, record }) => canOn(livewire, 'update', record))
|
|
2764
|
+
.label(t('filament-actions::edit.single.label')).tableIcon('c-pencil-square').groupedIcon('m-pencil-square')
|
|
2765
|
+
.modalHeading(({ livewire, record }) => t('filament-actions::edit.single.modal.heading', { label: of(livewire).getRecordTitle(record) }))
|
|
2766
|
+
.modalSubmitActionLabel(t('filament-actions::edit.single.modal.actions.save.label'))
|
|
2767
|
+
.schema((schema) => of(schema.livewire).form(schema))
|
|
2768
|
+
.fillForm(({ record }) => ({ ...record }))
|
|
2769
|
+
.action(async ({ livewire, record, data }) => {
|
|
2770
|
+
const adapter = of(livewire).adapter;
|
|
2771
|
+
await adapter.update(adapter.getKey(record), data);
|
|
2772
|
+
Notification.make().success().title(t('filament-actions::edit.single.notifications.saved.title')).send();
|
|
2773
|
+
});
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
/** Lien vers la page de consultation de l'enregistrement (port de ViewAction, quand la resource a une page « View »). */
|
|
2777
|
+
export class ViewAction {
|
|
2778
|
+
static make(resource = null) {
|
|
2779
|
+
const of = (livewire) => resource ?? livewire.resource;
|
|
2780
|
+
return Action.make('view').authorize(({ livewire, record }) => canOn(livewire, 'view', record))
|
|
2781
|
+
.label(t('filament-actions::view.single.label')).color('gray').tableIcon('c-eye').groupedIcon('m-eye')
|
|
2782
|
+
// Sans page « View » (resource simple, enregistrements liés) : l'infolist s'ouvre en modale, comme Filament.
|
|
2783
|
+
.url(({ livewire, record }) => (record && of(livewire).hasViewPage ? of(livewire).getUrl('view', of(livewire).adapter.getKey(record)) : null))
|
|
2784
|
+
.modalHeading(({ livewire, record }) => t('filament-actions::view.single.modal.heading', { label: of(livewire).getRecordTitle(record) }))
|
|
2785
|
+
.modalSubmitAction(false).modalCancelActionLabel(t('filament-actions::view.single.modal.actions.close.label'))
|
|
2786
|
+
.schema((schema) => (of(schema.livewire).hasViewPage || !of(schema.livewire).infolist ? null : of(schema.livewire).infolist(schema.operation('view'))));
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
const managerOf = (livewire) => livewire.constructor;
|
|
2790
|
+
/** Rattache un enregistrement existant au propriétaire d'un relation manager (port de AttachAction). */
|
|
2791
|
+
export class AttachAction {
|
|
2792
|
+
static make() {
|
|
2793
|
+
return Action.make('attach')
|
|
2794
|
+
.label(t('filament-actions::attach.single.label')).color('gray').modalWidth('lg')
|
|
2795
|
+
.modalHeading(({ livewire }) => t('filament-actions::attach.single.modal.heading', { label: String(livewire.resource.modelLabel).split(' ').map(ucfirst).join(' ') }))
|
|
2796
|
+
.modalSubmitActionLabel(t('filament-actions::attach.single.modal.actions.attach.label'))
|
|
2797
|
+
.extraModalFooterActions([Action.make('attachAnother').label(t('filament-actions::attach.single.modal.actions.attach_another.label')).color('gray').action(`callMountedAction(${js({ another: true })})`)])
|
|
2798
|
+
.schema((schema) => {
|
|
2799
|
+
const livewire = schema.livewire;
|
|
2800
|
+
return [Select.make('recordId').label(t('filament-actions::attach.single.modal.fields.record_id.label')).hiddenLabel().required()
|
|
2801
|
+
.getSearchResultsUsing((search) => managerOf(livewire).searchAttachable(livewire.ownerRecord, search))
|
|
2802
|
+
.getOptionLabelUsing((value) => managerOf(livewire).attachableLabel(livewire.ownerRecord, value))];
|
|
2803
|
+
})
|
|
2804
|
+
.action(async ({ livewire, data }) => {
|
|
2805
|
+
await managerOf(livewire).attach(livewire.ownerRecord, String(data.recordId));
|
|
2806
|
+
Notification.make().success().title(t('filament-actions::attach.single.notifications.attached.title')).send();
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
/** Détache une ligne du propriétaire sans la supprimer (port de DetachAction). */
|
|
2811
|
+
export class DetachAction {
|
|
2812
|
+
static make() {
|
|
2813
|
+
return Action.make('detach')
|
|
2814
|
+
.label(t('filament-actions::detach.single.label')).color('danger').icon('c-x-mark').groupedIcon('m-x-mark')
|
|
2815
|
+
.requiresConfirmation().modalIcon('o-x-mark')
|
|
2816
|
+
.modalHeading(({ livewire, record }) => t('filament-actions::detach.single.modal.heading', { label: livewire.resource.getRecordTitle(record) }))
|
|
2817
|
+
.modalSubmitActionLabel(t('filament-actions::detach.single.modal.actions.detach.label'))
|
|
2818
|
+
.action(async ({ livewire, record }) => {
|
|
2819
|
+
await managerOf(livewire).detach(livewire.ownerRecord, [livewire.resource.adapter.getKey(record)]);
|
|
2820
|
+
Notification.make().success().title(t('filament-actions::detach.single.notifications.detached.title')).send();
|
|
2821
|
+
});
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
/** Détache les lignes sélectionnées (port de DetachBulkAction). */
|
|
2825
|
+
export class DetachBulkAction {
|
|
2826
|
+
static make() {
|
|
2827
|
+
return Action.make('detach')
|
|
2828
|
+
.label(t('filament-actions::detach.multiple.label')).color('danger').icon('m-x-mark')
|
|
2829
|
+
.requiresConfirmation().modalIcon('o-x-mark')
|
|
2830
|
+
.modalHeading(({ livewire }) => t('filament-actions::detach.multiple.modal.heading', { label: livewire.resource.getPluralModelLabel() }))
|
|
2831
|
+
.modalSubmitActionLabel(t('filament-actions::detach.multiple.modal.actions.detach.label'))
|
|
2832
|
+
.action(async ({ livewire }) => {
|
|
2833
|
+
// Les clés sélectionnées sont celles de la requête courante du manager : elles appartiennent donc au propriétaire.
|
|
2834
|
+
await managerOf(livewire).detach(livewire.ownerRecord, await livewire.getSelectedRecordKeys());
|
|
2835
|
+
Notification.make().success().title(t('filament-actions::detach.multiple.notifications.detached.title')).send();
|
|
2836
|
+
livewire.deselectAllTableRecords();
|
|
2837
|
+
});
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
/** Restauration d'un enregistrement supprimé (port de RestoreAction). Visible seulement s'il est supprimé. */
|
|
2841
|
+
export class RestoreAction {
|
|
2842
|
+
static make() {
|
|
2843
|
+
return Action.make('restore').authorize(({ livewire, record }) => canOn(livewire, 'restore', record))
|
|
2844
|
+
.label(t('filament-actions::restore.single.label')).color('gray')
|
|
2845
|
+
.requiresConfirmation().modalIcon('o-arrow-uturn-left')
|
|
2846
|
+
.hidden(({ livewire }) => !livewire.getDataAdapter().isTrashed(livewire._record))
|
|
2847
|
+
.modalHeading(({ livewire }) => t('filament-actions::restore.single.modal.heading', { label: livewire.resource.getRecordTitle(livewire._record) }))
|
|
2848
|
+
.modalSubmitActionLabel(t('filament-actions::restore.single.modal.actions.restore.label'))
|
|
2849
|
+
.action(async ({ livewire }) => {
|
|
2850
|
+
await livewire.getDataAdapter().restore(livewire.recordKey);
|
|
2851
|
+
await livewire.hydrate();
|
|
2852
|
+
Notification.make().success().title(t('filament-actions::restore.single.notifications.restored.title')).send();
|
|
2853
|
+
});
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
/** Suppression définitive d'un enregistrement supprimé (port de ForceDeleteAction). Visible seulement s'il est supprimé. */
|
|
2857
|
+
export class ForceDeleteAction {
|
|
2858
|
+
static make() {
|
|
2859
|
+
return Action.make('forceDelete').authorize(({ livewire, record }) => canOn(livewire, 'forceDelete', record))
|
|
2860
|
+
.label(t('filament-actions::force-delete.single.label')).color('danger')
|
|
2861
|
+
.requiresConfirmation().modalIcon('o-trash')
|
|
2862
|
+
.hidden(({ livewire }) => !livewire.getDataAdapter().isTrashed(livewire._record))
|
|
2863
|
+
.modalHeading(({ livewire }) => t('filament-actions::force-delete.single.modal.heading', { label: livewire.resource.getRecordTitle(livewire._record) }))
|
|
2864
|
+
.modalSubmitActionLabel(t('filament-actions::force-delete.single.modal.actions.delete.label'))
|
|
2865
|
+
.action(async ({ livewire }) => {
|
|
2866
|
+
await livewire.getDataAdapter().forceDelete(livewire.recordKey);
|
|
2867
|
+
Notification.make().success().title(t('filament-actions::force-delete.single.notifications.deleted.title')).send();
|
|
2868
|
+
livewire.redirect(livewire.resource.getUrl(), true);
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
/** Suppression groupée des enregistrements sélectionnés dans une table (port de DeleteBulkAction). */
|
|
2873
|
+
export class DeleteBulkAction {
|
|
2874
|
+
static make() {
|
|
2875
|
+
return Action.make('delete').authorize(({ livewire, record }) => canOn(livewire, 'deleteAny', record))
|
|
2876
|
+
.label(t('filament-actions::delete.multiple.label')).color('danger').icon('m-trash')
|
|
2877
|
+
.requiresConfirmation().modalIcon('o-trash')
|
|
2878
|
+
.modalHeading(({ livewire }) => t('filament-actions::delete.multiple.modal.heading', { label: livewire.resource.getTitleCasePluralModelLabel() }))
|
|
2879
|
+
.modalSubmitActionLabel(t('filament-actions::delete.multiple.modal.actions.delete.label'))
|
|
2880
|
+
.action(async ({ livewire }) => {
|
|
2881
|
+
await livewire.getDataAdapterForTable().deleteMany(await livewire.getSelectedRecordKeys());
|
|
2882
|
+
Notification.make().success().title(t('filament-actions::delete.multiple.notifications.deleted.title')).send();
|
|
2883
|
+
livewire.deselectAllTableRecords();
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
/** Page d'édition d'un enregistrement (port de EditRecord). */
|
|
2888
|
+
export class EditRecord extends ResourceFormPage {
|
|
2889
|
+
static callable = ['save'];
|
|
2890
|
+
recordKey = '';
|
|
2891
|
+
/** Onglet de relation manager actif (indice dans `Resource.relations`). */
|
|
2892
|
+
activeRelationManager = '0';
|
|
2893
|
+
/**
|
|
2894
|
+
* Page précédente (équivalent de `url()->previous()`), retenue au premier affichage. Elle vient de l'en-tête Referer :
|
|
2895
|
+
* seule une adresse de ce même site est acceptée, et elle n'est plus modifiable ensuite.
|
|
2896
|
+
*/
|
|
2897
|
+
previousUrl = '';
|
|
2898
|
+
getCancelUrl() { return this.previousUrl; }
|
|
2899
|
+
contentSchema(schema) {
|
|
2900
|
+
const content = super.contentSchema(schema);
|
|
2901
|
+
// Toujours présent, comme dans Filament : masqué (colonne vide) quand la resource n'a pas de relation manager.
|
|
2902
|
+
return content.components([...content.getComponents(), this.resource.relations.length ? new RelationManagerTabs() : new RelationManagerTabs().key(null).hidden()]);
|
|
2903
|
+
}
|
|
2904
|
+
static locked = ['recordKey', 'previousUrl'];
|
|
2905
|
+
_record = null;
|
|
2906
|
+
async mount(params) {
|
|
2907
|
+
this.recordKey = String(params.record ?? '');
|
|
2908
|
+
this.previousUrl = previousUrlOf(this._ctx?.req);
|
|
2909
|
+
await this.hydrate();
|
|
2910
|
+
// Remplit le formulaire avec les valeurs de l'enregistrement pour chaque champ déclaré.
|
|
2911
|
+
fillSchemaFromRecord(this.getSchema('form'), this.data, await this.mutateFormDataBeforeFill({ ...this._record }));
|
|
2912
|
+
}
|
|
2913
|
+
/** L'enregistrement est relu à chaque requête, comme dans Filament (pas de liaison de route). */
|
|
2914
|
+
async hydrate() {
|
|
2915
|
+
this._record = await this.getDataAdapter().find(this.recordKey);
|
|
2916
|
+
if (!this._record)
|
|
2917
|
+
throw new WireError(404, 'Enregistrement introuvable.');
|
|
2918
|
+
if (!(await canOn(this, 'update', this._record)))
|
|
2919
|
+
throw new WireError(403, 'Accès refusé.');
|
|
2920
|
+
}
|
|
2921
|
+
getRecord() { return this._record; }
|
|
2922
|
+
getRecordKey() { return this.recordKey; }
|
|
2923
|
+
getActionMountContext() { return { recordKey: this.recordKey }; }
|
|
2924
|
+
getActionRecord() { return this._record; }
|
|
2925
|
+
/** Comme une page générée par Filament : suppression, plus restauration et suppression définitive si le modèle a la suppression douce. */
|
|
2926
|
+
getHeaderActions() {
|
|
2927
|
+
const defaults = this.getDataAdapter().supportsSoftDeletes() ? [DeleteAction.make(), RestoreAction.make(), ForceDeleteAction.make()] : [DeleteAction.make()];
|
|
2928
|
+
// Actions propres à la resource, placées avant les actions par défaut.
|
|
2929
|
+
return [...(this.resource.editHeaderActions?.() ?? []), ...defaults];
|
|
2930
|
+
}
|
|
2931
|
+
getOperation() { return 'edit'; }
|
|
2932
|
+
getSubmitMethod() { return 'save'; }
|
|
2933
|
+
getTitle() { return t('filament-panels::resources/pages/edit-record.title', { label: this.resource.getRecordTitle(this._record) }); }
|
|
2934
|
+
getBreadcrumbs() {
|
|
2935
|
+
return [
|
|
2936
|
+
{ label: this.resource.getTitleCasePluralModelLabel(), url: this.resource.getUrl() },
|
|
2937
|
+
// Le titre de l'enregistrement n'apparaît que si la resource déclare `recordTitleAttribute` ; il mène à la page « View » quand elle existe.
|
|
2938
|
+
...(this.resource.recordTitleAttribute ? [{ label: this.resource.getRecordTitle(this._record), url: this.resource.getUrl(this.resource.hasViewPage ? 'view' : 'edit', this.recordKey) }] : []),
|
|
2939
|
+
{ label: t('filament-panels::resources/pages/edit-record.breadcrumb'), url: null },
|
|
2940
|
+
];
|
|
2941
|
+
}
|
|
2942
|
+
getSubNavigation() { return this.resource.getRecordSubNavigation(this); }
|
|
2943
|
+
getPageClasses() { return ['fi-resource-edit-record-page', `fi-resource-${this.resource.slug.replace(/\//g, '-')}`, `fi-resource-record-${this.recordKey}`]; }
|
|
2944
|
+
getFormActions() {
|
|
2945
|
+
return [Action.make('save').label(t('filament-panels::resources/pages/edit-record.form.actions.save.label')).submit('save').keyBindings(['mod+s']), this.cancelAction()];
|
|
2946
|
+
}
|
|
2947
|
+
// --- crochets de cycle de vie, à surcharger dans une sous-classe (aucun n'est appelable depuis le navigateur)
|
|
2948
|
+
/** Transforme les valeurs de l'enregistrement avant de remplir le formulaire. */
|
|
2949
|
+
mutateFormDataBeforeFill(data) { return data; }
|
|
2950
|
+
/** Transforme les données validées avant l'enregistrement. */
|
|
2951
|
+
mutateFormDataBeforeSave(data) { return data; }
|
|
2952
|
+
/** Juste avant l'enregistrement ; lever une erreur l'annule. */
|
|
2953
|
+
beforeSave(_data) { }
|
|
2954
|
+
/** L'enregistrement lui-même : par défaut, l'adaptateur de la resource. */
|
|
2955
|
+
handleRecordUpdate(record, data) { return this.getDataAdapter().update(this.getDataAdapter().getKey(record), data); }
|
|
2956
|
+
/** Après l'enregistrement. */
|
|
2957
|
+
afterSave(_record) { }
|
|
2958
|
+
/** Adresse où aller après l'enregistrement ; `null` (défaut) : rester sur la page. */
|
|
2959
|
+
getRedirectUrl(_record) { return null; }
|
|
2960
|
+
/** Titre de la notification de succès ; `null` pour ne rien afficher. */
|
|
2961
|
+
getSavedNotificationTitle() { return t('filament-panels::resources/pages/edit-record.notifications.saved.title'); }
|
|
2962
|
+
async save() {
|
|
2963
|
+
if (!(await this.validateForm()))
|
|
2964
|
+
return;
|
|
2965
|
+
const data = await this.mutateFormDataBeforeSave(await getSchemaState(this.getSchema('form')));
|
|
2966
|
+
await this.beforeSave(data);
|
|
2967
|
+
this._record = await this.handleRecordUpdate(this._record, data);
|
|
2968
|
+
await this.afterSave(this._record);
|
|
2969
|
+
const title = this.getSavedNotificationTitle();
|
|
2970
|
+
if (title !== null)
|
|
2971
|
+
Notification.make().success().title(title).send();
|
|
2972
|
+
const url = this.getRedirectUrl(this._record);
|
|
2973
|
+
if (url !== null)
|
|
2974
|
+
this.redirect(url, true);
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
/** Page de consultation d'un enregistrement (port de ViewRecord) : le schéma `infolist()` de la resource, en lecture seule. */
|
|
2978
|
+
export class ViewRecord extends Page {
|
|
2979
|
+
static resource;
|
|
2980
|
+
static locked = ['recordKey'];
|
|
2981
|
+
recordKey = '';
|
|
2982
|
+
/** Onglet de relation manager actif (indice dans `Resource.relations`). */
|
|
2983
|
+
activeRelationManager = '0';
|
|
2984
|
+
_record = null;
|
|
2985
|
+
get resource() { return this.constructor.resource; }
|
|
2986
|
+
getDataAdapter() { return this.resource.adapter; }
|
|
2987
|
+
async mount(params) {
|
|
2988
|
+
this.recordKey = String(params.record ?? '');
|
|
2989
|
+
await this.hydrate();
|
|
2990
|
+
}
|
|
2991
|
+
/** L'enregistrement est relu à chaque requête, comme sur la page d'édition. */
|
|
2992
|
+
async hydrate() {
|
|
2993
|
+
this._record = await this.getDataAdapter().find(this.recordKey);
|
|
2994
|
+
if (!this._record)
|
|
2995
|
+
throw new WireError(404, 'Enregistrement introuvable.');
|
|
2996
|
+
if (!(await canOn(this, 'view', this._record)))
|
|
2997
|
+
throw new WireError(403, 'Accès refusé.');
|
|
2998
|
+
}
|
|
2999
|
+
getRecordKey() { return this.recordKey; }
|
|
3000
|
+
getActionMountContext() { return { recordKey: this.recordKey }; }
|
|
3001
|
+
getActionRecord() { return this._record; }
|
|
3002
|
+
infolistSchema(schema) { return this.resource.infolist(schema.columns(2).operation('view').record(this._record)); }
|
|
3003
|
+
contentSchema(schema) {
|
|
3004
|
+
const content = schema.components([EmbeddedSchema.make('infolist')]);
|
|
3005
|
+
// Toujours présent, comme dans Filament : masqué (colonne vide) quand la resource n'a pas de relation manager.
|
|
3006
|
+
return content.components([...content.getComponents(), this.resource.relations.length ? new RelationManagerTabs() : new RelationManagerTabs().key(null).hidden()]);
|
|
3007
|
+
}
|
|
3008
|
+
/** Par défaut : le lien vers la page d'édition (EditAction d'une page « View »). */
|
|
3009
|
+
getHeaderActions() { return [this.editAction()]; }
|
|
3010
|
+
editAction() {
|
|
3011
|
+
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));
|
|
3012
|
+
}
|
|
3013
|
+
getTitle() { return t('filament-panels::resources/pages/view-record.title', { label: this.resource.getRecordTitle(this._record) }); }
|
|
3014
|
+
getBreadcrumbs() {
|
|
3015
|
+
return [
|
|
3016
|
+
{ label: this.resource.getTitleCasePluralModelLabel(), url: this.resource.getUrl() },
|
|
3017
|
+
// Le titre de l'enregistrement n'apparaît que si la resource déclare `recordTitleAttribute`.
|
|
3018
|
+
...(this.resource.recordTitleAttribute ? [{ label: this.resource.getRecordTitle(this._record), url: this.resource.getUrl('view', this.recordKey) }] : []),
|
|
3019
|
+
{ label: t('filament-panels::resources/pages/view-record.breadcrumb'), url: null },
|
|
3020
|
+
];
|
|
3021
|
+
}
|
|
3022
|
+
getSubNavigation() { return this.resource.getRecordSubNavigation(this); }
|
|
3023
|
+
getPageClasses() { return ['fi-resource-view-record-page', `fi-resource-${this.resource.slug.replace(/\//g, '-')}`, `fi-resource-record-${this.recordKey}`]; }
|
|
3024
|
+
}
|
|
3025
|
+
// ------------------------------------------------------------------ composants de mise en page
|
|
3026
|
+
/** Contexte d'évaluation des actions d'un menu du panel (pas d'enregistrement). */
|
|
3027
|
+
const menuContext = (livewire) => ({ livewire: livewire, component: null, operation: null, record: null, get: () => undefined, state: undefined });
|
|
3028
|
+
/** Menu utilisateur (port de HasUserMenu et de la vue user-menu) : profil, éléments du panel, thèmes, déconnexion. */
|
|
3029
|
+
async function renderUserMenu(component, position) {
|
|
3030
|
+
const { req, res, panel, user } = component._ctx;
|
|
3031
|
+
const ctx = menuContext(component);
|
|
3032
|
+
const items = [];
|
|
3033
|
+
const profileUrl = panel.getProfileUrl();
|
|
3034
|
+
const custom = panel.getUserMenuItems();
|
|
3035
|
+
if (profileUrl && !custom.profile)
|
|
3036
|
+
items.push(Action.make('profile').label(panel.getProfile().page.getLabel()).icon('m-user-circle').url(profileUrl).color('gray').sort(-1));
|
|
3037
|
+
items.push(...Object.values(custom));
|
|
3038
|
+
const visible = [];
|
|
3039
|
+
for (const item of items)
|
|
3040
|
+
if (await item.isVisible(ctx))
|
|
3041
|
+
visible.push(item);
|
|
3042
|
+
visible.sort((a, b) => a.getSort() - b.getSort());
|
|
3043
|
+
const html = async (list) => (await Promise.all(list.map((item) => item.toMenuItemHtml(ctx, { navigate: panel.hasSpaMode() })))).filter(Boolean);
|
|
3044
|
+
return views.render('filament-panels::components.user-menu', {
|
|
3045
|
+
position, isSidebarCollapsibleOnDesktop: panel.isSidebarCollapsibleOnDesktop(), user: user, avatarUrl: panel.getUserAvatarUrl(user), logoutUrl: panel.getLogoutUrl(), csrf: csrfToken(req, res),
|
|
3046
|
+
profileUrl: profileUrl && !custom.profile ? profileUrl : null,
|
|
3047
|
+
itemsBefore: await html(visible.filter((item) => item.getSort() < 0)), itemsAfter: await html(visible.filter((item) => item.getSort() >= 0)),
|
|
3048
|
+
themes: panel.getThemes().map((theme) => ({ id: theme.id, label: theme.label, swatch: swatchOf(theme, panel) })), activeTheme: panel.resolveTheme(req)?.id ?? null,
|
|
3049
|
+
});
|
|
3050
|
+
}
|
|
3051
|
+
/** Menu du locataire (port de la vue tenant-menu) : locataire courant, autres locataires, création, profil. */
|
|
3052
|
+
async function renderTenantMenu(component, options) {
|
|
3053
|
+
const { panel, user } = component._ctx;
|
|
3054
|
+
const tenant = panel.getTenant();
|
|
3055
|
+
if (!panel.hasTenancy() || !panel.hasTenantMenu() || !tenant || !user)
|
|
3056
|
+
return '';
|
|
3057
|
+
const provider = panel.getTenantProvider();
|
|
3058
|
+
const ctx = menuContext(component);
|
|
3059
|
+
const items = [];
|
|
3060
|
+
const custom = panel.getTenantMenuItems();
|
|
3061
|
+
const ProfilePage = panel.getTenantProfilePage();
|
|
3062
|
+
if (ProfilePage && provider.update && !custom.profile)
|
|
3063
|
+
items.push(Action.make('profile').label(ProfilePage.getLabel()).icon('m-cog-6-tooth').url(panel.getUrl(ProfilePage.slug)).color('gray').sort(-1));
|
|
3064
|
+
const RegistrationPage = panel.getTenantRegistrationPage();
|
|
3065
|
+
if (RegistrationPage && !custom.register && (await RegistrationPage.canView(panel, user)))
|
|
3066
|
+
items.push(Action.make('register').label(RegistrationPage.getLabel()).icon('m-plus').url(panel.getBaseUrl(RegistrationPage.slug)).color('gray').sort(Number.MAX_SAFE_INTEGER));
|
|
3067
|
+
items.push(...Object.values(custom));
|
|
3068
|
+
const visible = [];
|
|
3069
|
+
for (const item of items)
|
|
3070
|
+
if (await item.isVisible(ctx))
|
|
3071
|
+
visible.push(item);
|
|
3072
|
+
visible.sort((a, b) => a.getSort() - b.getSort());
|
|
3073
|
+
const others = (await provider.getTenants(user)).filter((candidate) => provider.getKey(candidate) !== provider.getKey(tenant));
|
|
3074
|
+
const canSwitch = others.length > 0;
|
|
3075
|
+
const html = async (list) => (await Promise.all(list.map((item) => item.toMenuItemHtml(ctx, { navigate: panel.hasSpaMode() })))).filter(Boolean);
|
|
3076
|
+
return views.render('filament-panels::components.tenant-menu', {
|
|
3077
|
+
teleport: options.teleport, isSidebarCollapsibleOnDesktop: panel.isSidebarCollapsibleOnDesktop(),
|
|
3078
|
+
tenant: { name: provider.getName(tenant), label: provider.getCurrentTenantLabel?.(tenant) ?? null, avatarUrl: panel.getTenantAvatarUrl(tenant) },
|
|
3079
|
+
itemsBefore: canSwitch ? await html(visible.filter((item) => item.getSort() < 0)) : [],
|
|
3080
|
+
itemsAfter: await html(canSwitch ? visible.filter((item) => item.getSort() >= 0) : visible),
|
|
3081
|
+
switchable: others.map((other) => ({ name: provider.getName(other), url: panel.getUrl('', other), avatarUrl: panel.getTenantAvatarUrl(other) })),
|
|
3082
|
+
});
|
|
3083
|
+
}
|
|
3084
|
+
/** Sidebar : logo, menu du locataire, recherche, navigation, et — sans barre du haut — cloche et menu utilisateur. */
|
|
3085
|
+
export class Sidebar extends Component {
|
|
3086
|
+
static callable = ['setTheme'];
|
|
3087
|
+
currentPath = '/';
|
|
3088
|
+
static locked = ['currentPath'];
|
|
3089
|
+
mount(params) { this.currentPath = String(params.path ?? '/'); }
|
|
3090
|
+
/** Un élément de resource reste actif sur ses pages de création et d'édition. */
|
|
3091
|
+
async render() {
|
|
3092
|
+
const { panel, user } = this._ctx;
|
|
3093
|
+
if (!user)
|
|
3094
|
+
return '<div></div>';
|
|
3095
|
+
const inSidebar = (position) => position === 'sidebar';
|
|
3096
|
+
return views.render('filament-panels::livewire.sidebar', {
|
|
3097
|
+
hasTopbar: panel.hasTopbar(), hasTopNavigation: panel.hasTopNavigation(), spa: panel.hasSpaMode(),
|
|
3098
|
+
isSidebarCollapsibleOnDesktop: panel.isSidebarCollapsibleOnDesktop(), isSidebarFullyCollapsibleOnDesktop: panel.isSidebarFullyCollapsibleOnDesktop(),
|
|
3099
|
+
tenantMenu: (await renderTenantMenu(this, { teleport: false })) || null,
|
|
3100
|
+
globalSearch: panel.hasGlobalSearch() && inSidebar(panel.getGlobalSearchPosition()) ? await this.mountChild(`navette.${panel.getId()}.global-search`, {}, `${this.id}.global-search`) : null,
|
|
3101
|
+
databaseNotifications: panel.getDatabaseNotifications() && inSidebar(panel.getDatabaseNotificationsPosition()) ? await this.mountChild(`navette.${panel.getId()}.database-notifications`, {}, `${this.id}.database-notifications`, { lazy: true }) : null,
|
|
3102
|
+
userMenu: inSidebar(panel.getUserMenuPosition()) ? await renderUserMenu(this, 'sidebar') : null,
|
|
3103
|
+
homeUrl: panel.getUrl(), brand: panel.getBrand(), groups: panel.getNavigation(this.currentPath, await panel.resolveNavigationBadges(), await panel.resolveInaccessiblePages(user)),
|
|
3104
|
+
livewireId: this.id,
|
|
3105
|
+
});
|
|
3106
|
+
}
|
|
3107
|
+
/** Choix d'un thème par l'utilisateur : seul un thème déclaré par le panel est accepté ; la page est rechargée pour l'appliquer. */
|
|
3108
|
+
setTheme(id) { applyTheme(this, id); this.skipRender(); }
|
|
3109
|
+
}
|
|
3110
|
+
/** Choix d'un thème (sidebar ou barre du haut) : seul un thème déclaré par le panel est accepté ; la page est rechargée. */
|
|
3111
|
+
function applyTheme(component, id) {
|
|
3112
|
+
const { req, res, panel, user } = component._ctx;
|
|
3113
|
+
if (!user)
|
|
3114
|
+
throw new WireError(401, 'Non authentifié.');
|
|
3115
|
+
const theme = panel.getThemes().find((candidate) => candidate.id === id);
|
|
3116
|
+
if (!theme)
|
|
3117
|
+
throw new WireError(404, 'Thème inconnu.');
|
|
3118
|
+
panel.rememberTheme(res, theme);
|
|
3119
|
+
const referer = previousUrlOf(req);
|
|
3120
|
+
component.redirect(referer.replace(/^https?:\/\/[^/]+/, '') || '/');
|
|
3121
|
+
}
|
|
3122
|
+
/** Barre du haut (port de Filament\Livewire\Topbar) : logo, navigation éventuelle, recherche, cloche, menu utilisateur. */
|
|
3123
|
+
export class Topbar extends Component {
|
|
3124
|
+
static callable = ['setTheme'];
|
|
3125
|
+
currentPath = '/';
|
|
3126
|
+
static locked = ['currentPath'];
|
|
3127
|
+
mount(params) { this.currentPath = String(params.path ?? '/'); }
|
|
3128
|
+
async render() {
|
|
3129
|
+
const { panel, user } = this._ctx;
|
|
3130
|
+
if (!user)
|
|
3131
|
+
return '<div></div>';
|
|
3132
|
+
const inTopbar = (position) => position === 'topbar';
|
|
3133
|
+
const hasNavigation = panel.hasNavigation();
|
|
3134
|
+
const showsNavigationHere = panel.hasTopNavigation() || !hasNavigation;
|
|
3135
|
+
const tenant = panel.getTenant();
|
|
3136
|
+
const groups = showsNavigationHere && hasNavigation ? panel.getNavigation(this.currentPath, await panel.resolveNavigationBadges(), await panel.resolveInaccessiblePages(user)) : null;
|
|
3137
|
+
return views.render('filament-panels::livewire.topbar', {
|
|
3138
|
+
hasNavigation, spa: panel.hasSpaMode(), isSidebarCollapsibleOnDesktop: panel.isSidebarCollapsibleOnDesktop(), isSidebarFullyCollapsibleOnDesktop: panel.isSidebarFullyCollapsibleOnDesktop(),
|
|
3139
|
+
homeUrl: panel.getUrl(), brand: panel.getBrand(), livewireId: this.id,
|
|
3140
|
+
tenantMenu: showsNavigationHere ? (await renderTenantMenu(this, { teleport: true })) || null : null,
|
|
3141
|
+
topNavigation: groups?.map((group) => ({ ...group, items: group.items.map((item) => ({ ...item, colorClasses: item.isActive ? sharedColors.getComponentClasses('dropdown-item', 'primary') : [] })) })) ?? null,
|
|
3142
|
+
persistKey: panel.hasTenancy() ? `topbar.end.panel-${panel.getId()}.tenant-${tenant ? panel.getTenantUrlKey(tenant) : ''}` : `topbar.end.panel-${panel.getId()}`,
|
|
3143
|
+
globalSearch: panel.hasGlobalSearch() && inTopbar(panel.getGlobalSearchPosition()) ? await this.mountChild(`navette.${panel.getId()}.global-search`, {}, `${this.id}.global-search`) : null,
|
|
3144
|
+
databaseNotifications: panel.getDatabaseNotifications() && inTopbar(panel.getDatabaseNotificationsPosition()) ? await this.mountChild(`navette.${panel.getId()}.database-notifications`, {}, `${this.id}.database-notifications`, { lazy: true }) : null,
|
|
3145
|
+
userMenu: inTopbar(panel.getUserMenuPosition()) ? await renderUserMenu(this, 'topbar') : null,
|
|
3146
|
+
});
|
|
3147
|
+
}
|
|
3148
|
+
setTheme(id) { applyTheme(this, id); this.skipRender(); }
|
|
3149
|
+
}
|
|
3150
|
+
/** Menu utilisateur des pages simples d'un utilisateur connecté (port de SimpleUserMenu). */
|
|
3151
|
+
export class SimpleUserMenu extends Component {
|
|
3152
|
+
static callable = ['setTheme'];
|
|
3153
|
+
async render() {
|
|
3154
|
+
if (!this._ctx.user)
|
|
3155
|
+
return '<div></div>';
|
|
3156
|
+
return `<div><div class="fi-simple-user-menu-ctn">${await renderUserMenu(this, 'topbar')}</div><div wire:partial="action-modals" x-data="filamentActionModals({
|
|
3157
|
+
livewireId: ${js(this.id)},
|
|
3158
|
+
})" style="height: 0"></div></div>`;
|
|
3159
|
+
}
|
|
3160
|
+
setTheme(id) { applyTheme(this, id); this.skipRender(); }
|
|
3161
|
+
}
|
|
3162
|
+
// ------------------------------------------------------------------ routage
|
|
3163
|
+
/** Un runtime ne sert qu'un panel : chaque panel a son endpoint Livewire et ses composants. */
|
|
3164
|
+
const runtimePanels = new WeakMap();
|
|
3165
|
+
/** Segments réservés juste après le chemin d'un panel à locataires (ils ne sont pas des clés de locataire). */
|
|
3166
|
+
const RESERVED_SEGMENTS = new Set(['login', 'logout', 'register', 'password-reset', 'email-verification', 'profile', 'livewire']);
|
|
3167
|
+
/** Routeur Express d'un panel : assets, endpoint du runtime, authentification, locataires, pages protégées. */
|
|
3168
|
+
export function panelRouter(panel, runtime) {
|
|
3169
|
+
const owner = runtimePanels.get(runtime);
|
|
3170
|
+
if (owner && owner !== panel)
|
|
3171
|
+
throw new Error(`Ce WireRuntime sert déjà le panel « ${owner.getId()} » : créez un WireRuntime par panel.`);
|
|
3172
|
+
runtimePanels.set(runtime, panel);
|
|
3173
|
+
// Les envois de fichiers passent par l'endpoint de ce panel (session et utilisateur du panel).
|
|
3174
|
+
const runtimeOptions = runtime.options;
|
|
3175
|
+
runtimeOptions.uploadPrefix ??= panel.getLivewirePrefix();
|
|
3176
|
+
// Un seul gestionnaire de couleurs pour tous les panels : chaque requête y pose les couleurs du sien.
|
|
3177
|
+
useColorManager(sharedColors);
|
|
3178
|
+
useFormsColorManager(sharedColors);
|
|
3179
|
+
useTableColorManager(sharedColors);
|
|
3180
|
+
useInfolistsColorManager(sharedColors);
|
|
3181
|
+
// HTML affiché tel quel par une entrée d'infolist : repassé par le schéma de l'éditeur de texte enrichi, qui ne garde que ses balises.
|
|
3182
|
+
useHtmlSanitizer((html) => richDocumentToHtml(richHtmlToDocument(html)) ?? '');
|
|
3183
|
+
views.share({ assetUrl: (file) => panel.assetUrl(file) });
|
|
3184
|
+
for (const plugin of panel.getPlugins())
|
|
3185
|
+
plugin.boot?.(panel);
|
|
3186
|
+
assertAuthCapabilities(panel);
|
|
3187
|
+
// Locataires : les resources cloisonnées ne voient que les enregistrements du locataire de la requête.
|
|
3188
|
+
const ownership = panel.getTenantOwnershipColumn();
|
|
3189
|
+
if (panel.hasTenancy() && ownership) {
|
|
3190
|
+
for (const resource of panel.getResources()) {
|
|
3191
|
+
if (!resource.isScopedToTenant)
|
|
3192
|
+
continue;
|
|
3193
|
+
const adapter = resource.adapter;
|
|
3194
|
+
if (typeof adapter?.scopeToTenant !== 'function')
|
|
3195
|
+
throw new Error(`Resource ${resource.name} : son adaptateur ne sait pas se cloisonner par locataire (scopeToTenant).`);
|
|
3196
|
+
adapter.scopeToTenant(resource.tenantOwnershipColumn ?? ownership, () => { const tenant = panel.getTenant(); return tenant ? panel.getTenantUrlKey(tenant) : null; });
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
const router = express.Router();
|
|
3200
|
+
const name = (suffix) => `navette.${panel.getId()}.${suffix}`;
|
|
3201
|
+
const livewire = panel.getLivewirePrefix();
|
|
3202
|
+
// La boîte aux lettres des notifications est propre au navigateur : on la rattache à son jeton CSRF.
|
|
3203
|
+
const context = async (req, res) => ({ req, res, panel, user: await panel.user(req), mailbox: csrfToken(req, res), tenant: null });
|
|
3204
|
+
/** Locataire désigné par une adresse du panel, si l'utilisateur en est membre. */
|
|
3205
|
+
const tenantFromPath = async (path, user) => {
|
|
3206
|
+
if (!panel.hasTenancy() || !user)
|
|
3207
|
+
return null;
|
|
3208
|
+
const base = panel.getBaseUrl();
|
|
3209
|
+
const prefix = base === '/' ? '/' : `${base}/`;
|
|
3210
|
+
if (!path.startsWith(prefix))
|
|
3211
|
+
return null;
|
|
3212
|
+
let key;
|
|
3213
|
+
try {
|
|
3214
|
+
key = decodeURIComponent(path.slice(prefix.length).split('/')[0] ?? '');
|
|
3215
|
+
}
|
|
3216
|
+
catch {
|
|
3217
|
+
return null;
|
|
3218
|
+
}
|
|
3219
|
+
const registration = panel.getTenantRegistrationPage();
|
|
3220
|
+
if (!key || RESERVED_SEGMENTS.has(key) || key === registration?.slug || key.length > 200)
|
|
3221
|
+
return null;
|
|
3222
|
+
const provider = panel.getTenantProvider();
|
|
3223
|
+
const tenant = await provider.find(key);
|
|
3224
|
+
return tenant && (await provider.canAccess(user, tenant)) ? tenant : null;
|
|
3225
|
+
};
|
|
3226
|
+
/** Accueil de l'utilisateur : la racine du panel, ou dans un panel à locataires celle de son premier locataire (sinon la création d'un locataire). */
|
|
3227
|
+
const homeUrl = async (user) => {
|
|
3228
|
+
if (!panel.hasTenancy())
|
|
3229
|
+
return panel.getUrl();
|
|
3230
|
+
const [first] = await panel.getTenantProvider().getTenants(user);
|
|
3231
|
+
if (first)
|
|
3232
|
+
return panel.getUrl('', first);
|
|
3233
|
+
const registration = panel.getTenantRegistrationPage();
|
|
3234
|
+
return registration && (await registration.canView(panel, user)) ? panel.getBaseUrl(registration.slug) : null;
|
|
3235
|
+
};
|
|
3236
|
+
const LoginPage = panel.getLoginPage();
|
|
3237
|
+
if (LoginPage)
|
|
3238
|
+
runtime.register(name('auth.login'), LoginPage);
|
|
3239
|
+
const RegistrationPage = panel.getRegistrationPage();
|
|
3240
|
+
if (RegistrationPage)
|
|
3241
|
+
runtime.register(name('auth.register'), RegistrationPage);
|
|
3242
|
+
const passwordReset = panel.getPasswordResetPages();
|
|
3243
|
+
if (passwordReset) {
|
|
3244
|
+
runtime.register(name('auth.password-reset.request'), passwordReset.request);
|
|
3245
|
+
runtime.register(name('auth.password-reset.reset'), passwordReset.reset);
|
|
3246
|
+
}
|
|
3247
|
+
const VerificationPrompt = panel.getEmailVerificationPrompt();
|
|
3248
|
+
if (VerificationPrompt)
|
|
3249
|
+
runtime.register(name('auth.email-verification.prompt'), VerificationPrompt);
|
|
3250
|
+
const profile = panel.getProfile();
|
|
3251
|
+
if (profile)
|
|
3252
|
+
runtime.register(name('auth.profile'), profile.page);
|
|
3253
|
+
const TenantRegistration = panel.getTenantRegistrationPage();
|
|
3254
|
+
if (TenantRegistration)
|
|
3255
|
+
runtime.register(name('tenancy.registration'), TenantRegistration);
|
|
3256
|
+
const TenantProfile = panel.getTenantProfilePage();
|
|
3257
|
+
if (TenantProfile && !panel.getPages().includes(TenantProfile))
|
|
3258
|
+
panel.pages([TenantProfile]);
|
|
3259
|
+
runtime.register(name('sidebar'), Sidebar);
|
|
3260
|
+
runtime.register(name('topbar'), Topbar);
|
|
3261
|
+
runtime.register(name('simple-user-menu'), SimpleUserMenu);
|
|
3262
|
+
runtime.register(name('global-search'), GlobalSearch);
|
|
3263
|
+
runtime.register(name('database-notifications'), DatabaseNotifications);
|
|
3264
|
+
runtime.register('navette.notifications', Notifications);
|
|
3265
|
+
for (const [index, widget] of panel.getWidgets().entries())
|
|
3266
|
+
runtime.register(name(`widgets.panel.${index}`), widget);
|
|
3267
|
+
for (const resource of panel.getResources())
|
|
3268
|
+
for (const [index, widget] of resource.widgets.entries())
|
|
3269
|
+
runtime.register(name(`widgets.${resource.slug.replace(/\//g, '.')}.${index}`), widget);
|
|
3270
|
+
for (const resource of panel.getResources())
|
|
3271
|
+
for (const manager of resource.relations)
|
|
3272
|
+
runtime.register(name(`relation-managers.${resource.slug.replace(/\//g, '.')}.${manager.relationship}`), manager);
|
|
3273
|
+
router.use(express.static(dir('../dist/filament'), { maxAge: '1y' }));
|
|
3274
|
+
// Fichiers propres à Navette (hors balisage et styles de Filament).
|
|
3275
|
+
router.use(express.static(dir('../dist/navette')));
|
|
3276
|
+
// Le locataire d'une mise à jour est celui de la page qui l'envoie (adresse du snapshot, signé), si l'utilisateur en est membre.
|
|
3277
|
+
const wireContext = async (req, res) => {
|
|
3278
|
+
const ctx = await context(req, res);
|
|
3279
|
+
let path = '';
|
|
3280
|
+
try {
|
|
3281
|
+
path = String(JSON.parse(req.body?.components?.[0]?.snapshot ?? '{}')?.memo?.path ?? '');
|
|
3282
|
+
}
|
|
3283
|
+
catch {
|
|
3284
|
+
path = '';
|
|
3285
|
+
}
|
|
3286
|
+
ctx.tenant = await tenantFromPath(path, ctx.user);
|
|
3287
|
+
return ctx;
|
|
3288
|
+
};
|
|
3289
|
+
// Port du middleware PublishNotifications : les notifications envoyées pendant la requête partent dans un lot annoncé au navigateur.
|
|
3290
|
+
router.use(wireRouter(runtime, livewire, wireContext, async (ctx, _req, res, handle) => {
|
|
3291
|
+
const { result, pending } = await runInRequest(ctx.mailbox, () => panel.withTheme(ctx.req, () => panel.withTenant(ctx.tenant ?? null, handle)));
|
|
3292
|
+
if (pending.length) {
|
|
3293
|
+
const batch = publish(ctx.mailbox, pending);
|
|
3294
|
+
result.filamentNotificationBatch = batch;
|
|
3295
|
+
result.filamentNotifications = true;
|
|
3296
|
+
res.setHeader('X-Filament-Notifications', batch);
|
|
3297
|
+
if (result.components.some((c) => c.effects?.redirect))
|
|
3298
|
+
res.setHeader('X-Filament-Notifications-Redirect', 'true');
|
|
3299
|
+
}
|
|
3300
|
+
return result;
|
|
3301
|
+
// Seul un utilisateur connecté peut envoyer des fichiers.
|
|
3302
|
+
}, (ctx) => !!ctx.user));
|
|
3303
|
+
// Tout le rendu d'une page se fait avec les couleurs du thème de la requête, et pour son locataire.
|
|
3304
|
+
const document = (req, res, ctx, options) => panel.withTheme(req, () => panel.withTenant(ctx.tenant ?? null, () => renderDocument(req, res, ctx, options)));
|
|
3305
|
+
const renderDocument = async (req, res, ctx, options) => {
|
|
3306
|
+
const request = { path: req.path, method: req.method, ctx };
|
|
3307
|
+
const mounted = await runtime.mountWithInstance(options.component, options.params ?? {}, request);
|
|
3308
|
+
// Une page qui redirige dès son montage (déjà connecté, adresse déjà vérifiée…) : redirection HTTP.
|
|
3309
|
+
const redirect = mounted.component._effects?.redirect;
|
|
3310
|
+
if (typeof redirect === 'string')
|
|
3311
|
+
return void res.redirect(redirect);
|
|
3312
|
+
const slot = mounted.html;
|
|
3313
|
+
// Le titre du document vient de l'instance (il peut dépendre de l'enregistrement).
|
|
3314
|
+
const instanceTitle = mounted.component.getTitle?.();
|
|
3315
|
+
if (typeof instanceTitle === 'string')
|
|
3316
|
+
options.title = instanceTitle;
|
|
3317
|
+
// Les emplacements de la mise en page portent les portées de la page affichée.
|
|
3318
|
+
const renderHookScopes = mounted.component.getRenderHookScopes?.() ?? [];
|
|
3319
|
+
const hasNavigation = panel.hasNavigation();
|
|
3320
|
+
const body = options.layout === 'simple'
|
|
3321
|
+
? views.render('filament-panels::layout.simple', {
|
|
3322
|
+
slot, renderHookScopes,
|
|
3323
|
+
header: panel.hasTopbar() && ctx.user ? (await runtime.mount(name('simple-user-menu'), {}, request)).replace(/^s*<div /, '<div wire:key="lw-1-1-0" ') : null,
|
|
3324
|
+
})
|
|
3325
|
+
: views.render('filament-panels::layout.index', {
|
|
3326
|
+
slot, maxContentWidth: panel.getMaxContentWidth(), renderHookScopes, hasNavigation, hasTopNavigation: panel.hasTopNavigation(),
|
|
3327
|
+
isSidebarCollapsibleOnDesktop: panel.isSidebarCollapsibleOnDesktop(), isSidebarFullyCollapsibleOnDesktop: panel.isSidebarFullyCollapsibleOnDesktop(),
|
|
3328
|
+
topbar: panel.hasTopbar() ? (await runtime.mount(name('topbar'), { path: req.path }, request)).replace(/^s*<div /, '<div wire:key="lw-1-0-1" ') : null,
|
|
3329
|
+
sidebar: hasNavigation ? (await runtime.mount(name('sidebar'), { path: req.path }, request)).replace(/^s*<div /, '<div wire:key="lw-1-1-1" ') : '',
|
|
3330
|
+
});
|
|
3331
|
+
const bodyClass = options.layout === 'index' ? cls([{ 'fi-body-has-navigation': hasNavigation, 'fi-body-has-sidebar-collapsible-on-desktop': panel.isSidebarCollapsibleOnDesktop(), 'fi-body-has-sidebar-fully-collapsible-on-desktop': panel.isSidebarFullyCollapsibleOnDesktop(), 'fi-body-has-topbar': panel.hasTopbar(), 'fi-body-has-top-navigation': panel.hasTopNavigation() }]) || null : null;
|
|
3332
|
+
res.send(views.render('filament-panels::layout.base', {
|
|
3333
|
+
renderHookScopes, title: options.title, brandName: panel.getBrandName(), favicon: panel.getFavicon(), theme: panel.getTheme(), motion: panel.hasMotion(), panelId: panel.getId(), font: panel.resolveTheme(req)?.font ? { ...panel.getFont(), ...fontOf(panel.resolveTheme(req).font) } : panel.getFont(), themeCss: panel.resolveTheme(req)?.css ?? '',
|
|
3334
|
+
bodyClass, body, spa: panel.hasSpaMode(),
|
|
3335
|
+
csrf: csrfToken(req, res), colorVariables: panel.colors.renderCssVariables(), wireStyles: wireStyles(), wireScripts: wireScripts(req, res, livewire),
|
|
3336
|
+
notifications: (await runtime.mount('navette.notifications', {}, request)).replace(/^<div /, '<div wire:key="lw-1-0-0" '),
|
|
3337
|
+
}));
|
|
3338
|
+
};
|
|
3339
|
+
/** Erreurs d'accès d'une page : 404 et 403 en clair, le reste remonte. */
|
|
3340
|
+
const guarded = async (res, render) => {
|
|
3341
|
+
try {
|
|
3342
|
+
await render();
|
|
3343
|
+
}
|
|
3344
|
+
catch (error) {
|
|
3345
|
+
if (error instanceof WireError && error.status === 404)
|
|
3346
|
+
return void res.status(404).send('Not Found');
|
|
3347
|
+
if (error instanceof WireError && error.status === 403)
|
|
3348
|
+
return void res.status(403).send('Forbidden');
|
|
3349
|
+
throw error;
|
|
3350
|
+
}
|
|
3351
|
+
};
|
|
3352
|
+
/** Page simple réservée aux invités (connexion, inscription, mot de passe oublié). */
|
|
3353
|
+
const guestPage = (path, component, title, params = () => ({})) => {
|
|
3354
|
+
router.get(panel.getBaseUrl(path), async (req, res) => {
|
|
3355
|
+
const ctx = await context(req, res);
|
|
3356
|
+
if (ctx.user)
|
|
3357
|
+
return res.redirect((await homeUrl(ctx.user)) ?? panel.getBaseUrl());
|
|
3358
|
+
await guarded(res, () => document(req, res, ctx, { title, component, layout: 'simple', params: params(req) }));
|
|
3359
|
+
});
|
|
3360
|
+
};
|
|
3361
|
+
/** Page simple d'un utilisateur connecté, hors locataire (profil, vérification, création d'un locataire). */
|
|
3362
|
+
const userPage = (path, component, title, layout = 'simple') => {
|
|
3363
|
+
router.get(panel.getBaseUrl(path), async (req, res) => {
|
|
3364
|
+
const ctx = await context(req, res);
|
|
3365
|
+
if (!ctx.user)
|
|
3366
|
+
return res.redirect(panel.getLoginUrl());
|
|
3367
|
+
await guarded(res, () => document(req, res, ctx, { title, component, layout }));
|
|
3368
|
+
});
|
|
3369
|
+
};
|
|
3370
|
+
if (LoginPage) {
|
|
3371
|
+
guestPage('login', name('auth.login'), t('filament-panels::auth/pages/login.title'));
|
|
3372
|
+
router.post(panel.getLogoutUrl(), express.urlencoded({ extended: false }), (req, res) => {
|
|
3373
|
+
if (!verifyCsrf(req, req.body?._token))
|
|
3374
|
+
return res.status(419).send('Jeton CSRF invalide.');
|
|
3375
|
+
panel.logOut(res);
|
|
3376
|
+
res.redirect(panel.getLoginUrl());
|
|
3377
|
+
});
|
|
3378
|
+
}
|
|
3379
|
+
if (RegistrationPage)
|
|
3380
|
+
guestPage('register', name('auth.register'), t('filament-panels::auth/pages/register.title'));
|
|
3381
|
+
if (passwordReset) {
|
|
3382
|
+
guestPage('password-reset/request', name('auth.password-reset.request'), t('filament-panels::auth/pages/password-reset/request-password-reset.title'));
|
|
3383
|
+
guestPage('password-reset/reset', name('auth.password-reset.reset'), t('filament-panels::auth/pages/password-reset/reset-password.title'), (req) => ({ email: typeof req.query.email === 'string' ? req.query.email : null, token: typeof req.query.token === 'string' ? req.query.token : null }));
|
|
3384
|
+
}
|
|
3385
|
+
if (VerificationPrompt) {
|
|
3386
|
+
userPage('email-verification/prompt', name('auth.email-verification.prompt'), t('filament-panels::auth/pages/email-verification/email-verification-prompt.title'));
|
|
3387
|
+
// Lien reçu par e-mail : signé, limité dans le temps, et valable seulement pour l'utilisateur connecté qu'il désigne.
|
|
3388
|
+
router.get(panel.getBaseUrl('email-verification/verify/:id/:hash'), async (req, res) => {
|
|
3389
|
+
const ctx = await context(req, res);
|
|
3390
|
+
if (!ctx.user)
|
|
3391
|
+
return res.redirect(panel.getLoginUrl());
|
|
3392
|
+
const id = String(req.params.id), hash = String(req.params.hash);
|
|
3393
|
+
const expected = createHash('sha1').update(ctx.user.email).digest('hex');
|
|
3394
|
+
if (String(ctx.user.id) !== id || hash !== expected || !panel.checkVerifyEmailUrl(id, hash, req.query.expires, req.query.signature))
|
|
3395
|
+
return void res.status(403).send('Invalid signature.');
|
|
3396
|
+
await panel.getAuth().markEmailAsVerified?.(ctx.user);
|
|
3397
|
+
res.redirect((await homeUrl(ctx.user)) ?? panel.getBaseUrl());
|
|
3398
|
+
});
|
|
3399
|
+
}
|
|
3400
|
+
if (profile)
|
|
3401
|
+
userPage(profile.page.slug, name('auth.profile'), profile.page.getLabel(), profile.isSimple || panel.hasTenancy() ? 'simple' : 'index');
|
|
3402
|
+
if (TenantRegistration)
|
|
3403
|
+
userPage(TenantRegistration.slug, name('tenancy.registration'), TenantRegistration.getLabel());
|
|
3404
|
+
/** Contexte d'une page protégée : connexion, vérification de l'adresse, locataire. Rend null après avoir répondu. */
|
|
3405
|
+
const pageContext = async (req, res) => {
|
|
3406
|
+
const ctx = await context(req, res);
|
|
3407
|
+
if (!ctx.user) {
|
|
3408
|
+
res.redirect(panel.getLoginUrl());
|
|
3409
|
+
return null;
|
|
3410
|
+
}
|
|
3411
|
+
if (await panel.mustVerifyEmail(ctx.user)) {
|
|
3412
|
+
res.redirect(panel.getEmailVerificationPromptUrl());
|
|
3413
|
+
return null;
|
|
3414
|
+
}
|
|
3415
|
+
if (panel.hasTenancy()) {
|
|
3416
|
+
ctx.tenant = await tenantFromPath(req.path, ctx.user);
|
|
3417
|
+
if (!ctx.tenant) {
|
|
3418
|
+
res.status(404).send('Not Found');
|
|
3419
|
+
return null;
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
return ctx;
|
|
3423
|
+
};
|
|
3424
|
+
const tenantSegment = panel.hasTenancy() ? ':tenant/' : '';
|
|
3425
|
+
if (panel.hasTenancy()) {
|
|
3426
|
+
// Racine du panel : le premier locataire de l'utilisateur, sinon la création d'un locataire.
|
|
3427
|
+
router.get(panel.getBaseUrl(), async (req, res) => {
|
|
3428
|
+
const ctx = await context(req, res);
|
|
3429
|
+
if (!ctx.user)
|
|
3430
|
+
return res.redirect(panel.getLoginUrl());
|
|
3431
|
+
if (await panel.mustVerifyEmail(ctx.user))
|
|
3432
|
+
return res.redirect(panel.getEmailVerificationPromptUrl());
|
|
3433
|
+
const url = await homeUrl(ctx.user);
|
|
3434
|
+
if (!url)
|
|
3435
|
+
return void res.status(404).send('Not Found');
|
|
3436
|
+
res.redirect(url);
|
|
3437
|
+
});
|
|
3438
|
+
// Racine d'un locataire sans tableau de bord : la première page du menu (comme Filament).
|
|
3439
|
+
if (!panel.getPages().some((page) => page.slug === '')) {
|
|
3440
|
+
router.get(panel.getBaseUrl(':tenant'), async (req, res, next) => {
|
|
3441
|
+
if (RESERVED_SEGMENTS.has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug)
|
|
3442
|
+
return next();
|
|
3443
|
+
const ctx = await pageContext(req, res);
|
|
3444
|
+
if (!ctx)
|
|
3445
|
+
return;
|
|
3446
|
+
const url = await panel.withTenant(ctx.tenant, async () => panel.getNavigation(req.path, new Map(), await panel.resolveInaccessiblePages(ctx.user))[0]?.items[0]?.url ?? null);
|
|
3447
|
+
if (!url)
|
|
3448
|
+
return void res.status(404).send('Not Found');
|
|
3449
|
+
res.redirect(url);
|
|
3450
|
+
});
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
for (const PageClass of panel.getPages()) {
|
|
3454
|
+
const component = name(`pages.${(PageClass.slug || 'dashboard').replace(/[{}]/g, '')}`);
|
|
3455
|
+
runtime.register(component, PageClass);
|
|
3456
|
+
// `{record}` dans le slug devient un paramètre de route, passé à mount().
|
|
3457
|
+
router.get(panel.getBaseUrl(tenantSegment + PageClass.slug).replace(/\{(\w+)\}/g, ':$1'), async (req, res, next) => {
|
|
3458
|
+
if (panel.hasTenancy() && (RESERVED_SEGMENTS.has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug))
|
|
3459
|
+
return next();
|
|
3460
|
+
const ctx = await pageContext(req, res);
|
|
3461
|
+
if (!ctx)
|
|
3462
|
+
return;
|
|
3463
|
+
const { tenant: _tenant, ...params } = req.params;
|
|
3464
|
+
await guarded(res, () => document(req, res, ctx, { title: PageClass.getTitle(), component, layout: 'index', params }));
|
|
3465
|
+
});
|
|
3466
|
+
}
|
|
3467
|
+
return router;
|
|
3468
|
+
}
|
|
3469
|
+
/** Une option d'authentification exige certaines méthodes du fournisseur d'utilisateurs : on le dit au démarrage plutôt qu'au premier clic. */
|
|
3470
|
+
function assertAuthCapabilities(panel) {
|
|
3471
|
+
if (!panel.hasRegistration() && !panel.hasPasswordReset() && !panel.hasEmailVerification() && !panel.hasProfile())
|
|
3472
|
+
return;
|
|
3473
|
+
const auth = panel.getAuth();
|
|
3474
|
+
const missing = (feature, methods) => {
|
|
3475
|
+
const absent = methods.filter((method) => typeof auth[method] !== 'function');
|
|
3476
|
+
if (absent.length)
|
|
3477
|
+
throw new Error(`${feature} : le fournisseur d'utilisateurs doit définir ${absent.join(', ')} (knexAuthProvider le fait).`);
|
|
3478
|
+
};
|
|
3479
|
+
if (panel.hasRegistration())
|
|
3480
|
+
missing('Inscription', ['create', 'findByEmail']);
|
|
3481
|
+
if (panel.hasPasswordReset())
|
|
3482
|
+
missing('Mot de passe oublié', ['findByEmail', 'update', 'getPasswordFingerprint']);
|
|
3483
|
+
if (panel.hasEmailVerification())
|
|
3484
|
+
missing('Vérification de l\'adresse', ['hasVerifiedEmail', 'markEmailAsVerified']);
|
|
3485
|
+
if (panel.hasProfile())
|
|
3486
|
+
missing('Profil', ['update']);
|
|
3487
|
+
}
|