@navette-js/panels 0.0.0-stage → 1.0.0-beta.2

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.
Files changed (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/THIRD-PARTY-NOTICES.md +12 -0
  4. package/dist/FILAMENT-LICENSE.md +21 -0
  5. package/dist/filament/css/filament/filament/app.css +2 -0
  6. package/dist/filament/fonts/INTER-LICENSE.txt +92 -0
  7. package/dist/filament/fonts/filament/filament/inter/index.css +1 -0
  8. package/dist/filament/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-SP7Z6XGK.woff2 +0 -0
  9. package/dist/filament/fonts/filament/filament/inter/inter-cyrillic-wght-normal-DR6K5BQD.woff2 +0 -0
  10. package/dist/filament/fonts/filament/filament/inter/inter-greek-ext-wght-normal-A2J6H3EX.woff2 +0 -0
  11. package/dist/filament/fonts/filament/filament/inter/inter-greek-wght-normal-ZABHMKQG.woff2 +0 -0
  12. package/dist/filament/fonts/filament/filament/inter/inter-latin-ext-wght-normal-ZIT2UBIY.woff2 +0 -0
  13. package/dist/filament/fonts/filament/filament/inter/inter-latin-wght-normal-DIHYUR35.woff2 +0 -0
  14. package/dist/filament/fonts/filament/filament/inter/inter-vietnamese-wght-normal-VWEHJHBA.woff2 +0 -0
  15. package/dist/filament/js/filament/actions/actions.js +1 -0
  16. package/dist/filament/js/filament/filament/app.js +1 -0
  17. package/dist/filament/js/filament/filament/echo.js +13 -0
  18. package/dist/filament/js/filament/forms/components/builder.js +1 -0
  19. package/dist/filament/js/filament/forms/components/checkbox-list.js +1 -0
  20. package/dist/filament/js/filament/forms/components/code-editor.js +38 -0
  21. package/dist/filament/js/filament/forms/components/color-picker.js +1 -0
  22. package/dist/filament/js/filament/forms/components/date-time-picker.js +1 -0
  23. package/dist/filament/js/filament/forms/components/file-upload.js +116 -0
  24. package/dist/filament/js/filament/forms/components/js-field.js +1 -0
  25. package/dist/filament/js/filament/forms/components/key-value.js +1 -0
  26. package/dist/filament/js/filament/forms/components/markdown-editor.js +51 -0
  27. package/dist/filament/js/filament/forms/components/repeater.js +1 -0
  28. package/dist/filament/js/filament/forms/components/rich-editor.js +161 -0
  29. package/dist/filament/js/filament/forms/components/select.js +11 -0
  30. package/dist/filament/js/filament/forms/components/slider.js +1 -0
  31. package/dist/filament/js/filament/forms/components/tags-input.js +1 -0
  32. package/dist/filament/js/filament/forms/components/text-input/password-reveal.js +1 -0
  33. package/dist/filament/js/filament/forms/components/textarea.js +1 -0
  34. package/dist/filament/js/filament/forms/components/toggle-buttons.js +1 -0
  35. package/dist/filament/js/filament/notifications/notifications.js +1 -0
  36. package/dist/filament/js/filament/schemas/components/actions.js +1 -0
  37. package/dist/filament/js/filament/schemas/components/js-component.js +1 -0
  38. package/dist/filament/js/filament/schemas/components/tabs.js +1 -0
  39. package/dist/filament/js/filament/schemas/components/wizard.js +1 -0
  40. package/dist/filament/js/filament/schemas/schemas.js +1 -0
  41. package/dist/filament/js/filament/support/support.js +46 -0
  42. package/dist/filament/js/filament/tables/components/columns/checkbox.js +1 -0
  43. package/dist/filament/js/filament/tables/components/columns/select.js +11 -0
  44. package/dist/filament/js/filament/tables/components/columns/text-input.js +1 -0
  45. package/dist/filament/js/filament/tables/components/columns/toggle.js +1 -0
  46. package/dist/filament/js/filament/tables/tables.js +1 -0
  47. package/dist/filament/js/filament/widgets/components/chart.js +31 -0
  48. package/dist/filament/js/filament/widgets/components/js-widget.js +1 -0
  49. package/dist/filament/js/filament/widgets/components/stats-overview/stat/chart.js +20 -0
  50. package/dist/navette/navette/calendar.css +108 -0
  51. package/dist/navette/navette/motion.css +101 -0
  52. package/dist/navette/navette/motion.js +49 -0
  53. package/lib/auth.d.ts +61 -0
  54. package/lib/auth.js +140 -0
  55. package/lib/calendar.d.ts +82 -0
  56. package/lib/calendar.js +107 -0
  57. package/lib/index.d.ts +1624 -0
  58. package/lib/index.js +3532 -0
  59. package/lib/notifications.d.ts +76 -0
  60. package/lib/notifications.js +112 -0
  61. package/lib/topbar.d.ts +90 -0
  62. package/lib/topbar.js +240 -0
  63. package/package.json +58 -4
  64. package/resources/filament/LICENSE.md +21 -0
  65. package/resources/filament/README.md +2 -0
  66. package/resources/filament/packages/actions/resources/css/actions.css +30 -0
  67. package/resources/filament/packages/actions/resources/css/index.css +1 -0
  68. package/resources/filament/packages/forms/dist/index.css +1 -0
  69. package/resources/filament/packages/forms/resources/css/components/builder.css +206 -0
  70. package/resources/filament/packages/forms/resources/css/components/checkbox-list.css +55 -0
  71. package/resources/filament/packages/forms/resources/css/components/code-editor.css +79 -0
  72. package/resources/filament/packages/forms/resources/css/components/color-picker.css +17 -0
  73. package/resources/filament/packages/forms/resources/css/components/date-time-picker.css +127 -0
  74. package/resources/filament/packages/forms/resources/css/components/field.css +88 -0
  75. package/resources/filament/packages/forms/resources/css/components/file-upload/filepond-plugin-media-preview.css +104 -0
  76. package/resources/filament/packages/forms/resources/css/components/file-upload.css +199 -0
  77. package/resources/filament/packages/forms/resources/css/components/key-value.css +57 -0
  78. package/resources/filament/packages/forms/resources/css/components/markdown-editor.css +343 -0
  79. package/resources/filament/packages/forms/resources/css/components/modal-table-select.css +17 -0
  80. package/resources/filament/packages/forms/resources/css/components/radio.css +41 -0
  81. package/resources/filament/packages/forms/resources/css/components/repeater.css +401 -0
  82. package/resources/filament/packages/forms/resources/css/components/rich-editor.css +666 -0
  83. package/resources/filament/packages/forms/resources/css/components/select.css +13 -0
  84. package/resources/filament/packages/forms/resources/css/components/slider.css +54 -0
  85. package/resources/filament/packages/forms/resources/css/components/tags-input.css +33 -0
  86. package/resources/filament/packages/forms/resources/css/components/text-input.css +20 -0
  87. package/resources/filament/packages/forms/resources/css/components/textarea.css +26 -0
  88. package/resources/filament/packages/forms/resources/css/components/toggle-buttons.css +49 -0
  89. package/resources/filament/packages/forms/resources/css/index.css +32 -0
  90. package/resources/filament/packages/infolists/resources/css/components/code.css +21 -0
  91. package/resources/filament/packages/infolists/resources/css/components/color.css +34 -0
  92. package/resources/filament/packages/infolists/resources/css/components/entry.css +68 -0
  93. package/resources/filament/packages/infolists/resources/css/components/icon.css +39 -0
  94. package/resources/filament/packages/infolists/resources/css/components/image.css +161 -0
  95. package/resources/filament/packages/infolists/resources/css/components/key-value.css +24 -0
  96. package/resources/filament/packages/infolists/resources/css/components/repeatable.css +136 -0
  97. package/resources/filament/packages/infolists/resources/css/components/text.css +234 -0
  98. package/resources/filament/packages/infolists/resources/css/index.css +14 -0
  99. package/resources/filament/packages/notifications/resources/css/database-notifications.css +56 -0
  100. package/resources/filament/packages/notifications/resources/css/index.css +3 -0
  101. package/resources/filament/packages/notifications/resources/css/notification.css +109 -0
  102. package/resources/filament/packages/notifications/resources/css/notifications.css +29 -0
  103. package/resources/filament/packages/panels/resources/css/base.css +13 -0
  104. package/resources/filament/packages/panels/resources/css/components/global-search.css +93 -0
  105. package/resources/filament/packages/panels/resources/css/components/header.css +56 -0
  106. package/resources/filament/packages/panels/resources/css/components/layout.css +182 -0
  107. package/resources/filament/packages/panels/resources/css/components/logo.css +11 -0
  108. package/resources/filament/packages/panels/resources/css/components/page.css +60 -0
  109. package/resources/filament/packages/panels/resources/css/components/sidebar.css +311 -0
  110. package/resources/filament/packages/panels/resources/css/components/tenant-menu.css +49 -0
  111. package/resources/filament/packages/panels/resources/css/components/theme-switcher.css +15 -0
  112. package/resources/filament/packages/panels/resources/css/components/topbar.css +89 -0
  113. package/resources/filament/packages/panels/resources/css/components/user-menu.css +46 -0
  114. package/resources/filament/packages/panels/resources/css/index.css +40 -0
  115. package/resources/filament/packages/panels/resources/css/theme.css +2 -0
  116. package/resources/filament/packages/panels/resources/css/utilities.css +3 -0
  117. package/resources/filament/packages/panels/resources/css/widgets/account-widget.css +21 -0
  118. package/resources/filament/packages/panels/resources/css/widgets/filament-info-widget.css +21 -0
  119. package/resources/filament/packages/schemas/resources/css/components/actions.css +29 -0
  120. package/resources/filament/packages/schemas/resources/css/components/flex.css +106 -0
  121. package/resources/filament/packages/schemas/resources/css/components/form.css +7 -0
  122. package/resources/filament/packages/schemas/resources/css/components/fused-group.css +337 -0
  123. package/resources/filament/packages/schemas/resources/css/components/icon.css +11 -0
  124. package/resources/filament/packages/schemas/resources/css/components/image.css +12 -0
  125. package/resources/filament/packages/schemas/resources/css/components/section.css +24 -0
  126. package/resources/filament/packages/schemas/resources/css/components/tabs.css +77 -0
  127. package/resources/filament/packages/schemas/resources/css/components/text.css +77 -0
  128. package/resources/filament/packages/schemas/resources/css/components/unordered-list.css +15 -0
  129. package/resources/filament/packages/schemas/resources/css/components/wizard.css +177 -0
  130. package/resources/filament/packages/schemas/resources/css/index.css +12 -0
  131. package/resources/filament/packages/schemas/resources/css/schema.css +132 -0
  132. package/resources/filament/packages/support/dist/index.css +1 -0
  133. package/resources/filament/packages/support/resources/css/base.css +23 -0
  134. package/resources/filament/packages/support/resources/css/components/avatar.css +15 -0
  135. package/resources/filament/packages/support/resources/css/components/badge.css +90 -0
  136. package/resources/filament/packages/support/resources/css/components/breadcrumbs.css +25 -0
  137. package/resources/filament/packages/support/resources/css/components/button.css +214 -0
  138. package/resources/filament/packages/support/resources/css/components/callout.css +57 -0
  139. package/resources/filament/packages/support/resources/css/components/dropdown/header.css +21 -0
  140. package/resources/filament/packages/support/resources/css/components/dropdown/index.css +79 -0
  141. package/resources/filament/packages/support/resources/css/components/dropdown/list/index.css +7 -0
  142. package/resources/filament/packages/support/resources/css/components/dropdown/list/item.css +74 -0
  143. package/resources/filament/packages/support/resources/css/components/empty-state.css +67 -0
  144. package/resources/filament/packages/support/resources/css/components/fieldset.css +23 -0
  145. package/resources/filament/packages/support/resources/css/components/grid.css +511 -0
  146. package/resources/filament/packages/support/resources/css/components/icon-button.css +124 -0
  147. package/resources/filament/packages/support/resources/css/components/icon.css +31 -0
  148. package/resources/filament/packages/support/resources/css/components/input/checkbox.css +32 -0
  149. package/resources/filament/packages/support/resources/css/components/input/index.css +55 -0
  150. package/resources/filament/packages/support/resources/css/components/input/one-time-code.css +7 -0
  151. package/resources/filament/packages/support/resources/css/components/input/radio.css +11 -0
  152. package/resources/filament/packages/support/resources/css/components/input/select.css +154 -0
  153. package/resources/filament/packages/support/resources/css/components/input/wrapper.css +107 -0
  154. package/resources/filament/packages/support/resources/css/components/link.css +90 -0
  155. package/resources/filament/packages/support/resources/css/components/loading-indicator.css +3 -0
  156. package/resources/filament/packages/support/resources/css/components/loading-section.css +3 -0
  157. package/resources/filament/packages/support/resources/css/components/modal.css +494 -0
  158. package/resources/filament/packages/support/resources/css/components/pagination.css +160 -0
  159. package/resources/filament/packages/support/resources/css/components/section.css +234 -0
  160. package/resources/filament/packages/support/resources/css/components/tabs.css +70 -0
  161. package/resources/filament/packages/support/resources/css/components/toggle.css +60 -0
  162. package/resources/filament/packages/support/resources/css/components/tooltip.css +36 -0
  163. package/resources/filament/packages/support/resources/css/index.css +133 -0
  164. package/resources/filament/packages/support/resources/css/sortable.css +3 -0
  165. package/resources/filament/packages/support/resources/css/utilities.css +897 -0
  166. package/resources/filament/packages/tables/resources/css/actions.css +51 -0
  167. package/resources/filament/packages/tables/resources/css/cell.css +139 -0
  168. package/resources/filament/packages/tables/resources/css/column-manager.css +48 -0
  169. package/resources/filament/packages/tables/resources/css/columns/checkbox.css +54 -0
  170. package/resources/filament/packages/tables/resources/css/columns/color.css +76 -0
  171. package/resources/filament/packages/tables/resources/css/columns/icon.css +81 -0
  172. package/resources/filament/packages/tables/resources/css/columns/image.css +207 -0
  173. package/resources/filament/packages/tables/resources/css/columns/layout/grid.css +49 -0
  174. package/resources/filament/packages/tables/resources/css/columns/layout/panel.css +3 -0
  175. package/resources/filament/packages/tables/resources/css/columns/layout/split.css +35 -0
  176. package/resources/filament/packages/tables/resources/css/columns/layout/stack.css +29 -0
  177. package/resources/filament/packages/tables/resources/css/columns/select.css +36 -0
  178. package/resources/filament/packages/tables/resources/css/columns/summaries/icon-count.css +43 -0
  179. package/resources/filament/packages/tables/resources/css/columns/summaries/range.css +27 -0
  180. package/resources/filament/packages/tables/resources/css/columns/summaries/text.css +29 -0
  181. package/resources/filament/packages/tables/resources/css/columns/summaries/values.css +33 -0
  182. package/resources/filament/packages/tables/resources/css/columns/text-input.css +36 -0
  183. package/resources/filament/packages/tables/resources/css/columns/text.css +319 -0
  184. package/resources/filament/packages/tables/resources/css/columns/toggle.css +54 -0
  185. package/resources/filament/packages/tables/resources/css/container.css +311 -0
  186. package/resources/filament/packages/tables/resources/css/content.css +342 -0
  187. package/resources/filament/packages/tables/resources/css/empty-state.css +31 -0
  188. package/resources/filament/packages/tables/resources/css/group-header.css +54 -0
  189. package/resources/filament/packages/tables/resources/css/header-cell.css +224 -0
  190. package/resources/filament/packages/tables/resources/css/index.css +26 -0
  191. package/resources/filament/packages/tables/resources/css/row.css +47 -0
  192. package/resources/filament/packages/tables/resources/css/table.css +394 -0
  193. package/resources/filament/packages/widgets/resources/css/chart-widget.css +88 -0
  194. package/resources/filament/packages/widgets/resources/css/index.css +3 -0
  195. package/resources/filament/packages/widgets/resources/css/stats-overview-widget.css +67 -0
  196. package/resources/filament/packages/widgets/resources/css/widget.css +3 -0
  197. package/theme.css +17 -0
  198. package/views/components/logo.ejs +9 -0
  199. package/views/components/tenant-menu.ejs +13 -0
  200. package/views/components/user-menu.ejs +82 -0
  201. package/views/layout/base.ejs +129 -0
  202. package/views/layout/index.ejs +41 -0
  203. package/views/layout/simple.ejs +19 -0
  204. package/views/livewire/sidebar.ejs +123 -0
  205. package/views/livewire/topbar.ejs +47 -0
  206. package/views/pages/page.ejs +143 -0
  207. package/views/pages/simple.ejs +32 -0
package/lib/index.js ADDED
@@ -0,0 +1,3532 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ // @navette-js/panels: Panel, layouts, pages, resources, navigation, authentication and session.
10
+ import { AsyncLocalStorage } from 'node:async_hooks';
11
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
12
+ import { checkVerificationUrl, consoleMailer, readToken, signToken, signVerificationUrl } from "./auth.js";
13
+ export { consoleMailer, fileMailer, hashPassword, knexAuthProvider, knexTenantProvider, mailText, verifyPassword } from "./auth.js";
14
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { fileURLToPath, pathToFileURL } from 'node:url';
17
+ import express from 'express';
18
+ import { Action, useColorManager, ActionGroup } from '@navette-js/actions';
19
+ import { Checkbox, Field, Select, TextInput, Wizard, richDocumentToHtml, richHtmlToDocument, fillSchema, fillSchemaFromRecord, getSchemaState, validateSchema, useColorManager as useFormsColorManager } from '@navette-js/forms';
20
+ import { Actions, Component as SchemaComponent, EmbeddedSchema, Form, Group, Html, RenderHook, Schema, StatefulGroup } from '@navette-js/schemas';
21
+ import { WireError } from '@navette-js/wire';
22
+ import { useColorManager as useInfolistsColorManager, useHtmlSanitizer } from '@navette-js/infolists';
23
+ import { Notification, Notifications, publish, runInRequest } from "./notifications.js";
24
+ export { Notification, Notifications } from "./notifications.js";
25
+ import { addDays, addMonths, CALENDAR_LABELS, CALENDAR_VIEWS, isDate, renderCalendar, visibleRange } from "./calendar.js";
26
+ export { addDays, addMonths, CALENDAR_LABELS, dayOf, daysBetween, eventsOn, renderCalendar, renderCalendarEvent, timeOf, visibleRange } from "./calendar.js";
27
+ import { DatabaseNotifications, GlobalSearch, keyBindingSuffix } from "./topbar.js";
28
+ export { DatabaseNotifications, GlobalSearch, knexNotificationStore, timeAgo } from "./topbar.js";
29
+ import { Attributes, ColorManager, HtmlString, RenderHookRegistry, cls, convertToRgb, e, icon, js, loadingIndicator, renderHook, runWithRenderHooks, views } from '@navette-js/support';
30
+ import { t, tc } from '@navette-js/support/i18n';
31
+ import { emptyState } from '@navette-js/support/ui';
32
+ import { REORDER_LIMIT, Table, initializeTableGrouping, updateTableColumnState as updateEditableColumnState, getTableQuery, renderTable, sanitizeColumnState, sortTable, useColorManager as useTableColorManager } from '@navette-js/tables';
33
+ import { Component } from '@navette-js/wire';
34
+ import { csrfToken, verifyCsrf, wireRouter, wireScripts, wireStyles } from '@navette-js/wire/express';
35
+ const dir = (p) => fileURLToPath(new URL(p, import.meta.url));
36
+ views.namespace('filament-panels', dir('../views'));
37
+ views.namespace('filament', fileURLToPath(new URL('../views', import.meta.resolve('@navette-js/support'))));
38
+ views.share({ t, tc });
39
+ export const ASSET_VERSION = '5.11.2.0';
40
+ export class NavigationItem {
41
+ label;
42
+ _url = '#';
43
+ _icon = null;
44
+ _group = null;
45
+ _sort = 0;
46
+ _isActive = null;
47
+ _badge = null;
48
+ _badgeColor = 'primary';
49
+ constructor(label) { this.label = label; }
50
+ static make(label) { return new NavigationItem(label); }
51
+ url(url) { this._url = url; return this; }
52
+ icon(icon) { this._icon = icon; return this; }
53
+ group(group) { this._group = group; return this; }
54
+ sort(sort) { this._sort = sort; return this; }
55
+ badge(badge, color = 'primary') { this._badge = badge; this._badgeColor = color; return this; }
56
+ /** By default an item is active on its exact URL; this overrides the rule. */
57
+ isActiveWhen(fn) { this._isActive = fn; return this; }
58
+ isActive(path) { return this._isActive ? this._isActive(path) : path === this._url; }
59
+ }
60
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
61
+ export class Panel {
62
+ _id = 'admin';
63
+ _path = '';
64
+ _brandName = 'Navette';
65
+ _brandLogo = null;
66
+ _darkModeBrandLogo = null;
67
+ _brandLogoHeight = '1.5rem';
68
+ _favicon = null;
69
+ _theme = null;
70
+ _motion = true;
71
+ _themes = [];
72
+ _font = { family: 'Inter Variable', url: null, preconnect: null };
73
+ _login = null;
74
+ _auth = null;
75
+ _key = 'cle-de-developpement-a-remplacer';
76
+ _pages = [];
77
+ _resources = [];
78
+ _navigationItems = [];
79
+ _navigationGroups = [];
80
+ _maxContentWidth = '7xl';
81
+ colors = new ColorManager();
82
+ static make() { return new Panel(); }
83
+ id(id) { this._id = id; return this; }
84
+ path(path) { this._path = path.replace(/^\/|\/$/g, ''); return this; }
85
+ brandName(name) { this._brandName = name; return this; }
86
+ brandLogo(url) { this._brandLogo = url; return this; }
87
+ darkModeBrandLogo(url) { this._darkModeBrandLogo = url; return this; }
88
+ brandLogoHeight(height) { this._brandLogoHeight = height; return this; }
89
+ /** Browser tab icon. */
90
+ favicon(url) { this._favicon = url; return this; }
91
+ getFavicon() { return this._favicon; }
92
+ /**
93
+ * Theme stylesheet compiled by the application (Tailwind); it replaces the default stylesheet.
94
+ * The theme imports `@navette-js/panels/theme.css` and declares its own sources.
95
+ */
96
+ theme(url) { this._theme = url; return this; }
97
+ getTheme() { return this._theme; }
98
+ /**
99
+ * Navette's motion and loading indicators (springs, activity dot, spinner for deferred areas).
100
+ * `motion(false)` renders the plain interface without them.
101
+ */
102
+ motion(enabled = true) { this._motion = enabled; return this; }
103
+ hasMotion() { return this._motion; }
104
+ /** Themes offered to the user; the first one is the default. */
105
+ themes(themes) {
106
+ for (const theme of themes)
107
+ if (!/^[a-z0-9-]{1,40}$/.test(theme.id))
108
+ throw new Error(`Invalid theme id: ${theme.id}`);
109
+ this._themes = themes;
110
+ return this;
111
+ }
112
+ getThemes() { return this._themes; }
113
+ /** Theme for the request: the cookie's theme if declared, otherwise the first one. `null` if the panel has no themes. */
114
+ resolveTheme(req) {
115
+ if (!this._themes.length)
116
+ return null;
117
+ const id = (req.headers.cookie ?? '').split(';').map((c) => c.trim()).find((c) => c.startsWith(`navette_${this._id}_theme=`))?.split('=')[1];
118
+ return this._themes.find((theme) => theme.id === id) ?? this._themes[0];
119
+ }
120
+ rememberTheme(res, theme) { res.cookie(`navette_${this._id}_theme`, theme.id, { sameSite: 'lax', secure: res.req?.secure === true, maxAge: 1000 * 60 * 60 * 24 * 365 }); }
121
+ /** Runs a render with the colors of the request's theme. */
122
+ withTheme(req, fn) {
123
+ const theme = this.resolveTheme(req);
124
+ // Components from other packages read a color manager shared by all panels:
125
+ // this panel's (and its theme's) colors only apply for the duration of the request.
126
+ const withPanelColors = () => sharedColors.runWith(`${this._id}:${theme?.id ?? ''}`, this.colors.getColors(), fn);
127
+ // A panel's render hooks only apply to its own requests.
128
+ return runWithRenderHooks(this._renderHooks, () => (theme?.colors ? this.colors.runWith(theme.id, theme.colors, withPanelColors) : withPanelColors()));
129
+ }
130
+ setColors(colors) { this.colors.register(colors); return this; }
131
+ /** Panel font. Without a URL, it is loaded from Bunny Fonts. */
132
+ font(family, url) {
133
+ this._font = { family, url: url ?? `https://fonts.bunny.net/css?family=${slug(family)}:400,500,600,700&display=swap`, preconnect: url ? null : 'https://fonts.bunny.net' };
134
+ return this;
135
+ }
136
+ login(page = Login) { this._login = page; return this; }
137
+ auth(provider) { this._auth = provider; return this; }
138
+ key(key) { this._key = key; return this; }
139
+ pages(pages) { this._pages.push(...pages); return this; }
140
+ /** Registers resources: their pages and navigation items. */
141
+ resources(resources) {
142
+ for (const resource of resources) {
143
+ resource.panel = this;
144
+ this._resources.push(resource);
145
+ this._pages.push(...Object.values(resource.getPages()));
146
+ }
147
+ return this;
148
+ }
149
+ navigationItems(items) { this._navigationItems.push(...items); return this; }
150
+ /** Order of navigation groups. */
151
+ navigationGroups(groups) { this._navigationGroups = groups; return this; }
152
+ _plugins = [];
153
+ /** Applies a plugin: an object that configures the panel (resources, pages, render hooks, themes…). */
154
+ plugin(plugin) {
155
+ if (this._plugins.some((p) => p.id === plugin.id))
156
+ throw new Error(`Plugin already registered: ${plugin.id}`);
157
+ this._plugins.push(plugin);
158
+ plugin.register(this);
159
+ return this;
160
+ }
161
+ plugins(plugins) { for (const plugin of plugins)
162
+ this.plugin(plugin); return this; }
163
+ getPlugins() { return this._plugins; }
164
+ _renderHooks = new RenderHookRegistry();
165
+ /**
166
+ * Inserts HTML at a named location of this panel's UI (names: `PanelsRenderHook`, `TablesRenderHook`, `ActionsRenderHook`, `WidgetsRenderHook`).
167
+ * `scopes` limits the hook to some pages or resources (their class). The HTML is inserted as-is: escape any data with `e()`.
168
+ */
169
+ renderHook(name, render, scopes = null) { this._renderHooks.register(name, render, scopes); return this; }
170
+ getRenderHooks() { return this._renderHooks; }
171
+ _authorizer = null;
172
+ /** Plugs in your own permission system: consulted before any policy, for every ability, page and widget. */
173
+ authorizeUsing(authorizer) { this._authorizer = authorizer; return this; }
174
+ /** Authorization decision: the panel hook first, then the given rule. Always `false` without a user. */
175
+ async authorize(user, ability, subject = {}) {
176
+ if (!user)
177
+ return false;
178
+ const decided = await this._authorizer?.({ user, ability, resource: subject.resource ?? null, record: subject.record ?? null, page: subject.page ?? null, widget: subject.widget ?? null });
179
+ if (typeof decided === 'boolean')
180
+ return decided;
181
+ return subject.rule ? Boolean(await subject.rule()) : true;
182
+ }
183
+ /** Whether the user can open a page. List: `viewAny`; create: `create`; edit: checked against the record; custom page: `canAccess`. */
184
+ async canAccessPage(PageClass, user) {
185
+ const resource = PageClass.resource;
186
+ if (resource && PageClass.prototype instanceof EditRecord)
187
+ return user !== null;
188
+ if (resource && PageClass.prototype instanceof CreateRecord)
189
+ return resource.can('create', user);
190
+ if (resource)
191
+ return resource.can('viewAny', user);
192
+ return this.authorize(user, 'access', { page: PageClass, rule: () => PageClass.canAccess(user) });
193
+ }
194
+ _widgets = [];
195
+ /** Panel widgets (dashboard, custom pages): registered as components, displayed through a page's `getHeaderWidgets()`. */
196
+ widgets(widgets) { this._widgets.push(...widgets); return this; }
197
+ getWidgets() { return this._widgets; }
198
+ maxContentWidth(width) { this._maxContentWidth = width; return this; }
199
+ getId() { return this._id; }
200
+ getBrandName() { return this._brandName; }
201
+ getAuth() { if (!this._auth)
202
+ throw new Error('No auth provider: call panel.auth(...).'); return this._auth; }
203
+ getLoginPage() { return this._login; }
204
+ getPages() { return this._pages; }
205
+ getResources() { return this._resources; }
206
+ // -------------------------------------------------------------- global search
207
+ _globalSearch = true;
208
+ _globalSearchKeyBindings = [];
209
+ _globalSearchDebounce = '500ms';
210
+ _globalSearchSuffix = null;
211
+ /** `false` removes the global search field. It only appears when at least one resource is searchable. */
212
+ globalSearch(condition = true) { this._globalSearch = condition; return this; }
213
+ /** Keyboard shortcuts that focus the field: `['command+k', 'ctrl+k']`. */
214
+ globalSearchKeyBindings(keyBindings) { this._globalSearchKeyBindings = keyBindings.filter((binding) => /^[a-z0-9+]+$/.test(binding)); return this; }
215
+ getGlobalSearchKeyBindings() { return this._globalSearchKeyBindings; }
216
+ /** Delay between typing and searching (`500ms` by default). */
217
+ globalSearchDebounce(debounce) { if (/^[0-9]+m?s$/.test(debounce))
218
+ this._globalSearchDebounce = debounce; return this; }
219
+ getGlobalSearchDebounce() { return this._globalSearchDebounce; }
220
+ globalSearchFieldSuffix(suffix) { this._globalSearchSuffix = suffix; return this; }
221
+ /** Shows the first shortcut in the field, written for the browser's platform (`command+k` shows as "META+K" outside macOS). */
222
+ globalSearchFieldKeyBindingSuffix() { this._globalSearchSuffix = (userAgent) => keyBindingSuffix(this._globalSearchKeyBindings, userAgent); return this; }
223
+ getGlobalSearchFieldSuffix(userAgent = '') { return typeof this._globalSearchSuffix === 'function' ? this._globalSearchSuffix(userAgent) : this._globalSearchSuffix; }
224
+ hasGlobalSearch() { return this._globalSearch && this._resources.some((resource) => resource.canGloballySearch()); }
225
+ /** Results per resource, in registration order. Only resources the user can list are queried. */
226
+ async getGlobalSearchResults(search, user) {
227
+ const categories = [];
228
+ if (!this._globalSearch)
229
+ return categories;
230
+ const ranked = this._resources.map((resource, index) => ({ resource, index })).sort((a, b) => (a.resource.globalSearchSort ?? Infinity) - (b.resource.globalSearchSort ?? Infinity) || a.index - b.index);
231
+ for (const { resource } of ranked) {
232
+ if (!resource.canGloballySearch() || !(await resource.can('viewAny', user)))
233
+ continue;
234
+ const results = await resource.getGlobalSearchResults(search, user);
235
+ if (results.length)
236
+ categories.push({ label: resource.getPluralModelLabel(), results });
237
+ }
238
+ return categories;
239
+ }
240
+ // -------------------------------------------------------------- database notifications
241
+ _databaseNotifications = null;
242
+ _databaseNotificationsPolling = '30s';
243
+ /** Enables the database notifications bell, read from and written to this store (`knexNotificationStore(knex)`). */
244
+ databaseNotifications(store) { this._databaseNotifications = store; return this; }
245
+ getDatabaseNotifications() { return this._databaseNotifications; }
246
+ /** Polling interval (`30s` by default); `null` disables polling. */
247
+ databaseNotificationsPolling(interval) { this._databaseNotificationsPolling = interval; return this; }
248
+ getDatabaseNotificationsPollingInterval() { return this._databaseNotificationsPolling; }
249
+ // -------------------------------------------------------------- layout: topbar, navigation, sidebar
250
+ _topbar = true;
251
+ _topNavigation = false;
252
+ _navigation = true;
253
+ _sidebarCollapsibleOnDesktop = false;
254
+ _sidebarFullyCollapsibleOnDesktop = false;
255
+ _positions = { userMenu: null, globalSearch: null, databaseNotifications: null };
256
+ /** Topbar, shown by default. `topbar(false)` moves the logo, search, bell and user menu into the sidebar. */
257
+ topbar(condition = true) { this._topbar = condition; return this; }
258
+ hasTopbar() { return this._topbar; }
259
+ /** Navigation in the topbar (one dropdown per group) instead of the sidebar. */
260
+ topNavigation(condition = true) { this._topNavigation = condition; return this; }
261
+ hasTopNavigation() { return this._topNavigation; }
262
+ /** `navigation(false)` removes all navigation (no sidebar, no menus). */
263
+ navigation(condition = true) { this._navigation = condition; return this; }
264
+ hasNavigation() { return this._navigation; }
265
+ /** On desktop, the sidebar can collapse to a column of icons. */
266
+ sidebarCollapsibleOnDesktop(condition = true) { this._sidebarCollapsibleOnDesktop = condition; return this; }
267
+ isSidebarCollapsibleOnDesktop() { return this._sidebarCollapsibleOnDesktop; }
268
+ /** On desktop, the sidebar can be fully hidden. */
269
+ sidebarFullyCollapsibleOnDesktop(condition = true) { this._sidebarFullyCollapsibleOnDesktop = condition; return this; }
270
+ isSidebarFullyCollapsibleOnDesktop() { return this._sidebarFullyCollapsibleOnDesktop; }
271
+ /** Where the user menu goes; defaults to the topbar when there is one. */
272
+ userMenuPosition(position) { this._positions.userMenu = position; return this; }
273
+ getUserMenuPosition() { return this._positions.userMenu ?? (this._topbar ? 'topbar' : 'sidebar'); }
274
+ globalSearchPosition(position) { this._positions.globalSearch = position; return this; }
275
+ getGlobalSearchPosition() { return this._positions.globalSearch ?? (this._topbar ? 'topbar' : 'sidebar'); }
276
+ databaseNotificationsPosition(position) { this._positions.databaseNotifications = position; return this; }
277
+ getDatabaseNotificationsPosition() { return this._positions.databaseNotifications ?? (this._topbar ? 'topbar' : 'sidebar'); }
278
+ _userMenuItems = {};
279
+ /** Items added to the user menu (URL or modal actions). A negative `sort()` places them above the theme switcher. Keys `profile` and `logout` replace the defaults. */
280
+ userMenuItems(items) {
281
+ for (const [key, item] of Array.isArray(items) ? items.map((item) => [item.getName(), item]) : Object.entries(items))
282
+ this._userMenuItems[key] = item;
283
+ return this;
284
+ }
285
+ getUserMenuItems() { return this._userMenuItems; }
286
+ // -------------------------------------------------------------- registration, password reset, email verification, profile
287
+ _registration = null;
288
+ _passwordReset = null;
289
+ _emailVerification = null;
290
+ _profile = null;
291
+ _mailer = consoleMailer;
292
+ /** Registration page (`/register`). The auth provider must implement `create` and `findByEmail`. */
293
+ registration(page = Register) { this._registration = page; return this; }
294
+ hasRegistration() { return this._registration !== null; }
295
+ getRegistrationPage() { return this._registration; }
296
+ /** Password reset: request page (`/password-reset/request`), then reset through a signed link sent by email. */
297
+ passwordReset(request = RequestPasswordReset, reset = ResetPassword) { this._passwordReset = { request, reset }; return this; }
298
+ hasPasswordReset() { return this._passwordReset !== null; }
299
+ getPasswordResetPages() { return this._passwordReset; }
300
+ /** Email verification: a signed link is sent on registration. Until it is followed, only the verification prompt is accessible (`isRequired`). */
301
+ emailVerification(prompt = EmailVerificationPrompt, isRequired = true) { this._emailVerification = { prompt, isRequired }; return this; }
302
+ hasEmailVerification() { return this._emailVerification !== null; }
303
+ isEmailVerificationRequired() { return this._emailVerification?.isRequired === true; }
304
+ getEmailVerificationPrompt() { return this._emailVerification?.prompt ?? null; }
305
+ /** Profile page (`/profile`): name, email, password. `isSimple`: simple layout (default) or full panel layout. */
306
+ profile(page = EditProfile, isSimple = true) { this._profile = { page, isSimple }; return this; }
307
+ hasProfile() { return this._profile !== null; }
308
+ getProfile() { return this._profile; }
309
+ /** Mailer for password reset and verification emails. Defaults to the console. */
310
+ mailer(mailer) { this._mailer = mailer; return this; }
311
+ async sendMail(message) { await this._mailer.send(message); }
312
+ getAuthKey() { return this._key; }
313
+ getRegistrationUrl() { return this.getBaseUrl('register'); }
314
+ getRequestPasswordResetUrl() { return this.getBaseUrl('password-reset/request'); }
315
+ getResetPasswordUrl(user, fingerprint) {
316
+ const token = signToken(this._key, `${this._id}.password-reset`, { uid: user.id, fp: fingerprint }, 60 * 60);
317
+ return `${this.getBaseUrl('password-reset/reset')}?email=${encodeURIComponent(user.email)}&token=${encodeURIComponent(token)}`;
318
+ }
319
+ /** Payload of a password reset token valid for this panel, otherwise `null`. */
320
+ readResetPasswordToken(token) { return readToken(this._key, `${this._id}.password-reset`, token); }
321
+ getVerifyEmailUrl(user) {
322
+ const expires = Math.floor(Date.now() / 1000) + 60 * 60;
323
+ const { hash, signature } = signVerificationUrl(`${this._key}.${this._id}`, user.id, user.email, expires);
324
+ return `${this.getBaseUrl(`email-verification/verify/${encodeURIComponent(String(user.id))}/${hash}`)}?expires=${expires}&signature=${encodeURIComponent(signature)}`;
325
+ }
326
+ checkVerifyEmailUrl(id, hash, expires, signature) { return checkVerificationUrl(`${this._key}.${this._id}`, id, hash, expires, signature); }
327
+ getEmailVerificationPromptUrl() { return this.getBaseUrl('email-verification/prompt'); }
328
+ getProfileUrl() { return this._profile ? this.getBaseUrl('profile') : null; }
329
+ /** Whether the user must still verify their email before using the panel. */
330
+ async mustVerifyEmail(user) {
331
+ if (!user || !this.isEmailVerificationRequired())
332
+ return false;
333
+ const auth = this.getAuth();
334
+ return auth.hasVerifiedEmail ? !(await auth.hasVerifiedEmail(user)) : false;
335
+ }
336
+ // -------------------------------------------------------------- multi-tenancy
337
+ _tenant = null;
338
+ _tenantOwnershipColumn = null;
339
+ _tenantRegistration = null;
340
+ _tenantProfile = null;
341
+ _tenantMenu = true;
342
+ _tenantMenuItems = {};
343
+ _tenantScope = new AsyncLocalStorage();
344
+ /**
345
+ * Makes the panel multi-tenant. `ownershipColumn` is the column linking a record to its tenant (`team_id`);
346
+ * the panel's resources then only read and write the current tenant's records (see `Resource.isScopedToTenant`).
347
+ */
348
+ tenant(provider, options = {}) { this._tenant = provider; this._tenantOwnershipColumn = options.ownershipColumn ?? null; return this; }
349
+ hasTenancy() { return this._tenant !== null; }
350
+ getTenantProvider() { if (!this._tenant)
351
+ throw new Error('Panel has no tenancy: call panel.tenant(...).'); return this._tenant; }
352
+ getTenantOwnershipColumn() { return this._tenantOwnershipColumn; }
353
+ /** Tenant registration page (`/new`). */
354
+ tenantRegistration(page) { this._tenantRegistration = page; return this; }
355
+ getTenantRegistrationPage() { return this._tenantRegistration; }
356
+ /** Tenant profile page (`/<tenant>/profile`). */
357
+ tenantProfile(page) { this._tenantProfile = page; return this; }
358
+ getTenantProfilePage() { return this._tenantProfile; }
359
+ tenantMenu(condition = true) { this._tenantMenu = condition; return this; }
360
+ hasTenantMenu() { return this._tenantMenu; }
361
+ tenantMenuItems(items) {
362
+ for (const [key, item] of Array.isArray(items) ? items.map((item) => [item.getName(), item]) : Object.entries(items))
363
+ this._tenantMenuItems[key] = item;
364
+ return this;
365
+ }
366
+ getTenantMenuItems() { return this._tenantMenuItems; }
367
+ /** Runs `fn` for a tenant: generated URLs (`getUrl`) and resource queries are scoped to it. */
368
+ withTenant(tenant, fn) { return this._tenantScope.run({ tenant }, fn); }
369
+ /** Tenant of the current request, or `null`. */
370
+ getTenant() { return (this._tenantScope.getStore()?.tenant ?? null); }
371
+ getTenantUrlKey(tenant) { return this.getTenantProvider().getKey(tenant); }
372
+ _subNavigationPosition = 'start';
373
+ /** Default position of page sub-navigation (`start`). */
374
+ subNavigationPosition(position) { this._subNavigationPosition = position; return this; }
375
+ getSubNavigationPosition() { return this._subNavigationPosition; }
376
+ _appUrl = null;
377
+ /** Public URL of the application (`https://example.com`), used in emailed links. Without it the request origin is used, which should be avoided in production (the `Host` header can be forged). */
378
+ appUrl(url) { this._appUrl = url ? url.replace(/\/$/, '') : null; return this; }
379
+ getAppUrl() { return this._appUrl; }
380
+ getMaxContentWidth() { return this._maxContentWidth; }
381
+ /** URL inside the panel; in a multi-tenant panel, prefixed with the current (or given) tenant. */
382
+ getUrl(sub = '', tenant = this.getTenant()) {
383
+ if (this._tenant && tenant)
384
+ return '/' + [this._path, encodeURIComponent(this.getTenantUrlKey(tenant)), sub.replace(/^\/|\/$/g, '')].filter(Boolean).join('/');
385
+ return this.getBaseUrl(sub);
386
+ }
387
+ /** URL outside any tenant (login, registration, profile, tenant registration). */
388
+ getBaseUrl(sub = '') { return '/' + [this._path, sub.replace(/^\/|\/$/g, '')].filter(Boolean).join('/'); }
389
+ getLoginUrl() { return this.getBaseUrl('login'); }
390
+ getLogoutUrl() { return this.getBaseUrl('logout'); }
391
+ /** Prefix of this panel's Livewire endpoint (`/livewire` or `/<path>/livewire`): each panel has its own. */
392
+ getLivewirePrefix() { return this._path ? `/${this._path}/livewire` : '/livewire'; }
393
+ assetUrl(file) { return `/${file}?v=${ASSET_VERSION}`; }
394
+ getBrand() { return { name: this._brandName, logo: this._brandLogo, darkLogo: this._darkModeBrandLogo, height: this._brandLogoHeight }; }
395
+ getFont() { return this._font; }
396
+ /** Default avatar: the name's initials from the external ui-avatars.com service. */
397
+ getUserAvatarUrl(user) { return this.getAvatarUrlFor(user.name); }
398
+ /** Tenant avatar: from the tenant provider (`getAvatarUrl`), otherwise its initials. */
399
+ getTenantAvatarUrl(tenant) { return this.getTenantProvider().getAvatarUrl?.(tenant) ?? this.getAvatarUrlFor(this.getTenantProvider().getName(tenant)); }
400
+ _spa = false;
401
+ /** SPA mode: navigate between panel pages without full reloads (`wire:navigate`). */
402
+ spa(condition = true) { this._spa = condition; return this; }
403
+ hasSpaMode() { return this._spa; }
404
+ getAvatarUrlFor(name) {
405
+ const initials = name.trim().split(' ').map((s) => s.replace(/^[^\p{L}\p{N}]+/u, '').slice(0, 1)).join(' ');
406
+ const background = '#' + convertToRgb(this.colors.getColor('gray')[950]).map((n) => n.toString(16).padStart(2, '0')).join('');
407
+ return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials).replace(/%20/g, '+')}&format=svg&color=FFFFFF&background=${encodeURIComponent(background)}`;
408
+ }
409
+ /** Navigation groups: registered pages plus custom items, sorted, grouped in the declared order. */
410
+ /** Computed navigation badges: page URL → value. */
411
+ async resolveNavigationBadges() {
412
+ const badges = new Map();
413
+ for (const page of this._pages)
414
+ if (page.shouldRegisterNavigation && typeof page.getNavigationBadge === 'function')
415
+ badges.set(page.slug, await page.getNavigationBadge());
416
+ return badges;
417
+ }
418
+ /** URLs of menu pages this user cannot open. */
419
+ async resolveInaccessiblePages(user) {
420
+ const hidden = new Set();
421
+ for (const page of this._pages)
422
+ if (page.shouldRegisterNavigation && !(await this.canAccessPage(page, user)))
423
+ hidden.add(page.slug);
424
+ return hidden;
425
+ }
426
+ getNavigation(path, badges = new Map(), hidden = new Set()) {
427
+ const items = [
428
+ ...this._pages.filter((p) => p.shouldRegisterNavigation && !hidden.has(p.slug)).map((p) => {
429
+ 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);
430
+ const base = this.getUrl(p.slug);
431
+ return p.resource ? item.isActiveWhen((current) => current === base || current.startsWith(base + '/')) : item;
432
+ }),
433
+ ...this._navigationItems,
434
+ ].sort((a, b) => a._sort - b._sort);
435
+ const order = [null, ...this._navigationGroups, ...items.map((i) => i._group).filter((g) => g !== null && !this._navigationGroups.includes(g))];
436
+ return [...new Set(order)].map((label) => {
437
+ const own = items.filter((i) => i._group === label).map((i) => ({ label: i.label, url: i._url, icon: i._icon, isActive: i.isActive(path), badge: i._badge, badgeColorClasses: this.colors.getComponentClasses('badge', i._badgeColor) }));
438
+ 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 };
439
+ }).filter((g) => g.items.length);
440
+ }
441
+ // --- session: an HMAC-signed cookie holding the user id
442
+ sign(value) { return createHmac('sha256', this._key).update(value).digest('base64url'); }
443
+ logIn(res, user, remember) {
444
+ const value = Buffer.from(JSON.stringify({ uid: user.id })).toString('base64url');
445
+ 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 } : {}) });
446
+ }
447
+ logOut(res) { res.clearCookie(`navette_${this._id}_session`); }
448
+ async user(req) {
449
+ const raw = (req.headers.cookie ?? '').split(';').map((c) => c.trim()).find((c) => c.startsWith(`navette_${this._id}_session=`))?.split('=')[1];
450
+ if (!raw)
451
+ return null;
452
+ const [value, mac] = raw.split('.');
453
+ const a = Buffer.from(mac ?? ''), b = Buffer.from(this.sign(value));
454
+ if (a.length !== b.length || !timingSafeEqual(a, b))
455
+ return null;
456
+ try {
457
+ return await this.getAuth().find(JSON.parse(Buffer.from(value, 'base64url').toString()).uid);
458
+ }
459
+ catch {
460
+ return null;
461
+ }
462
+ }
463
+ }
464
+ /**
465
+ * Render hook locations of a panel. A few are declared but not rendered anywhere yet: see `UNPLACED_RENDER_HOOKS`.
466
+ */
467
+ export const PanelsRenderHook = {
468
+ AUTH_LOGIN_FORM_AFTER: 'panels::auth.login.form.after',
469
+ AUTH_LOGIN_FORM_BEFORE: 'panels::auth.login.form.before',
470
+ AUTH_PASSWORD_RESET_REQUEST_FORM_AFTER: 'panels::auth.password-reset.request.form.after',
471
+ AUTH_PASSWORD_RESET_REQUEST_FORM_BEFORE: 'panels::auth.password-reset.request.form.before',
472
+ AUTH_PASSWORD_RESET_RESET_FORM_AFTER: 'panels::auth.password-reset.reset.form.after',
473
+ AUTH_PASSWORD_RESET_RESET_FORM_BEFORE: 'panels::auth.password-reset.reset.form.before',
474
+ AUTH_REGISTER_FORM_AFTER: 'panels::auth.register.form.after',
475
+ AUTH_REGISTER_FORM_BEFORE: 'panels::auth.register.form.before',
476
+ BODY_END: 'panels::body.end',
477
+ BODY_START: 'panels::body.start',
478
+ CONTENT_AFTER: 'panels::content.after',
479
+ CONTENT_BEFORE: 'panels::content.before',
480
+ CONTENT_END: 'panels::content.end',
481
+ CONTENT_START: 'panels::content.start',
482
+ FOOTER: 'panels::footer',
483
+ GLOBAL_SEARCH_AFTER: 'panels::global-search.after',
484
+ GLOBAL_SEARCH_BEFORE: 'panels::global-search.before',
485
+ GLOBAL_SEARCH_END: 'panels::global-search.end',
486
+ GLOBAL_SEARCH_START: 'panels::global-search.start',
487
+ HEAD_END: 'panels::head.end',
488
+ HEAD_START: 'panels::head.start',
489
+ LAYOUT_END: 'panels::layout.end',
490
+ LAYOUT_START: 'panels::layout.start',
491
+ PAGE_END: 'panels::page.end',
492
+ PAGE_FOOTER_WIDGETS_AFTER: 'panels::page.footer-widgets.after',
493
+ PAGE_FOOTER_WIDGETS_BEFORE: 'panels::page.footer-widgets.before',
494
+ PAGE_FOOTER_WIDGETS_END: 'panels::page.footer-widgets.end',
495
+ PAGE_FOOTER_WIDGETS_START: 'panels::page.footer-widgets.start',
496
+ PAGE_HEADER_ACTIONS_AFTER: 'panels::page.header.actions.after',
497
+ PAGE_HEADER_ACTIONS_BEFORE: 'panels::page.header.actions.before',
498
+ PAGE_HEADER_HEADING_AFTER: 'panels::page.header.heading.after',
499
+ PAGE_HEADER_HEADING_BEFORE: 'panels::page.header.heading.before',
500
+ PAGE_HEADER_WIDGETS_AFTER: 'panels::page.header-widgets.after',
501
+ PAGE_HEADER_WIDGETS_BEFORE: 'panels::page.header-widgets.before',
502
+ PAGE_HEADER_WIDGETS_END: 'panels::page.header-widgets.end',
503
+ PAGE_HEADER_WIDGETS_START: 'panels::page.header-widgets.start',
504
+ PAGE_START: 'panels::page.start',
505
+ /** The hook name says "module-menu", not "mobile-menu": kept for compatibility. */
506
+ PAGE_SUB_NAVIGATION_MOBILE_MENU_AFTER: 'panels::page.sub-navigation.module-menu.after',
507
+ PAGE_SUB_NAVIGATION_MOBILE_MENU_BEFORE: 'panels::page.sub-navigation.module-menu.before',
508
+ PAGE_SUB_NAVIGATION_SIDEBAR_AFTER: 'panels::page.sub-navigation.sidebar.after',
509
+ PAGE_SUB_NAVIGATION_SIDEBAR_BEFORE: 'panels::page.sub-navigation.sidebar.before',
510
+ PAGE_SUB_NAVIGATION_START_AFTER: 'panels::page.sub-navigation.start.after',
511
+ PAGE_SUB_NAVIGATION_START_BEFORE: 'panels::page.sub-navigation.start.before',
512
+ PAGE_SUB_NAVIGATION_TOP_AFTER: 'panels::page.sub-navigation.top.after',
513
+ PAGE_SUB_NAVIGATION_TOP_BEFORE: 'panels::page.sub-navigation.top.before',
514
+ PAGE_SUB_NAVIGATION_END_AFTER: 'panels::page.sub-navigation.end.after',
515
+ PAGE_SUB_NAVIGATION_END_BEFORE: 'panels::page.sub-navigation.end.before',
516
+ RESOURCE_PAGES_LIST_RECORDS_TABLE_AFTER: 'panels::resource.pages.list-records.table.after',
517
+ RESOURCE_PAGES_LIST_RECORDS_TABLE_BEFORE: 'panels::resource.pages.list-records.table.before',
518
+ RESOURCE_PAGES_LIST_RECORDS_TABS_END: 'panels::resource.pages.list-records.tabs.end',
519
+ RESOURCE_PAGES_LIST_RECORDS_TABS_START: 'panels::resource.pages.list-records.tabs.start',
520
+ RESOURCE_PAGES_MANAGE_RELATED_RECORDS_TABLE_AFTER: 'panels::resource.pages.manage-related-records.table.after',
521
+ RESOURCE_PAGES_MANAGE_RELATED_RECORDS_TABLE_BEFORE: 'panels::resource.pages.manage-related-records.table.before',
522
+ RESOURCE_RELATION_MANAGER_AFTER: 'panels::resource.relation-manager.after',
523
+ RESOURCE_RELATION_MANAGER_BEFORE: 'panels::resource.relation-manager.before',
524
+ RESOURCE_TABS_END: 'panels::resource.tabs.end',
525
+ RESOURCE_TABS_START: 'panels::resource.tabs.start',
526
+ SCRIPTS_AFTER: 'panels::scripts.after',
527
+ SCRIPTS_BEFORE: 'panels::scripts.before',
528
+ SIDEBAR_FOOTER: 'panels::sidebar.footer',
529
+ SIDEBAR_LOGO_AFTER: 'panels::sidebar.logo.after',
530
+ SIDEBAR_LOGO_BEFORE: 'panels::sidebar.logo.before',
531
+ SIDEBAR_START: 'panels::sidebar.start',
532
+ SIDEBAR_NAV_END: 'panels::sidebar.nav.end',
533
+ SIDEBAR_NAV_START: 'panels::sidebar.nav.start',
534
+ SIMPLE_LAYOUT_END: 'panels::simple-layout.end',
535
+ SIMPLE_LAYOUT_START: 'panels::simple-layout.start',
536
+ SIMPLE_PAGE_END: 'panels::simple-page.end',
537
+ SIMPLE_PAGE_START: 'panels::simple-page.start',
538
+ STYLES_AFTER: 'panels::styles.after',
539
+ STYLES_BEFORE: 'panels::styles.before',
540
+ TENANT_MENU_AFTER: 'panels::tenant-menu.after',
541
+ TENANT_MENU_BEFORE: 'panels::tenant-menu.before',
542
+ TOPBAR_AFTER: 'panels::topbar.after',
543
+ TOPBAR_BEFORE: 'panels::topbar.before',
544
+ TOPBAR_END: 'panels::topbar.end',
545
+ TOPBAR_LOGO_AFTER: 'panels::topbar.logo.after',
546
+ TOPBAR_LOGO_BEFORE: 'panels::topbar.logo.before',
547
+ TOPBAR_START: 'panels::topbar.start',
548
+ USER_MENU_AFTER: 'panels::user-menu.after',
549
+ USER_MENU_BEFORE: 'panels::user-menu.before',
550
+ USER_MENU_PROFILE_AFTER: 'panels::user-menu.profile.after',
551
+ USER_MENU_PROFILE_BEFORE: 'panels::user-menu.profile.before',
552
+ };
553
+ /** Widget render hook locations, rendered by `TableWidget`. */
554
+ export const WidgetsRenderHook = {
555
+ TABLE_WIDGET_END: 'widgets::table-widget.end',
556
+ TABLE_WIDGET_START: 'widgets::table-widget.start',
557
+ };
558
+ /**
559
+ * Hook locations that are declared but not rendered anywhere yet, with the reason.
560
+ */
561
+ export const UNPLACED_RENDER_HOOKS = {
562
+ ...Object.fromEntries(['RESOURCE_PAGES_MANAGE_RELATED_RECORDS_TABLE_AFTER', 'RESOURCE_PAGES_MANAGE_RELATED_RECORDS_TABLE_BEFORE'].map((k) => [PanelsRenderHook[k], 'ManageRelatedRecords page not implemented'])),
563
+ ...Object.fromEntries(['RESOURCE_PAGES_LIST_RECORDS_TABS_END', 'RESOURCE_PAGES_LIST_RECORDS_TABS_START', 'RESOURCE_TABS_END', 'RESOURCE_TABS_START'].map((k) => [PanelsRenderHook[k], 'reserved, no view renders it'])),
564
+ };
565
+ /** Alpine scope of a schema component without a key (anonymous grid cell). */
566
+ const ANONYMOUS_SCHEMA_COMPONENT = `x-data="filamentSchemaComponent(${e(js({ key: null, exposedMethods: [], path: '', containerPath: '' }))})"`;
567
+ /** Common base: a stateful component hosting schemas (`<name>Schema(schema)`). */
568
+ class BasePage extends Component {
569
+ get panel() { return this._ctx.panel; }
570
+ getSchema(name) {
571
+ const schema = Schema.make(this).key(name);
572
+ if (name === 'form')
573
+ schema.statePath('data');
574
+ return this[`${name}Schema`](schema);
575
+ }
576
+ contentSchema(schema) { return schema.components([]); }
577
+ /** Render hook scopes of this page: its class (and its resource, for a resource page). */
578
+ getRenderHookScopes() {
579
+ const resource = this.constructor.resource;
580
+ return resource ? [this.constructor, resource] : [this.constructor];
581
+ }
582
+ }
583
+ /** A panel page, in the full layout (sidebar, header). */
584
+ export class Page extends BasePage {
585
+ /** Methods the browser may call. A subclass adds its own: `static callable = ['approve']`. */
586
+ static callable = ['mountAction', 'unmountAction', 'callMountedAction', 'callSchemaComponentMethod'];
587
+ static slug = '';
588
+ static title = null;
589
+ static navigationLabel = null;
590
+ static navigationIcon = null;
591
+ static navigationGroup = null;
592
+ static navigationSort = 0;
593
+ static navigationBadge = null;
594
+ static navigationBadgeColor = 'primary';
595
+ static shouldRegisterNavigation = true;
596
+ /** Restricts the page to some users: returning `false` hides it from the menu and denies it (403). */
597
+ static canAccess(_user) { return true; }
598
+ static getTitle() { return this.title ?? this.name.replace(/([a-z])([A-Z])/g, '$1 $2'); }
599
+ static getNavigationLabel() { return this.navigationLabel ?? this.getTitle(); }
600
+ /** Every request, including browser updates, requires an authenticated user: a snapshot kept after logout is not enough. */
601
+ async boot() {
602
+ const ctx = this._ctx;
603
+ if (!ctx?.user)
604
+ throw new WireError(401, 'Unauthenticated.');
605
+ // In a multi-tenant panel, every page belongs to a tenant the user is a member of (checked by the router).
606
+ if (ctx.panel.hasTenancy() && !ctx.tenant)
607
+ throw new WireError(404, 'Tenant not found.');
608
+ if (await ctx.panel.mustVerifyEmail(ctx.user))
609
+ throw new WireError(403, 'Email address not verified.');
610
+ // Checked on every request, not only on display: a forged request to a forbidden page is denied.
611
+ if (!(await this.isAccessAuthorized(ctx)))
612
+ throw new WireError(403, 'Forbidden.');
613
+ }
614
+ isAccessAuthorized(ctx) { return ctx.panel.canAccessPage(this.constructor, ctx.user); }
615
+ getTitle() { return this.constructor.getTitle(); }
616
+ getHeading() { return this.getTitle(); }
617
+ getBreadcrumbs() { return []; }
618
+ getHeaderActions() { return []; }
619
+ getPageClasses() { return []; }
620
+ /** Page sub-navigation; empty by default. */
621
+ getSubNavigation() { return []; }
622
+ /** Sub-navigation position: `start` (column before the content), `end` (after) or `top` (tabs). Defaults to the resource's, then the panel's. */
623
+ static subNavigationPosition = null;
624
+ getSubNavigationPosition() {
625
+ const own = this.constructor.subNavigationPosition;
626
+ const resource = this.constructor.resource ?? this.constructor.parentResource;
627
+ return own ?? resource?.subNavigationPosition ?? this.panel.getSubNavigationPosition();
628
+ }
629
+ /** Widgets shown below the page content. */
630
+ getFooterWidgets() { return []; }
631
+ getFooterWidgetsColumns() { return 2; }
632
+ /** Footer widget grid, in the page's `footerWidgets` schema. */
633
+ async renderFooterWidgets() {
634
+ const widgets = await this.filterVisibleWidgets(this.getFooterWidgets());
635
+ if (!widgets.length)
636
+ return '';
637
+ const liberate = (hook) => `<div class="fi-sc-liberated" wire:key="" ${ANONYMOUS_SCHEMA_COMPONENT}>${renderHook(hook, this.getRenderHookScopes())}</div>`;
638
+ return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'footerWidgets' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${liberate(PanelsRenderHook.PAGE_FOOTER_WIDGETS_START)}${await this.renderWidgetsGrid(widgets, this.getFooterWidgetsColumns(), 'footerWidgets')}${liberate(PanelsRenderHook.PAGE_FOOTER_WIDGETS_END)}</div>`;
639
+ }
640
+ /** Widgets shown above the page content, with the data passed to each (widget properties). */
641
+ getHeaderWidgets() { return []; }
642
+ getHeaderWidgetsColumns() { return 2; }
643
+ getWidgetData() { return {}; }
644
+ /** Header widget grid, in the page's `headerWidgets` schema. */
645
+ async renderHeaderWidgets() {
646
+ const widgets = await this.filterVisibleWidgets(this.getHeaderWidgets());
647
+ if (!widgets.length)
648
+ return '';
649
+ const liberate = (hook) => `<div class="fi-sc-liberated" wire:key="" ${ANONYMOUS_SCHEMA_COMPONENT}>${renderHook(hook, this.getRenderHookScopes())}</div>`;
650
+ return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'headerWidgets' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${liberate(PanelsRenderHook.PAGE_HEADER_WIDGETS_START)}${await this.renderWidgetsGrid(widgets, this.getHeaderWidgetsColumns(), 'headerWidgets')}${liberate(PanelsRenderHook.PAGE_HEADER_WIDGETS_END)}</div>`;
651
+ }
652
+ /** Widgets the current user is allowed to see. */
653
+ async filterVisibleWidgets(candidates) {
654
+ const viewer = this._ctx.user;
655
+ const widgets = [];
656
+ for (const widget of candidates)
657
+ if (await this.panel.authorize(viewer, 'view', { widget, rule: () => widget.canView(viewer) }))
658
+ widgets.push(widget);
659
+ return widgets;
660
+ }
661
+ /** Widget grid of a schema: each widget is a child component, lazy-loaded by default. */
662
+ async renderWidgetsGrid(widgets, columns, schemaKey) {
663
+ const items = [];
664
+ for (const [index, widget] of widgets.entries()) {
665
+ const componentName = this._runtime.nameOf(widget);
666
+ const key = `${schemaKey}.${componentName}-${index}`;
667
+ const child = await this.mountChild(componentName, this.getWidgetData(), `${this.id}.${key}`, { lazy: widget.isLazy });
668
+ items.push(`<div class="fi-sc-liberated" wire:key="${e(`${this.id}.${key}`)}" x-data="filamentSchemaComponent(${e(js({ key, exposedMethods: [], path: '', containerPath: '' }))})">${child}</div>`);
669
+ }
670
+ const grid = new Attributes().grid(typeof columns === 'number' ? { lg: columns } : columns).class(['fi-sc', 'fi-sc-has-gap']);
671
+ return `<div ${ANONYMOUS_SCHEMA_COMPONENT} class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component"><div><div ${grid.toHtml()}>${items.join('')}</div></div></div></div>`;
672
+ }
673
+ /** Page content: its `content` schema by default. */
674
+ async renderContent() { return (await this.getSchema('content')).toHtml(); }
675
+ // --- mounted actions: the stack lives in the component state, modals are rendered server-side
676
+ mountedActions = [];
677
+ getActionMountContext() { return {}; }
678
+ getActionRecord() { return null; }
679
+ /** Record targeted by an action mounted in a given context (table row…). */
680
+ async resolveActionRecord(_context) { return this.getActionRecord(); }
681
+ /** Actions that can be mounted in a given context (page header by default). */
682
+ getMountableActions(context) {
683
+ return typeof context.schemaComponent === 'string' ? this.getSchemaComponentActions(context.schemaComponent) : this.getHeaderActions();
684
+ }
685
+ /**
686
+ * Actions owned by a schema component (Repeater: add, delete, move; Section: header actions).
687
+ * The key comes from the browser: it must match a real component of the schema, or the schema itself.
688
+ */
689
+ async getSchemaComponentActions(key) {
690
+ const name = key.split('.')[0];
691
+ // The component may belong to the form of an already mounted action (rich editor in a modal).
692
+ const mountedIndex = /^mountedActionSchema(\d+)$/.exec(name);
693
+ if (!mountedIndex && (!/^[A-Za-z]+$/.test(name) || typeof this[`${name}Schema`] !== 'function'))
694
+ return [];
695
+ const schema = mountedIndex ? await this.getMountedActionSchema(Number(mountedIndex[1])) : this.getSchema(name);
696
+ if (!schema)
697
+ return [];
698
+ // Only visible components expose their actions: a hidden component (section, callout…) mounts none.
699
+ const components = (await schema.getVisibleFlatComponents());
700
+ const exact = components.find((c) => c.getKey() === key && typeof c.getActions === 'function');
701
+ if (exact)
702
+ return exact.getActions();
703
+ return key === schema.getKey() ? components.filter((c) => !c.hasStatePath() && typeof c.getActions === 'function').flatMap((c) => c.getActions()) : [];
704
+ }
705
+ /** Context kept for a mounted action: the server's, never the browser's as-is. */
706
+ resolveMountContext(clientContext) {
707
+ const component = clientContext?.schemaComponent;
708
+ return typeof component === 'string' && component.length <= 200 ? { ...this.getActionMountContext(), schemaComponent: component } : this.getActionMountContext();
709
+ }
710
+ async findMountableAction(name, context = this.getActionMountContext()) {
711
+ const action = (await this.getMountableActions(context)).find((a) => a.getName() === name && a.isMountable());
712
+ if (!action)
713
+ throw new WireError(404, `Action not found: ${String(name)}`);
714
+ return action;
715
+ }
716
+ async actionContext(mounted) { return { livewire: this, record: await this.resolveActionRecord(mounted.context), arguments: mounted.arguments, data: emptyToNull(mounted.data ?? {}) }; }
717
+ /** Form schema of a mounted action: key `mountedActionSchema<n>`, state under `mountedActions.<n>.data`. */
718
+ async getMountedActionSchema(index) {
719
+ const mounted = this.mountedActions[index];
720
+ if (!mounted)
721
+ return null;
722
+ const action = await this.findMountableAction(mounted.name, mounted.context);
723
+ return action.applySchema(Schema.make(this).key(`mountedActionSchema${index}`).statePath(`mountedActions.${index}.data`).operation(mounted.name).record(await this.resolveActionRecord(mounted.context)));
724
+ }
725
+ async renderActionModal(index) {
726
+ const mounted = this.mountedActions[index];
727
+ return (await this.findMountableAction(mounted.name, mounted.context)).toModalHtml(await this.actionContext(mounted), index, (await (await this.getMountedActionSchema(index))?.toHtml()) ?? null);
728
+ }
729
+ /** Cancels a partial modal render prepared by a live field when the same request then runs an action. */
730
+ cancelModalPartialRender() {
731
+ for (const name of Object.keys(this._effects.partials ?? {}))
732
+ if (name.startsWith('action-modals.')) {
733
+ delete this._effects.partials[name];
734
+ this._skipRender = false;
735
+ }
736
+ }
737
+ /** Live field in a mounted action's form: runs `afterStateUpdated`, then only returns the affected modal. */
738
+ async updatedMountedActions(_value, key) {
739
+ const m = /^(\d+)\.data\.(.+)$/.exec(key ?? '');
740
+ if (!m)
741
+ return;
742
+ const index = Number(m[1]);
743
+ const schema = await this.getMountedActionSchema(index);
744
+ const field = schema?.getFlatComponents().find((c) => c instanceof Field && c.getStatePath() === `mountedActions.${index}.data.${m[2]}`);
745
+ if (!field?.hasAfterStateUpdated())
746
+ return;
747
+ const data = (this.mountedActions[index].data ??= {});
748
+ await field.callAfterStateUpdated((name, value) => { data[name] = value; });
749
+ this.renderPartial(`action-modals.${index}`, await this.renderActionModal(index));
750
+ }
751
+ /** Calls a method exposed by a field, from that field's JavaScript (e.g. Select search). Does not re-render. */
752
+ async callSchemaComponentMethod(componentKey, method, args = []) {
753
+ if (typeof componentKey !== 'string' || typeof method !== 'string') {
754
+ this.skipRender();
755
+ return null;
756
+ }
757
+ const name = componentKey.split('.')[0];
758
+ const mountedIndex = /^mountedActionSchema(\d+)$/.exec(name);
759
+ const schema = mountedIndex ? await this.getMountedActionSchema(Number(mountedIndex[1])) : typeof this[`${name}Schema`] === 'function' ? await this.getSchema(name) : null;
760
+ // A hidden component exposes no method, even if the browser forges the call.
761
+ const component = schema ? (await schema.getVisibleFlatComponents()).find((c) => c.getKey() === componentKey) : undefined;
762
+ // Only methods the component declares as exposed can be called from the browser.
763
+ if (!component || !component.getExposedMethods().includes(method) || typeof component[method] !== 'function') {
764
+ this.skipRender();
765
+ return null;
766
+ }
767
+ if (component.isRenderlessMethod(method))
768
+ this.skipRender();
769
+ // Named arguments (an object), as sent by the front-end JavaScript: passed in key order.
770
+ return component[method](...(Array.isArray(args) ? args : args && typeof args === 'object' ? Object.values(args) : []));
771
+ }
772
+ /** A hidden action can be neither mounted nor run, even through a call forged by the browser. */
773
+ async assertActionVisible(action, context = {}) {
774
+ const record = await this.resolveActionRecord(context);
775
+ if (context.recordKey !== undefined && record === null)
776
+ throw new WireError(404, 'Record not found.');
777
+ const ctx = { livewire: this, component: null, operation: null, record, get: () => undefined, state: undefined };
778
+ if (!(await action.isVisible(ctx)) || (await action.isDisabled(ctx)))
779
+ throw new WireError(403, `Action unavailable: ${action.getName()}`);
780
+ // An action with a URL for this record is a link: it cannot be mounted.
781
+ if (action.resolveUrl(ctx) !== null)
782
+ throw new WireError(404, `Action not found: ${action.getName()}`);
783
+ }
784
+ syncActionModals() {
785
+ this.dispatch('sync-action-modals', { id: this.id, newActionNestingIndex: this.mountedActions.length ? this.mountedActions.length - 1 : null, shouldOverlayParentActions: false });
786
+ }
787
+ /** False for a table component: its modals are rendered inside the table. */
788
+ rendersActionModalsInPage() { return true; }
789
+ async renderActionModals() {
790
+ const modals = [];
791
+ for (const index of this.mountedActions.keys())
792
+ modals.push(await this.renderActionModal(index));
793
+ return `<div wire:partial="action-modals" x-data="filamentActionModals({
794
+ livewireId: ${js(this.id)},
795
+ })" style="height: 0">${modals.join('')}</div>`;
796
+ }
797
+ /** Mounts an action: without a modal it runs immediately; with one, only the modals fragment is returned. */
798
+ async mountAction(name, args = {}, _context = {}) {
799
+ this.cancelModalPartialRender();
800
+ const context = this.resolveMountContext(_context);
801
+ const action = await this.findMountableAction(name, context);
802
+ await this.assertActionVisible(action, context);
803
+ const mounted = { name: String(name), arguments: args && typeof args === 'object' ? args : {}, context };
804
+ if (!action.hasModal()) {
805
+ await action.call(await this.actionContext(mounted));
806
+ return;
807
+ }
808
+ this.mountedActions.push(mounted);
809
+ const schema = await this.getMountedActionSchema(this.mountedActions.length - 1);
810
+ if (schema) {
811
+ // Initial state: field defaults, then `fillForm()`.
812
+ const data = {};
813
+ mounted.data = data;
814
+ fillSchema(schema, data);
815
+ const fill = await action.getFormFill(await this.actionContext(mounted));
816
+ if (fill)
817
+ fillSchemaFromRecord(schema, data, fill);
818
+ }
819
+ this.renderPartial('action-modals', await this.renderActionModals());
820
+ this.syncActionModals();
821
+ }
822
+ unmountAction(_shouldCancelParentActions = true) {
823
+ this.cancelModalPartialRender();
824
+ this._errors = {};
825
+ this.mountedActions.pop();
826
+ this.syncActionModals();
827
+ }
828
+ async callMountedAction(_args = {}) {
829
+ this.cancelModalPartialRender();
830
+ const mounted = this.mountedActions[this.mountedActions.length - 1];
831
+ if (!mounted)
832
+ return;
833
+ const action = await this.findMountableAction(mounted.name, mounted.context);
834
+ await this.assertActionVisible(action, mounted.context);
835
+ const schema = await this.getMountedActionSchema(this.mountedActions.length - 1);
836
+ if (schema) {
837
+ this._errors = await validateSchema(schema);
838
+ if (Object.keys(this._errors).length) {
839
+ this.dispatch('form-validation-error', { livewireId: this.id });
840
+ return;
841
+ }
842
+ }
843
+ const callContext = await this.actionContext(mounted);
844
+ if (schema)
845
+ callContext.data = await getSchemaState(schema);
846
+ await action.call(callContext);
847
+ // "… & create another": the modal stays open with a reset form.
848
+ if (schema && _args && typeof _args === 'object' && _args.another === true) {
849
+ const data = {};
850
+ mounted.data = data;
851
+ fillSchema(schema, data);
852
+ return;
853
+ }
854
+ this.mountedActions.pop();
855
+ this.syncActionModals();
856
+ }
857
+ async render() {
858
+ const ctx = { livewire: this, component: null, operation: null, record: this.getActionRecord(), get: () => undefined, state: undefined };
859
+ const headerActions = [];
860
+ for (const action of this.getHeaderActions())
861
+ if (await action.isVisible(ctx))
862
+ headerActions.push(await action.toHtml(ctx));
863
+ return views.render('filament-panels::pages.page', {
864
+ heading: this.getHeading(), headerWidgets: await this.renderHeaderWidgets(), content: await this.renderContent(), footerWidgets: await this.renderFooterWidgets(), livewireId: this.id, renderHookScopes: this.getRenderHookScopes(),
865
+ subNavigation: await this.getSubNavigation(), subNavigationPosition: this.getSubNavigationPosition(),
866
+ breadcrumbs: this.getBreadcrumbs(), headerActions, pageClasses: this.getPageClasses(), actionModals: this.rendersActionModalsInPage() ? await this.renderActionModals() : '',
867
+ headerActionsKey: createHash('md5').update(this.getHeaderActions().map((a) => a.getName()).join(',')).digest('hex'),
868
+ });
869
+ }
870
+ }
871
+ /**
872
+ * Dashboard: a widget grid, preceded by a filters form when the page defines `filtersFormSchema(schema)`.
873
+ * The filters are passed to widgets as `pageFilters`.
874
+ */
875
+ export class Dashboard extends Page {
876
+ static slug = '';
877
+ static title = 'Dashboard';
878
+ static navigationIcon = 'o-home';
879
+ static navigationSort = -2;
880
+ /** Filters form state. Stays `null` while the page has no filters. */
881
+ filters = null;
882
+ /** Dashboard widgets. Defaults to the panel's (`panel.widgets([...])`). */
883
+ getWidgets() { return this.panel.getWidgets(); }
884
+ /** Grid columns: a number (at the `lg` breakpoint), or one number per breakpoint. */
885
+ getColumns() { return 2; }
886
+ hasFiltersForm() { return typeof this.filtersFormSchema === 'function'; }
887
+ getSchema(name) {
888
+ if (name !== 'filtersForm')
889
+ return super.getSchema(name);
890
+ // 2 / 3 / 4 column grid, state under `filters`, live fields.
891
+ const schema = this.filtersFormSchema(Schema.make(this).key('filtersForm').columns({ md: 2, xl: 3, '2xl': 4 }).statePath('filters').partialName('table-filters-form'));
892
+ for (const component of schema.getFlatComponents())
893
+ if (component instanceof Field && typeof component.live === 'function')
894
+ component.live();
895
+ return schema;
896
+ }
897
+ mount() {
898
+ if (!this.hasFiltersForm())
899
+ return;
900
+ this.filters = {};
901
+ fillSchema(this.getSchema('filtersForm'), this.filters);
902
+ }
903
+ getWidgetData() { return this.hasFiltersForm() ? { pageFilters: this.filters } : {}; }
904
+ async renderContent() {
905
+ 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>` : '';
906
+ const widgets = await this.renderWidgetsGrid(await this.filterVisibleWidgets(this.getWidgets()), this.getColumns(), 'content');
907
+ return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'content' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${filters}${widgets}</div>`;
908
+ }
909
+ }
910
+ /** In-memory rate limiter: `max` attempts per window, per key. Fine for a single instance; use a shared store when running several. */
911
+ export class Throttle {
912
+ hits = new Map();
913
+ max;
914
+ windowMs;
915
+ constructor(max = 5, windowMs = 60_000) { this.max = max; this.windowMs = windowMs; }
916
+ /** Records an attempt; returns 0 if allowed, otherwise the number of seconds to wait. */
917
+ hit(key, now = Date.now()) {
918
+ const recent = (this.hits.get(key) ?? []).filter((at) => at > now - this.windowMs);
919
+ if (recent.length >= this.max) {
920
+ this.hits.set(key, recent);
921
+ return Math.max(1, Math.ceil((recent[0] + this.windowMs - now) / 1000));
922
+ }
923
+ recent.push(now);
924
+ this.hits.set(key, recent);
925
+ // Keep the store from growing forever.
926
+ if (this.hits.size > 10_000)
927
+ for (const [k, v] of this.hits)
928
+ if (!v.some((at) => at > now - this.windowMs))
929
+ this.hits.delete(k);
930
+ return 0;
931
+ }
932
+ clear(key) { this.hits.delete(key); }
933
+ }
934
+ const loginThrottle = new Throttle();
935
+ /** Page with the simple layout (login, registration…). */
936
+ export class SimplePage extends BasePage {
937
+ getHeading() { return this.getTitle(); }
938
+ /** Subheading, as trusted HTML (built by the page, with data escaped). */
939
+ getSubheading() { return null; }
940
+ hasLogo() { return true; }
941
+ async render() {
942
+ return views.render('filament-panels::pages.simple', { brand: this.panel.getBrand(), heading: this.getHeading(), subheading: this.getSubheading(), hasLogo: this.hasLogo(), content: await this.getSchema('content').toHtml(), livewireId: this.id, renderHookScopes: this.getRenderHookScopes() });
943
+ }
944
+ }
945
+ /** Color manager read by the other packages and shared by all panels; each request sets its panel's colors on it (`Panel.withTheme`). */
946
+ const sharedColors = new ColorManager();
947
+ /** `fi-link` link of an authentication page. */
948
+ function authLink(url, label, options = {}) {
949
+ const classes = ['fi-ac-link-action', 'fi-link', `fi-size-${options.size ?? 'md'}`, ...sharedColors.getComponentClasses('link', 'primary')];
950
+ return `<a href="${e(url)}" class="${classes.join(' ')}" >${options.iconName ? icon(options.iconName) : ''}<span class="fi-link-label"> ${e(label)} </span></a>`;
951
+ }
952
+ /** Absolute URL for an email: based on `panel.appUrl()`, otherwise the request origin. */
953
+ function absoluteUrl(panel, req, path) { return `${panel.getAppUrl() ?? `${req.protocol}://${req.get('host')}`}${path}`; }
954
+ const registerThrottle = new Throttle(2);
955
+ const passwordResetThrottle = new Throttle(2);
956
+ const verificationThrottle = new Throttle(2);
957
+ const throttled = (group, wait) => Notification.make().danger().title(t(`${group}.notifications.throttled.title`, { seconds: wait, minutes: Math.ceil(wait / 60) })).body(t(`${group}.notifications.throttled.body`, { seconds: wait, minutes: Math.ceil(wait / 60) })).send();
958
+ /** Password rules (at least 8 characters) and confirmation rule (`same`). */
959
+ const passwordRules = (confirmationField, confirmationLabel) => [
960
+ { name: 'min', passes: (value) => String(value).length >= 8, message: (attribute) => `The ${attribute} field must be at least 8 characters.` },
961
+ { name: 'same', passes: (value, ctx) => value === ctx.get(confirmationField), message: (attribute) => `The ${attribute} field must match ${confirmationLabel}.` },
962
+ ];
963
+ /** Email verification message. */
964
+ async function sendVerificationEmail(panel, req, user) {
965
+ await panel.sendMail({
966
+ to: user.email, subject: 'Verify Email Address',
967
+ lines: ['Please click the button below to verify your email address.'],
968
+ action: { label: 'Verify Email Address', url: absoluteUrl(panel, req, panel.getVerifyEmailUrl(user)) },
969
+ outro: ['If you did not create an account, no further action is required.'],
970
+ });
971
+ }
972
+ const formContent = (submit, actions, options = {}) => [
973
+ ...(options.before ? [RenderHook.make(options.before)] : []),
974
+ Form.make([EmbeddedSchema.make('form')]).id('form').livewireSubmitHandler(submit)
975
+ .footer([Actions.make(actions).alignment(options.alignment ?? null).fullWidth(options.fullWidth ?? true).key('form-actions')]),
976
+ ...(options.after ? [RenderHook.make(options.after)] : []),
977
+ ];
978
+ /** Registration page: name, email, confirmed password. The user is logged in, then asked to verify their email if the panel requires it. */
979
+ export class Register extends SimplePage {
980
+ static callable = ['register', 'callSchemaComponentMethod'];
981
+ data = {};
982
+ mount() {
983
+ if (this._ctx.user) {
984
+ this.redirect(this.panel.getUrl());
985
+ return;
986
+ }
987
+ fillSchema(this.getSchema('form'), this.data);
988
+ }
989
+ getTitle() { return t('filament-panels::auth/pages/register.title'); }
990
+ getHeading() { return t('filament-panels::auth/pages/register.heading'); }
991
+ getSubheading() {
992
+ if (!this.panel.getLoginPage())
993
+ return null;
994
+ return `${e(t('filament-panels::auth/pages/register.actions.login.before'))} ${authLink(this.panel.getLoginUrl(), t('filament-panels::auth/pages/register.actions.login.label'))}`;
995
+ }
996
+ formSchema(schema) {
997
+ const auth = this.panel.getAuth();
998
+ return schema.components([
999
+ TextInput.make('name').label(t('filament-panels::auth/pages/register.form.name.label')).required().maxLength(255).autofocus(),
1000
+ TextInput.make('email').label(t('filament-panels::auth/pages/register.form.email.label')).email().required().maxLength(255)
1001
+ .rule({ name: 'unique', passes: async (value) => !(await auth.findByEmail(String(value))), message: (attribute) => `The ${attribute} has already been taken.` }),
1002
+ 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()),
1003
+ TextInput.make('passwordConfirmation').label(t('filament-panels::auth/pages/register.form.password_confirmation.label')).password().revealable().required().dehydrated(false),
1004
+ ]);
1005
+ }
1006
+ contentSchema(schema) {
1007
+ return schema.components(formContent('register', [Action.make('register').label(t('filament-panels::auth/pages/register.form.actions.register.label')).submit('register')], { before: PanelsRenderHook.AUTH_REGISTER_FORM_BEFORE, after: PanelsRenderHook.AUTH_REGISTER_FORM_AFTER }));
1008
+ }
1009
+ /** Validated data, just before creation (add a value, normalize the email…). */
1010
+ mutateFormDataBeforeRegister(data) { return data; }
1011
+ /** Creates the user; defaults to the auth provider's `create()`. */
1012
+ handleRegistration(data) {
1013
+ return this.panel.getAuth().create({ name: String(data.name), email: String(data.email), password: String(data.password) });
1014
+ }
1015
+ async register() {
1016
+ const { req, res, panel } = this._ctx;
1017
+ const wait = registerThrottle.hit(`${panel.getId()}:${req.ip ?? ''}`);
1018
+ if (wait > 0) {
1019
+ throttled('filament-panels::auth/pages/register', wait);
1020
+ return;
1021
+ }
1022
+ const form = this.getSchema('form');
1023
+ this._errors = await validateSchema(form);
1024
+ if (Object.keys(this._errors).length) {
1025
+ this.dispatch('form-validation-error', { livewireId: this.id });
1026
+ return;
1027
+ }
1028
+ const user = await this.handleRegistration(await this.mutateFormDataBeforeRegister(await getSchemaState(form)));
1029
+ if (panel.hasEmailVerification())
1030
+ await sendVerificationEmail(panel, req, user);
1031
+ panel.logIn(res, user, false);
1032
+ this.redirect(panel.getUrl());
1033
+ }
1034
+ }
1035
+ /** Password reset request page: a signed link, valid for 60 minutes, is sent by email. */
1036
+ export class RequestPasswordReset extends SimplePage {
1037
+ static callable = ['request', 'callSchemaComponentMethod'];
1038
+ data = {};
1039
+ mount() {
1040
+ if (this._ctx.user) {
1041
+ this.redirect(this.panel.getUrl());
1042
+ return;
1043
+ }
1044
+ fillSchema(this.getSchema('form'), this.data);
1045
+ }
1046
+ getTitle() { return t('filament-panels::auth/pages/password-reset/request-password-reset.title'); }
1047
+ getHeading() { return t('filament-panels::auth/pages/password-reset/request-password-reset.heading'); }
1048
+ getSubheading() {
1049
+ 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;
1050
+ }
1051
+ formSchema(schema) {
1052
+ return schema.components([TextInput.make('email').label(t('filament-panels::auth/pages/password-reset/request-password-reset.form.email.label')).email().required().autocomplete().autofocus()]);
1053
+ }
1054
+ contentSchema(schema) {
1055
+ 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 }));
1056
+ }
1057
+ async request() {
1058
+ const { req, panel } = this._ctx;
1059
+ const wait = passwordResetThrottle.hit(`${panel.getId()}:request:${req.ip ?? ''}`);
1060
+ if (wait > 0) {
1061
+ throttled('filament-panels::auth/pages/password-reset/request-password-reset', wait);
1062
+ return;
1063
+ }
1064
+ const form = this.getSchema('form');
1065
+ this._errors = await validateSchema(form);
1066
+ if (Object.keys(this._errors).length) {
1067
+ this.dispatch('form-validation-error', { livewireId: this.id });
1068
+ return;
1069
+ }
1070
+ const auth = panel.getAuth();
1071
+ const user = await auth.findByEmail(String(this.data.email));
1072
+ if (user) {
1073
+ await panel.sendMail({
1074
+ to: user.email, subject: 'Reset Password Notification',
1075
+ lines: ['You are receiving this email because we received a password reset request for your account.'],
1076
+ action: { label: 'Reset Password', url: absoluteUrl(panel, req, panel.getResetPasswordUrl(user, await auth.getPasswordFingerprint(user))) },
1077
+ outro: ['This password reset link will expire in 60 minutes.', 'If you did not request a password reset, no further action is required.'],
1078
+ });
1079
+ }
1080
+ // Security: always answer the same way, whether or not an account exists, so the response
1081
+ // does not reveal which email addresses are registered.
1082
+ Notification.make().success().title('We have emailed your password reset link.').body(t('filament-panels::auth/pages/password-reset/request-password-reset.notifications.sent.body')).send();
1083
+ this.data = {};
1084
+ fillSchema(form, this.data);
1085
+ }
1086
+ }
1087
+ /** Sets a new password from the emailed link. The link works once: it dies with the old password. */
1088
+ export class ResetPassword extends SimplePage {
1089
+ static callable = ['resetPassword', 'callSchemaComponentMethod'];
1090
+ static locked = ['email', 'token'];
1091
+ email = null;
1092
+ password = '';
1093
+ passwordConfirmation = '';
1094
+ token = null;
1095
+ mount(params) {
1096
+ if (this._ctx.user) {
1097
+ this.redirect(this.panel.getUrl());
1098
+ return;
1099
+ }
1100
+ this.token = typeof params.token === 'string' ? params.token : null;
1101
+ this.email = typeof params.email === 'string' ? params.email : null;
1102
+ }
1103
+ /** The form has no state path: its fields are page properties. */
1104
+ getSchema(name) { return name === 'form' ? this.formSchema(Schema.make(this).key('form')) : super.getSchema(name); }
1105
+ getTitle() { return t('filament-panels::auth/pages/password-reset/reset-password.title'); }
1106
+ getHeading() { return t('filament-panels::auth/pages/password-reset/reset-password.heading'); }
1107
+ formSchema(schema) {
1108
+ return schema.components([
1109
+ TextInput.make('email').label(t('filament-panels::auth/pages/password-reset/reset-password.form.email.label')).disabled().autofocus(),
1110
+ 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()),
1111
+ 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),
1112
+ ]);
1113
+ }
1114
+ contentSchema(schema) {
1115
+ 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 }));
1116
+ }
1117
+ async resetPassword() {
1118
+ const { req, panel } = this._ctx;
1119
+ const wait = passwordResetThrottle.hit(`${panel.getId()}:reset:${req.ip ?? ''}`);
1120
+ if (wait > 0) {
1121
+ throttled('filament-panels::auth/pages/password-reset/reset-password', wait);
1122
+ return;
1123
+ }
1124
+ this._errors = await validateSchema(this.getSchema('form'));
1125
+ if (Object.keys(this._errors).length) {
1126
+ this.dispatch('form-validation-error', { livewireId: this.id });
1127
+ return;
1128
+ }
1129
+ const auth = panel.getAuth();
1130
+ const payload = panel.readResetPasswordToken(this.token);
1131
+ const user = payload ? await auth.find(payload.uid) : null;
1132
+ // The token identifies the user; the URL's email must be theirs and their password must not have changed since the link was sent.
1133
+ if (!payload || !user || user.email !== this.email || (await auth.getPasswordFingerprint(user)) !== payload.fp) {
1134
+ Notification.make().danger().title('This password reset token is invalid.').send();
1135
+ return;
1136
+ }
1137
+ await auth.update(user, { password: this.password });
1138
+ Notification.make().success().title('Your password has been reset.').send();
1139
+ this.redirect(panel.getLoginUrl());
1140
+ }
1141
+ }
1142
+ /** Email verification prompt, with a "Resend it." link. */
1143
+ export class EmailVerificationPrompt extends SimplePage {
1144
+ static callable = ['mountAction'];
1145
+ async mount() {
1146
+ const { panel, user } = this._ctx;
1147
+ if (!user || !(await panel.mustVerifyEmail(user)))
1148
+ this.redirect(panel.getUrl());
1149
+ }
1150
+ getTitle() { return t('filament-panels::auth/pages/email-verification/email-verification-prompt.title'); }
1151
+ getHeading() { return t('filament-panels::auth/pages/email-verification/email-verification-prompt.heading'); }
1152
+ contentSchema(schema) {
1153
+ const { user } = this._ctx;
1154
+ const expression = `mountAction('resendNotification')`;
1155
+ 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>`;
1156
+ const text = (html) => Html.make(`<span class="fi-sc-text" > ${html} </span>`);
1157
+ return schema.components([
1158
+ text(e(t('filament-panels::auth/pages/email-verification/email-verification-prompt.messages.notification_sent', { email: user?.email ?? '' }))),
1159
+ text(`${e(t('filament-panels::auth/pages/email-verification/email-verification-prompt.messages.notification_not_received'))} ${link}`),
1160
+ ]);
1161
+ }
1162
+ /** The page's only action: resend the email (at most twice per minute). */
1163
+ async mountAction(name) {
1164
+ const { req, panel, user } = this._ctx;
1165
+ if (name !== 'resendNotification' || !user)
1166
+ throw new WireError(404, 'Action not found.');
1167
+ this.skipRender();
1168
+ const wait = verificationThrottle.hit(`${panel.getId()}:verify:${user.id}`);
1169
+ if (wait > 0) {
1170
+ 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();
1171
+ return;
1172
+ }
1173
+ if (await panel.mustVerifyEmail(user))
1174
+ await sendVerificationEmail(panel, req, user);
1175
+ Notification.make().success().title(t('filament-panels::auth/pages/email-verification/email-verification-prompt.notifications.notification_resent.title')).send();
1176
+ }
1177
+ }
1178
+ /** User profile page: name, email, new password. The current password is required to change the email or password. */
1179
+ export class EditProfile extends SimplePage {
1180
+ static callable = ['save', 'callSchemaComponentMethod'];
1181
+ static slug = 'profile';
1182
+ data = {};
1183
+ static getLabel() { return t('filament-panels::auth/pages/edit-profile.label'); }
1184
+ async mount() {
1185
+ const { user } = this._ctx;
1186
+ if (!user)
1187
+ throw new WireError(401, 'Unauthenticated.');
1188
+ this.data = { name: user.name, email: user.email, password: null, passwordConfirmation: null, currentPassword: null };
1189
+ }
1190
+ async boot() { if (!this._ctx?.user)
1191
+ throw new WireError(401, 'Unauthenticated.'); }
1192
+ getTitle() { return this.constructor.getLabel(); }
1193
+ hasLogo() { return false; }
1194
+ formSchema(schema) {
1195
+ const { panel, user } = this._ctx;
1196
+ const auth = panel.getAuth();
1197
+ return schema.operation('edit').components([
1198
+ TextInput.make('name').label(t('filament-panels::auth/pages/edit-profile.form.name.label')).required().maxLength(255).autofocus(),
1199
+ TextInput.make('email').label(t('filament-panels::auth/pages/edit-profile.form.email.label')).email().required().maxLength(255).live()
1200
+ .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.` }),
1201
+ 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()),
1202
+ TextInput.make('passwordConfirmation').label(t('filament-panels::auth/pages/edit-profile.form.password_confirmation.label')).password().autocomplete('new-password').revealable().required()
1203
+ .visible(({ get }) => !blankValue(get('password'))).dehydrated(false),
1204
+ 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'))
1205
+ .password().autocomplete('current-password').revealable().required()
1206
+ .rule({ name: 'current_password', passes: async (value) => Boolean(user && (await auth.attempt(user.email, String(value)))), message: () => 'The password is incorrect.' })
1207
+ .visible(({ get }) => !blankValue(get('password')) || get('email') !== user?.email).dehydrated(false),
1208
+ ]);
1209
+ }
1210
+ contentSchema(schema) {
1211
+ return schema.components(formContent('save', [
1212
+ Action.make('save').label(t('filament-panels::auth/pages/edit-profile.form.actions.save.label')).submit('save').keyBindings(['mod+s']),
1213
+ 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())})`),
1214
+ ], { fullWidth: false, alignment: 'start' }));
1215
+ }
1216
+ async save() {
1217
+ const { panel, user } = this._ctx;
1218
+ if (!user)
1219
+ throw new WireError(401, 'Unauthenticated.');
1220
+ const form = this.getSchema('form');
1221
+ this._errors = await validateSchema(form);
1222
+ if (Object.keys(this._errors).length) {
1223
+ this.dispatch('form-validation-error', { livewireId: this.id });
1224
+ return;
1225
+ }
1226
+ const data = await getSchemaState(form);
1227
+ await panel.getAuth().update(user, { name: String(data.name), email: String(data.email), password: blankValue(data.password) ? undefined : String(data.password) });
1228
+ this.data = { ...this.data, password: null, passwordConfirmation: null, currentPassword: null };
1229
+ Notification.make().success().title(t('filament-panels::auth/pages/edit-profile.notifications.saved.title')).send();
1230
+ }
1231
+ }
1232
+ const blankValue = (value) => value === null || value === undefined || value === '';
1233
+ /** Tenant registration page: the subclass provides its label and form (`formSchema`). */
1234
+ export class RegisterTenant extends SimplePage {
1235
+ static callable = ['register', 'callSchemaComponentMethod'];
1236
+ static slug = 'new';
1237
+ data = {};
1238
+ static getLabel() { return 'Register tenant'; }
1239
+ /** Whether tenants can be created; by default, if the tenant provider implements `create`. */
1240
+ static canView(panel, _user) { return typeof panel.getTenantProvider().create === 'function'; }
1241
+ async boot() {
1242
+ const ctx = this._ctx;
1243
+ if (!ctx?.user)
1244
+ throw new WireError(401, 'Unauthenticated.');
1245
+ if (!(await this.constructor.canView(ctx.panel, ctx.user)))
1246
+ throw new WireError(404, 'Not Found');
1247
+ }
1248
+ mount() { fillSchema(this.getSchema('form'), this.data); }
1249
+ getTitle() { return this.constructor.getLabel(); }
1250
+ hasLogo() { return false; }
1251
+ formSchema(schema) { return schema; }
1252
+ contentSchema(schema) {
1253
+ return schema.components(formContent('register', [Action.make('register').label(this.constructor.getLabel()).submit('register')]));
1254
+ }
1255
+ mutateFormDataBeforeRegister(data) { return data; }
1256
+ handleRegistration(data) {
1257
+ const { panel, user } = this._ctx;
1258
+ return panel.getTenantProvider().create(data, user);
1259
+ }
1260
+ async register() {
1261
+ const { panel } = this._ctx;
1262
+ const form = this.getSchema('form');
1263
+ this._errors = await validateSchema(form);
1264
+ if (Object.keys(this._errors).length) {
1265
+ this.dispatch('form-validation-error', { livewireId: this.id });
1266
+ return;
1267
+ }
1268
+ const tenant = await this.handleRegistration(await this.mutateFormDataBeforeRegister(await getSchemaState(form)));
1269
+ this.redirect(panel.getUrl('', tenant));
1270
+ }
1271
+ }
1272
+ /** Current tenant's profile page, in the full layout. */
1273
+ export class EditTenantProfile extends Page {
1274
+ static callable = [...Page.callable, 'save'];
1275
+ static slug = 'profile';
1276
+ static shouldRegisterNavigation = false;
1277
+ data = {};
1278
+ static getLabel() { return 'Tenant profile'; }
1279
+ static getTitle() { return this.getLabel(); }
1280
+ isAccessAuthorized(ctx) { return ctx.tenant !== undefined && ctx.tenant !== null && typeof ctx.panel.getTenantProvider().update === 'function'; }
1281
+ mount() {
1282
+ const tenant = this.panel.getTenant();
1283
+ fillSchemaFromRecord(this.getSchema('form'), this.data, tenant ?? {});
1284
+ }
1285
+ formSchema(schema) { return schema; }
1286
+ contentSchema(schema) {
1287
+ 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' }));
1288
+ }
1289
+ async save() {
1290
+ const form = this.getSchema('form');
1291
+ this._errors = await validateSchema(form);
1292
+ if (Object.keys(this._errors).length) {
1293
+ this.dispatch('form-validation-error', { livewireId: this.id });
1294
+ return;
1295
+ }
1296
+ await this.panel.getTenantProvider().update(this.panel.getTenant(), await getSchemaState(form));
1297
+ Notification.make().success().title(t('filament-panels::pages/tenancy/edit-tenant-profile.notifications.saved.title')).send();
1298
+ }
1299
+ }
1300
+ export class Login extends SimplePage {
1301
+ static callable = ['authenticate', 'callSchemaComponentMethod'];
1302
+ data = {};
1303
+ mount() { fillSchema(this.getSchema('form'), this.data); }
1304
+ getTitle() { return t('filament-panels::auth/pages/login.title'); }
1305
+ getHeading() { return t('filament-panels::auth/pages/login.heading'); }
1306
+ getSubheading() {
1307
+ if (!this.panel.hasRegistration())
1308
+ return null;
1309
+ return `${e(t('filament-panels::auth/pages/login.actions.register.before'))} ${authLink(this.panel.getRegistrationUrl(), t('filament-panels::auth/pages/login.actions.register.label'))}`;
1310
+ }
1311
+ formSchema(schema) {
1312
+ return schema.components([
1313
+ TextInput.make('email').label(t('filament-panels::auth/pages/login.form.email.label')).email().required().autocomplete().autofocus(),
1314
+ TextInput.make('password').label(t('filament-panels::auth/pages/login.form.password.label'))
1315
+ .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)
1316
+ .password().revealable().autocomplete('current-password').required(),
1317
+ Checkbox.make('remember').label(t('filament-panels::auth/pages/login.form.remember.label')),
1318
+ ]);
1319
+ }
1320
+ contentSchema(schema) {
1321
+ return schema.components([
1322
+ RenderHook.make(PanelsRenderHook.AUTH_LOGIN_FORM_BEFORE),
1323
+ Form.make([EmbeddedSchema.make('form')]).id('form').livewireSubmitHandler('authenticate')
1324
+ .footer([Actions.make([Action.make('authenticate').label(t('filament-panels::auth/pages/login.form.actions.authenticate.label')).submit('authenticate')]).fullWidth().key('form-actions')]),
1325
+ // Multi-factor challenge form: hidden until MFA is implemented.
1326
+ Form.make([]).id('multiFactorChallengeForm').hidden(),
1327
+ RenderHook.make(PanelsRenderHook.AUTH_LOGIN_FORM_AFTER),
1328
+ ]);
1329
+ }
1330
+ async authenticate() {
1331
+ this._errors = await validateSchema(this.getSchema('form'));
1332
+ if (Object.keys(this._errors).length) {
1333
+ this.dispatch('form-validation-error', { livewireId: this.id });
1334
+ return;
1335
+ }
1336
+ const { req, res, panel } = this._ctx;
1337
+ // Five attempts per minute per IP address, then wait.
1338
+ const wait = loginThrottle.hit(`${panel.getId()}:${req.ip ?? ''}`);
1339
+ if (wait > 0) {
1340
+ Notification.make().danger().title(t('filament-actions::notifications.throttled.title', { seconds: wait, minutes: Math.ceil(wait / 60) })).body(t('filament-actions::notifications.throttled.body', { seconds: wait, minutes: Math.ceil(wait / 60) })).send();
1341
+ return;
1342
+ }
1343
+ const user = await panel.getAuth().attempt(String(this.data.email), String(this.data.password));
1344
+ if (!user) {
1345
+ this.addError('data.email', t('filament-panels::auth/pages/login.messages.failed'));
1346
+ return;
1347
+ }
1348
+ loginThrottle.clear(`${panel.getId()}:${req.ip ?? ''}`);
1349
+ panel.logIn(res, user, Boolean(this.data.remember));
1350
+ this.redirect(panel.getUrl());
1351
+ }
1352
+ }
1353
+ /** Whether the host component's user has this ability on the component's resource (or relation manager). Use it in your actions' `authorize()`. */
1354
+ export const can = (livewire, ability, record = null) => canOn(livewire, ability, record);
1355
+ function canOn(livewire, ability, record = null) {
1356
+ const resource = livewire?.resource;
1357
+ return typeof resource?.can === 'function' ? resource.can(ability, livewire._ctx?.user ?? null, record) : true;
1358
+ }
1359
+ const plural = (s) => (/[^aeiou]y$/.test(s) ? s.slice(0, -1) + 'ies' : /(s|x|z|ch|sh)$/.test(s) ? s + 'es' : s + 's');
1360
+ const ucfirst = (s) => s.replace(/^./, (c) => c.toUpperCase());
1361
+ /** Resource: a model with its pages, table and navigation. */
1362
+ export class Resource {
1363
+ static panel;
1364
+ static slug = '';
1365
+ static modelLabel = 'record';
1366
+ static pluralModelLabel = null;
1367
+ static navigationIcon = null;
1368
+ static navigationGroup = null;
1369
+ static navigationSort = 0;
1370
+ static navigationBadge = null;
1371
+ static adapter;
1372
+ /** In a multi-tenant panel, whether records belong to each tenant (`true` by default). */
1373
+ static isScopedToTenant = true;
1374
+ /** Tenant column for this resource (defaults to the panel's: `panel.tenant(provider, { ownershipColumn })`). */
1375
+ static tenantOwnershipColumn = null;
1376
+ /**
1377
+ * Resource pages; replace them with your subclasses to change their behavior:
1378
+ * `static pages = { index: MyList, create: MyCreate, edit: MyEdit }`.
1379
+ */
1380
+ static pages = {};
1381
+ /** `true` adds a view page (`ViewRecord`) to the resource; table rows then link to it. */
1382
+ static hasViewPage = false;
1383
+ /** Who can do what on this resource. Without a policy, any signed-in user can do everything. */
1384
+ static policy = null;
1385
+ /** Whether the user has this ability (on this record, if any). */
1386
+ static can(ability, user, record = null) {
1387
+ const rule = this.policy?.[ability];
1388
+ return this.panel.authorize(user, ability, { resource: this, record, rule: rule ? () => rule(user, record) : null });
1389
+ }
1390
+ /** Resource widgets, registered as components. */
1391
+ static widgets = [];
1392
+ /** Widgets shown above the table on the list page. */
1393
+ static headerWidgets = [];
1394
+ /** Relation managers shown below the edit form. */
1395
+ static relations = [];
1396
+ /** Tabs of the list page. */
1397
+ static tabs() { return {}; }
1398
+ /** Simple resource: a single page, with create and edit in modals. */
1399
+ static isSimple = false;
1400
+ static getPluralModelLabel() { return this.pluralModelLabel ?? plural(this.modelLabel); }
1401
+ static getTitleCasePluralModelLabel() { return this.getPluralModelLabel().split(' ').map(ucfirst).join(' '); }
1402
+ static getUrl(page = 'index', record) {
1403
+ const base = this.panel.getUrl(this.slug);
1404
+ if (page in this.relatedPages)
1405
+ return `${base}/${record}/${page}`;
1406
+ return page === 'index' ? base : page === 'create' ? `${base}/create` : page === 'view' ? `${base}/${record}` : `${base}/${record}/edit`;
1407
+ }
1408
+ /** Related records pages: `{ comments: ManagePostComments }` is served at `<slug>/{record}/comments`. */
1409
+ static relatedPages = {};
1410
+ /** Pages listed in a record's sub-navigation, in order: `['view', 'edit', 'comments']`. Empty: no sub-navigation. */
1411
+ static recordSubNavigation = [];
1412
+ /** Record sub-navigation: one tab per declared page, the current page's tab active. */
1413
+ static getRecordSubNavigation(page) {
1414
+ const pages = this.getPages();
1415
+ const key = page.getRecordKey() ?? '';
1416
+ return this.recordSubNavigation.filter((name) => name in pages).map((name) => {
1417
+ const related = this.relatedPages[name];
1418
+ return {
1419
+ label: related ? related.getNavigationLabel() : name === 'view' ? t('filament-panels::resources/pages/view-record.navigation_label') : t('filament-panels::resources/pages/edit-record.navigation_label'),
1420
+ icon: related ? (related.navigationIcon ?? 'o-rectangle-stack') : name === 'view' ? 'o-eye' : 'o-pencil-square',
1421
+ url: this.getUrl(name, key),
1422
+ active: page instanceof this.getRegisteredPage(name),
1423
+ };
1424
+ });
1425
+ }
1426
+ static _registered = new WeakMap();
1427
+ /** Page class actually registered under a name (pages are generated once per resource). */
1428
+ static getRegisteredPage(name) { return this.getPages()[name]; }
1429
+ static table(table) { return table; }
1430
+ static form(schema) { return schema; }
1431
+ /** Schema of the view page. */
1432
+ static infolist(schema) { return schema; }
1433
+ // -------------------------------------------------------------- global search
1434
+ /** Columns searched by global search; defaults to the title attribute. Empty array: the resource is not searchable. */
1435
+ static globallySearchableAttributes = null;
1436
+ static getGloballySearchableAttributes() { return this.globallySearchableAttributes ?? (this.recordTitleAttribute ? [this.recordTitleAttribute] : []); }
1437
+ static canGloballySearch() { return this.getGloballySearchableAttributes().length > 0; }
1438
+ /** Position of this resource's group in the results; without one, after the others in registration order. */
1439
+ static globalSearchSort = null;
1440
+ /** Maximum results rendered per resource. */
1441
+ static globalSearchResultsLimit = 50;
1442
+ static getGlobalSearchResultTitle(record) { return this.getRecordTitle(record); }
1443
+ /** Details shown under the title: `{ Author: 'Jane' }`. Empty values are skipped. */
1444
+ static getGlobalSearchResultDetails(_record) { return {}; }
1445
+ /** URL of a result: the view page, otherwise the edit page; for a simple resource, the list with the action open. `null` if the user has no access. */
1446
+ static async getGlobalSearchResultUrl(record, user) {
1447
+ const key = this.adapter.getKey(record);
1448
+ const canView = await this.can('view', user, record);
1449
+ if (this.hasViewPage && canView)
1450
+ return this.getUrl('view', key);
1451
+ const canEdit = await this.can('update', user, record);
1452
+ if (!this.isSimple && canEdit)
1453
+ return this.getUrl('edit', key);
1454
+ const action = canView ? 'view' : canEdit ? 'edit' : null;
1455
+ return action ? `${this.getUrl('index')}?tableAction=${action}&tableActionRecord=${encodeURIComponent(key)}` : null;
1456
+ }
1457
+ static async getGlobalSearchResults(search, user) {
1458
+ const page = await this.adapter.paginate({ search, searchColumns: this.getGloballySearchableAttributes(), page: 1, perPage: Math.min(Math.max(this.globalSearchResultsLimit, 1), 200) });
1459
+ const results = [];
1460
+ for (const record of page.records) {
1461
+ const url = await this.getGlobalSearchResultUrl(record, user);
1462
+ if (url === null)
1463
+ continue;
1464
+ const details = {};
1465
+ for (const [label, value] of Object.entries(await this.getGlobalSearchResultDetails(record)))
1466
+ if (value !== null && value !== undefined && value !== '')
1467
+ details[label] = String(value);
1468
+ results.push({ title: this.getGlobalSearchResultTitle(record), url, details });
1469
+ }
1470
+ return results;
1471
+ }
1472
+ /** Attribute used as a record's title. */
1473
+ static recordTitleAttribute = null;
1474
+ /** Without a title attribute: the model label in title case ("Leave Request"). */
1475
+ static getRecordTitle(record) { return this.recordTitleAttribute ? String(record?.[this.recordTitleAttribute] ?? '') : this.modelLabel.split(' ').map(ucfirst).join(' '); }
1476
+ /** Generated resource pages: list, create, edit. */
1477
+ static getPages() {
1478
+ const known = Resource._registered.get(this);
1479
+ if (known)
1480
+ return known;
1481
+ const pages = this.buildPages();
1482
+ Resource._registered.set(this, pages);
1483
+ return pages;
1484
+ }
1485
+ static buildPages() {
1486
+ const resource = this;
1487
+ const List = class extends (resource.pages.index ?? ListRecords) {
1488
+ static resource = resource;
1489
+ static slug = resource.slug;
1490
+ static title = resource.getTitleCasePluralModelLabel();
1491
+ static navigationIcon = resource.navigationIcon;
1492
+ static navigationGroup = resource.navigationGroup;
1493
+ static navigationSort = resource.navigationSort;
1494
+ static navigationBadge = resource.navigationBadge;
1495
+ static getNavigationBadge = typeof resource.getNavigationBadge === 'function' ? () => resource.getNavigationBadge() : undefined;
1496
+ };
1497
+ const Create = class extends (resource.pages.create ?? CreateRecord) {
1498
+ static resource = resource;
1499
+ static slug = `${resource.slug}/create`;
1500
+ static shouldRegisterNavigation = false;
1501
+ };
1502
+ const Edit = class extends (resource.pages.edit ?? EditRecord) {
1503
+ static resource = resource;
1504
+ static slug = `${resource.slug}/{record}/edit`;
1505
+ static shouldRegisterNavigation = false;
1506
+ };
1507
+ if (resource.isSimple)
1508
+ return { index: List };
1509
+ const related = {};
1510
+ for (const [name, RelatedPage] of Object.entries(resource.relatedPages)) {
1511
+ related[name] = class extends RelatedPage {
1512
+ static parentResource = resource;
1513
+ static slug = `${resource.slug}/{record}/${name}`;
1514
+ static shouldRegisterNavigation = false;
1515
+ };
1516
+ }
1517
+ if (!resource.hasViewPage)
1518
+ return { index: List, create: Create, edit: Edit, ...related };
1519
+ const View = class extends (resource.pages.view ?? ViewRecord) {
1520
+ static resource = resource;
1521
+ static slug = `${resource.slug}/{record}`;
1522
+ static shouldRegisterNavigation = false;
1523
+ };
1524
+ return { index: List, create: Create, edit: Edit, view: View, ...related };
1525
+ }
1526
+ }
1527
+ /** Widget: a component displayed in a page's widget grid. */
1528
+ export class Widget extends Component {
1529
+ static componentName;
1530
+ /** Width in the widget grid: a number of columns, or `'full'`. */
1531
+ static columnSpan = 1;
1532
+ /** Loaded when it becomes visible. */
1533
+ static isLazy = true;
1534
+ get panel() { return this._ctx.panel; }
1535
+ /** Restricts the widget to some users: returning `false` removes it from the page and refuses to load it. */
1536
+ static canView(_user) { return true; }
1537
+ async boot() {
1538
+ const ctx = this._ctx;
1539
+ if (!ctx?.user)
1540
+ throw new WireError(401, 'Unauthenticated.');
1541
+ const WidgetClass = this.constructor;
1542
+ if (!(await ctx.panel.authorize(ctx.user, 'view', { widget: WidgetClass, rule: () => WidgetClass.canView(ctx.user) })))
1543
+ throw new WireError(403, 'Forbidden.');
1544
+ }
1545
+ /** Grid column classes and style, at the `lg` breakpoint. */
1546
+ gridColumn() {
1547
+ const span = this.constructor.columnSpan;
1548
+ // The span is always written, even for a single column.
1549
+ return { classes: ['fi-grid-col', 'lg:fi-grid-col-span'], style: `--col-span-lg: ${span === 'full' ? '1 / -1' : `span ${Math.floor(span)} / span ${Math.floor(span)}`};` };
1550
+ }
1551
+ placeholder() {
1552
+ const column = this.gridColumn();
1553
+ return `<div role="status" aria-busy="true" style="height: 8rem; ${column.style}" class="${cls(['fi-section', 'fi-loading-section', ...column.classes])}"><span class="fi-sr-only">${e(t('filament::components/loading-section.label'))}</span></div>`;
1554
+ }
1555
+ /** Common widget wrapper. */
1556
+ wrap(content, classes = []) {
1557
+ const column = this.gridColumn();
1558
+ return `<div${column.style ? ` style="${column.style}"` : ''} class="${cls(['fi-wi-widget', ...column.classes, ...classes])}">${content}</div>`;
1559
+ }
1560
+ }
1561
+ /**
1562
+ * Calendar widget: month, week and list views, server-rendered navigation, no JavaScript library.
1563
+ *
1564
+ * Minimal use: extend the class and return the events of the displayed period.
1565
+ *
1566
+ * class LeaveCalendar extends CalendarWidget {
1567
+ * static heading = 'Leave';
1568
+ * async getEvents({ start, end }) { return [{ id: 1, title: 'Ada — vacation', start: '2026-11-12', end: '2026-11-14', color: 'success' }]; }
1569
+ * }
1570
+ *
1571
+ * Everything else can be overridden method by method: available views, first day of the week, locale, labels, events
1572
+ * per cell, day clicks (`onDateClick`) and event clicks (`onEventClick`), event rendering (`renderEvent`), cell footer
1573
+ * (`renderDayFooter`), toolbar buttons (`renderToolbarEnd`), and appearance through the `.nv-cal` CSS variables
1574
+ * (`getExtraClasses`, `getStyle`).
1575
+ */
1576
+ export class CalendarWidget extends Widget {
1577
+ static columnSpan = 'full';
1578
+ /** Heading shown before the period ("Leave — November 2026"). */
1579
+ static heading = null;
1580
+ static callable = ['previousPeriod', 'nextPeriod', 'goToToday', 'setView', 'selectDate', 'selectEvent', 'showDay'];
1581
+ /** View and date only change through the methods below, which validate them. */
1582
+ static locked = ['view', 'date'];
1583
+ view = 'month';
1584
+ /** Anchor date (`YYYY-MM-DD`): the displayed month or week contains it. Empty: today. */
1585
+ date = '';
1586
+ /** Available views; the first is the initial one. With a single view, the switcher is hidden. */
1587
+ getViews() { return ['month', 'week', 'list']; }
1588
+ /** 0 = Sunday, 1 = Monday… */
1589
+ getFirstDayOfWeek() { return 1; }
1590
+ /** Locale for month and day names (`Intl`). */
1591
+ getLocale() { return 'en-US'; }
1592
+ getLabels() { return CALENDAR_LABELS; }
1593
+ /** In month view, beyond this number the cell shows "+N more" (which opens the list on that day). */
1594
+ getMaxEventsPerDay() { return 3; }
1595
+ getHeading() { return this.constructor.heading; }
1596
+ /** Today's date, `YYYY-MM-DD` (UTC by default; override for another time zone). */
1597
+ getToday() { return new Date().toISOString().slice(0, 10); }
1598
+ /** Click on a day number. Overriding this makes days clickable: open a page (`this.redirect`), notify, switch view… */
1599
+ onDateClick(_date) { }
1600
+ /** Click on an event without a URL. The event is reloaded server-side, so `event.extra` is available. */
1601
+ onEventClick(_event) { }
1602
+ /** Already-escaped HTML for an event, or `null` for the default rendering (`renderCalendarEvent`). */
1603
+ renderEvent(_event, _context) { return null; }
1604
+ /** Already-escaped HTML appended to the bottom of each cell. */
1605
+ renderDayFooter(_day, _events) { return ''; }
1606
+ /** Already-escaped HTML at the end of the toolbar: a "New" link, a filter… */
1607
+ renderToolbarEnd() { return ''; }
1608
+ /** Extra classes on the calendar, to hook your styles or override its CSS variables. */
1609
+ getExtraClasses() { return []; }
1610
+ /** CSS variables set on the calendar: `{ '--nv-cal-day-min-height': '9rem' }`. */
1611
+ getStyle() { return {}; }
1612
+ // ---------------------------------------------------------- navigation
1613
+ anchor() { return isDate(this.date) ? this.date : this.getToday(); }
1614
+ currentView() { const views = this.getViews().filter((view) => CALENDAR_VIEWS.includes(view)); return views.includes(this.view) ? this.view : (views[0] ?? 'month'); }
1615
+ shift(direction) { this.date = this.currentView() === 'week' ? addDays(this.anchor(), 7 * direction) : addMonths(this.anchor().slice(0, 8) + '01', direction); }
1616
+ previousPeriod() { this.shift(-1); }
1617
+ nextPeriod() { this.shift(1); }
1618
+ goToToday() { this.date = this.getToday(); }
1619
+ setView(view) { if (typeof view === 'string' && this.getViews().includes(view) && CALENDAR_VIEWS.includes(view))
1620
+ this.view = view; }
1621
+ /** "+N more": the list view anchored on that day (or the week view if list is not available). */
1622
+ showDay(date) {
1623
+ if (!isDate(date))
1624
+ return;
1625
+ this.date = date;
1626
+ const views = this.getViews();
1627
+ this.view = views.includes('list') ? 'list' : views.includes('week') ? 'week' : this.currentView();
1628
+ }
1629
+ datesClickable() { return this.onDateClick !== CalendarWidget.prototype.onDateClick; }
1630
+ eventsClickable() { return this.onEventClick !== CalendarWidget.prototype.onEventClick; }
1631
+ async selectDate(date) { if (isDate(date) && this.datesClickable())
1632
+ await this.onDateClick(date); }
1633
+ /** The id comes from the browser: the event is looked up among those of the displayed period, never trusted as-is. */
1634
+ async selectEvent(id) {
1635
+ if ((typeof id !== 'string' && typeof id !== 'number') || !this.eventsClickable())
1636
+ return;
1637
+ const view = this.currentView();
1638
+ const event = (await this.getEvents({ ...visibleRange(view, this.anchor(), this.getFirstDayOfWeek()), view })).find((candidate) => String(candidate.id) === String(id));
1639
+ if (event)
1640
+ await this.onEventClick(event);
1641
+ }
1642
+ async render() {
1643
+ const view = this.currentView();
1644
+ this.view = view;
1645
+ const anchor = this.anchor();
1646
+ const firstDay = ((Math.floor(this.getFirstDayOfWeek()) % 7) + 7) % 7;
1647
+ const events = await this.getEvents({ ...visibleRange(view, anchor, firstDay), view });
1648
+ const calendar = renderCalendar({
1649
+ heading: this.getHeading(), view, views: this.getViews().filter((candidate) => CALENDAR_VIEWS.includes(candidate)), anchor, today: this.getToday(), firstDay, locale: this.getLocale(), events,
1650
+ maxEventsPerDay: this.getMaxEventsPerDay(), datesClickable: this.datesClickable(), eventsClickable: this.eventsClickable(), labels: this.getLabels(),
1651
+ renderEvent: (event, context) => this.renderEvent(event, context), renderDayFooter: (day, dayEvents) => this.renderDayFooter(day, dayEvents), toolbarEnd: this.renderToolbarEnd(),
1652
+ });
1653
+ const extra = this.getExtraClasses().filter((name) => /^[A-Za-z0-9_:-]+$/.test(name));
1654
+ const style = Object.entries(this.getStyle()).filter(([name, value]) => /^--[a-z0-9-]+$/.test(name) && /^[^;{}<>]+$/.test(value)).map(([name, value]) => `${name}: ${value};`).join(' ');
1655
+ const decorated = calendar.replace('<div class="nv-cal ', `<div${style ? ` style="${e(style)}"` : ''} class="nv-cal ${extra.length ? `${extra.join(' ')} ` : ''}`);
1656
+ return this.wrap(widgetSection(decorated), ['nv-wi-calendar']);
1657
+ }
1658
+ }
1659
+ /**
1660
+ * Calendar page: the same calendar as `CalendarWidget`, on a full page, with actions — so modals, forms,
1661
+ * confirmations and slide-overs.
1662
+ *
1663
+ * class Planning extends CalendarPage {
1664
+ * static slug = 'planning';
1665
+ * async getEvents({ start, end }) { return db('shifts').where('day', '>=', start).where('day', '<=', end)… }
1666
+ * // Day click: a modal with a form, prefilled with the date.
1667
+ * dateAction() {
1668
+ * return Action.make('createShift').modalHeading('New shift')
1669
+ * .schema([Select.make('employee_id').options(…).required(), DatePicker.make('day').required()])
1670
+ * .fillForm(({ arguments: args }) => ({ day: CalendarPage.dateOf(args) }))
1671
+ * .action(async ({ data }) => { await db('shifts').insert(data); Notification.make().success().title('Saved').send(); });
1672
+ * }
1673
+ * // Event click: another action; the event is reloaded server-side.
1674
+ * eventAction() {
1675
+ * return Action.make('editShift').schema([…])
1676
+ * .fillForm(async ({ livewire, arguments: args }) => (await livewire.findEvent(args.event))?.extra ?? {})
1677
+ * .action(async ({ livewire, arguments: args, data }) => { const event = await livewire.findEvent(args.event); if (event) await db('shifts').where({ id: event.id }).update(data); });
1678
+ * }
1679
+ * }
1680
+ *
1681
+ * Three ways to handle a click: a **URL** on the event (`url`: dedicated page), an **action** (modal),
1682
+ * or **custom code** by overriding `onDateClick()` / `onEventClick()` (redirect, notify, write to the database, mount any
1683
+ * action with `this.mountAction(name, arguments)`). `getHeaderActions()` adds buttons to the page header, and
1684
+ * `getCalendarActions()` declares other actions mountable from your own rendering (`renderEvent`, `renderToolbarEnd`).
1685
+ */
1686
+ export class CalendarPage extends Page {
1687
+ static callable = ['mountAction', 'unmountAction', 'callMountedAction', 'callSchemaComponentMethod', 'previousPeriod', 'nextPeriod', 'goToToday', 'setView', 'selectDate', 'selectEvent', 'showDay'];
1688
+ static locked = ['view', 'date'];
1689
+ view = 'month';
1690
+ date = '';
1691
+ getViews() { return ['month', 'week', 'list']; }
1692
+ getFirstDayOfWeek() { return 1; }
1693
+ getLocale() { return 'en-US'; }
1694
+ getLabels() { return CALENDAR_LABELS; }
1695
+ getMaxEventsPerDay() { return 3; }
1696
+ getCalendarHeading() { return null; }
1697
+ getToday() { return new Date().toISOString().slice(0, 10); }
1698
+ renderEvent(_event, _context) { return null; }
1699
+ renderDayFooter(_day, _events) { return ''; }
1700
+ renderToolbarEnd() { return ''; }
1701
+ getExtraClasses() { return []; }
1702
+ getStyle() { return {}; }
1703
+ /** Action opened on day click; it receives `arguments.date`. `null`: days are not clickable (unless `onDateClick` is overridden). */
1704
+ dateAction() { return null; }
1705
+ /** Action opened on click on an event without a URL; it receives `arguments.event` (an id, to resolve with `findEvent`). */
1706
+ eventAction() { return null; }
1707
+ /** Other actions mountable from your own HTML: `wire:click="mountAction('name', { … })"`. */
1708
+ getCalendarActions() { return []; }
1709
+ /** Opens `dateAction()` by default. Override to do something else (e.g. redirect to a create page). */
1710
+ async onDateClick(date) { const action = this.dateAction(); if (action)
1711
+ await this.mountAction(action.getName(), { date }); }
1712
+ /** Opens `eventAction()` by default. The event has been resolved server-side before the call. */
1713
+ async onEventClick(event) { const action = this.eventAction(); if (action)
1714
+ await this.mountAction(action.getName(), { event: String(event.id) }); }
1715
+ /** Clicked date, read from an action's arguments. They come from the browser: only a valid date is returned. */
1716
+ static dateOf(args) { return isDate(args?.date) ? args.date : null; }
1717
+ /** Event of the displayed period with this id, or `null`. Never act on a received id without going through this. */
1718
+ async findEvent(id) {
1719
+ if (typeof id !== 'string' && typeof id !== 'number')
1720
+ return null;
1721
+ const view = this.currentView();
1722
+ return (await this.getEvents({ ...visibleRange(view, this.anchor(), this.firstDay()), view })).find((candidate) => String(candidate.id) === String(id)) ?? null;
1723
+ }
1724
+ async getMountableActions(context) {
1725
+ return [...(await super.getMountableActions(context)), ...[this.dateAction(), this.eventAction(), ...this.getCalendarActions()].filter((action) => action !== null)];
1726
+ }
1727
+ // ---------------------------------------------------------- navigation
1728
+ firstDay() { return ((Math.floor(this.getFirstDayOfWeek()) % 7) + 7) % 7; }
1729
+ anchor() { return isDate(this.date) ? this.date : this.getToday(); }
1730
+ currentView() { const views = this.getViews().filter((view) => CALENDAR_VIEWS.includes(view)); return views.includes(this.view) ? this.view : (views[0] ?? 'month'); }
1731
+ shift(direction) { this.date = this.currentView() === 'week' ? addDays(this.anchor(), 7 * direction) : addMonths(this.anchor().slice(0, 8) + '01', direction); }
1732
+ previousPeriod() { this.shift(-1); }
1733
+ nextPeriod() { this.shift(1); }
1734
+ goToToday() { this.date = this.getToday(); }
1735
+ setView(view) { if (typeof view === 'string' && this.getViews().includes(view) && CALENDAR_VIEWS.includes(view))
1736
+ this.view = view; }
1737
+ showDay(date) {
1738
+ if (!isDate(date))
1739
+ return;
1740
+ this.date = date;
1741
+ const views = this.getViews();
1742
+ this.view = views.includes('list') ? 'list' : views.includes('week') ? 'week' : this.currentView();
1743
+ }
1744
+ datesClickable() { return this.dateAction() !== null || this.onDateClick !== CalendarPage.prototype.onDateClick; }
1745
+ eventsClickable() { return this.eventAction() !== null || this.onEventClick !== CalendarPage.prototype.onEventClick; }
1746
+ async selectDate(date) { if (isDate(date) && this.datesClickable())
1747
+ await this.onDateClick(date); }
1748
+ async selectEvent(id) {
1749
+ if (!this.eventsClickable())
1750
+ return;
1751
+ const event = await this.findEvent(id);
1752
+ if (event)
1753
+ await this.onEventClick(event);
1754
+ }
1755
+ async renderContent() {
1756
+ const view = this.currentView();
1757
+ this.view = view;
1758
+ const anchor = this.anchor();
1759
+ const events = await this.getEvents({ ...visibleRange(view, anchor, this.firstDay()), view });
1760
+ const calendar = renderCalendar({
1761
+ heading: this.getCalendarHeading(), view, views: this.getViews().filter((candidate) => CALENDAR_VIEWS.includes(candidate)), anchor, today: this.getToday(), firstDay: this.firstDay(), locale: this.getLocale(), events,
1762
+ maxEventsPerDay: this.getMaxEventsPerDay(), datesClickable: this.datesClickable(), eventsClickable: this.eventsClickable(), labels: this.getLabels(),
1763
+ renderEvent: (event, context) => this.renderEvent(event, context), renderDayFooter: (day, dayEvents) => this.renderDayFooter(day, dayEvents), toolbarEnd: this.renderToolbarEnd(),
1764
+ });
1765
+ const extra = this.getExtraClasses().filter((name) => /^[A-Za-z0-9_:-]+$/.test(name));
1766
+ const style = Object.entries(this.getStyle()).filter(([name, value]) => /^--[a-z0-9-]+$/.test(name) && /^[^;{}<>]+$/.test(value)).map(([name, value]) => `${name}: ${value};`).join(' ');
1767
+ return widgetSection(calendar.replace('<div class="nv-cal ', `<div${style ? ` style="${e(style)}"` : ''} class="nv-cal ${extra.length ? `${extra.join(' ')} ` : ''}`));
1768
+ }
1769
+ }
1770
+ /** Header-less section used by the built-in widgets. */
1771
+ const widgetSection = (content) => `<section x-data="{
1772
+ isCollapsed: false ,
1773
+ }" class="fi-section"><div class="fi-section-content-ctn"><div class="fi-section-content">${content}</div></div></section>`;
1774
+ /** Signed-in user's account: avatar, name, logout. */
1775
+ export class AccountWidget extends Widget {
1776
+ static isLazy = false;
1777
+ render() {
1778
+ const { req, res, panel, user } = this._ctx;
1779
+ const logout = t('filament-panels::widgets/account-widget.actions.logout.label');
1780
+ const loading = 'wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled"';
1781
+ return this.wrap(widgetSection(`<img alt="${e(t('filament-panels::layout.avatar.alt', { name: user.name }))}" class="fi-avatar fi-circular fi-size-lg fi-user-avatar" src="${e(panel.getUserAvatarUrl(user))}" loading="lazy" /><div class="fi-account-widget-main"><h2 class="fi-account-widget-heading">${e(t('filament-panels::widgets/account-widget.welcome'))}</h2><p class="fi-account-widget-user-name">${e(user.name)}</p></div><form action="${e(panel.getLogoutUrl())}" method="post" class="fi-account-widget-logout-form"><input type="hidden" name="_token" value="${e(csrfToken(req, res))}" autocomplete="off"><button class="fi-icon-btn fi-size-md" title="${e(logout)}" aria-label="${e(logout)}" type="submit" ${loading}>${icon('m-arrow-left-end-on-rectangle')}</button><button class="fi-btn fi-size-md fi-labeled-from-sm" type="submit" ${loading}>${icon('m-arrow-left-end-on-rectangle')}${e(logout)}</button></form>`), ['fi-account-widget']);
1782
+ }
1783
+ }
1784
+ /** Navette version and useful links. */
1785
+ export class NavetteInfoWidget extends Widget {
1786
+ static isLazy = false;
1787
+ static homeUrl = 'https://github.com/NavetteJS';
1788
+ static documentationUrl = 'https://github.com/NavetteJS';
1789
+ static githubUrl = 'https://github.com/NavetteJS';
1790
+ static version = JSON.parse(readFileSync(dir('../package.json'), 'utf8')).version;
1791
+ render() {
1792
+ const WidgetClass = this.constructor;
1793
+ 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>`;
1794
+ 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>';
1795
+ return this.wrap(widgetSection(`<div class="fi-filament-info-widget-main"><a href="${e(WidgetClass.homeUrl)}" rel="noopener noreferrer" target="_blank"><svg aria-label="Navette" fill="currentColor" role="img" viewBox="0 0 96 24" xmlns="http://www.w3.org/2000/svg" class="fi-filament-info-widget-logo"><text x="0" y="19" font-size="22" font-weight="700" letter-spacing="-0.5">Navette</text></svg></a><p class="fi-filament-info-widget-version">v${e(WidgetClass.version)}</p></div><div class="fi-filament-info-widget-links">${link(WidgetClass.documentationUrl, icon('m-book-open'), t('filament-panels::widgets/filament-info-widget.actions.open_documentation.label'))}${link(WidgetClass.githubUrl, github, t('filament-panels::widgets/filament-info-widget.actions.open_github.label'))}</div>`), ['fi-filament-info-widget']);
1796
+ }
1797
+ }
1798
+ /** Table properties a list page exposes to its widgets. */
1799
+ const PAGE_TABLE_PROPERTIES = ['activeTab', 'paginators', 'tableColumnSearches', 'tableFilters', 'tableRecordsPerPage', 'tableSearch', 'tableSort'];
1800
+ /** One stat of a StatsOverviewWidget. */
1801
+ export class Stat {
1802
+ _description = null;
1803
+ _descriptionIcon = null;
1804
+ _descriptionIconPosition = 'after';
1805
+ _color = null;
1806
+ _icon = null;
1807
+ _chart = null;
1808
+ _url = null;
1809
+ label;
1810
+ value;
1811
+ constructor(label, value) { this.label = label; this.value = value; }
1812
+ static make(label, value) { return new Stat(label, value); }
1813
+ description(text) { this._description = text; return this; }
1814
+ descriptionIcon(name, position = 'after') { this._descriptionIcon = name; this._descriptionIconPosition = position; return this; }
1815
+ /** Color of the description and chart. */
1816
+ color(color) { this._color = color; return this; }
1817
+ icon(name) { this._icon = name; return this; }
1818
+ /** Trend chart: a list of values, or values keyed by label. */
1819
+ chart(values) { this._chart = values; return this; }
1820
+ url(url) { this._url = url; return this; }
1821
+ hasChart() { return this._chart !== null; }
1822
+ toHtml(panel, key) {
1823
+ const tag = this._url ? 'a' : 'div';
1824
+ const color = this._color ?? 'gray';
1825
+ const filled = this.value !== null && this.value !== undefined && this.value !== '';
1826
+ const descriptionIcon = this._descriptionIcon ? icon(this._descriptionIcon) : '';
1827
+ const description = this._description
1828
+ ? `<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>`
1829
+ : '';
1830
+ const chart = this._chart
1831
+ ? `<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({
1832
+ key: ${e(js(key))},
1833
+ labels: ${e(js(Array.isArray(this._chart) ? this._chart.map((_, i) => i) : Object.keys(this._chart)))},
1834
+ values: ${e(js(Object.values(this._chart)))},
1835
+ })" 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>`
1836
+ : '';
1837
+ return `<${tag}${this._url ? ` href="${e(this._url)}"` : ''} class="fi-wi-stats-overview-stat"><div class="fi-wi-stats-overview-stat-content"><div class="fi-wi-stats-overview-stat-label-ctn">${this._icon ? icon(this._icon) : ''}<span class="fi-wi-stats-overview-stat-label">${e(this.label)}</span></div>${filled ? `<div class="fi-wi-stats-overview-stat-value">${e(String(this.value))}</div>` : '<div class="fi-wi-stats-overview-stat-placeholder"></div>'}${description}</div>${chart}</${tag}>`;
1838
+ }
1839
+ }
1840
+ /** A row of stats. */
1841
+ export class StatsOverviewWidget extends Widget {
1842
+ static columnSpan = 'full';
1843
+ /** Polling interval, or `null` to disable polling. */
1844
+ static pollingInterval = '5s';
1845
+ /** Row columns: three, or four when the number of stats fits better. */
1846
+ getColumns(count) { return count < 3 ? 3 : count % 3 !== 1 ? 3 : 4; }
1847
+ async render() {
1848
+ const stats = await this.getStats();
1849
+ const columns = this.getColumns(stats.length);
1850
+ const anonymous = `x-data="filamentSchemaComponent(${e(js({ key: null, exposedMethods: [], path: '', containerPath: '' }))})"`;
1851
+ // A stat with a chart gets an id (`stats-overview-stat-<n>`), hence a component key; the others stay anonymous.
1852
+ const cells = stats.map((stat, index) => {
1853
+ if (!stat.hasChart())
1854
+ return `<div ${anonymous} class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component">${stat.toHtml(this.panel, `${this.id}.stats.${index}`)}</div></div>`;
1855
+ const id = `stats-overview-stat-${index}`, key = `content.${id}`;
1856
+ return `<div wire:partial="schema-component::${key}" x-data="filamentSchemaComponent(${e(js({ key, exposedMethods: [], path: '', containerPath: '' }))})" wire:key="${e(`${this.id}.${key}`)}" class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component">${stat.toHtml(this.panel, id)}</div></div>`;
1857
+ }).join('');
1858
+ const interval = this.constructor.pollingInterval;
1859
+ const poll = interval && /^[0-9]+(ms|s)$/.test(interval) ? ` wire:poll.${interval}` : '';
1860
+ return this.wrap(`<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'content' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));"><div ${anonymous} class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component fi-grid-ctn"><div${poll} class="fi-sc-section"><section x-data="{
1861
+ isCollapsed: false,
1862
+ }" 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']);
1863
+ }
1864
+ }
1865
+ /**
1866
+ * Chart.js chart. A subclass provides `getType()` (`'line'`, `'bar'`, `'pie'`, `'doughnut'`, `'radar'`, `'polarArea'`,
1867
+ * `'scatter'`, `'bubble'`) and `getData()`. Rendering happens in the browser; the server only sends new data when it
1868
+ * has changed (checksum).
1869
+ */
1870
+ export class ChartWidget extends Widget {
1871
+ static callable = ['updateChartData'];
1872
+ static locked = ['dataChecksum'];
1873
+ /** Polling interval, or `null` to disable polling. */
1874
+ static pollingInterval = '5s';
1875
+ static heading = null;
1876
+ static description = null;
1877
+ /** Color of datasets that do not set one. */
1878
+ static color = 'primary';
1879
+ /** Maximum chart height (`'300px'`). */
1880
+ static maxHeight = null;
1881
+ static isCollapsible = false;
1882
+ dataChecksum = null;
1883
+ /** Selected filter, one of `getFilters()`. */
1884
+ filter = null;
1885
+ _cachedData = null;
1886
+ getData() { return {}; }
1887
+ /** Chart.js options. */
1888
+ getOptions() { return null; }
1889
+ /** Filters offered in the header: value → label. */
1890
+ getFilters() { return null; }
1891
+ getHeading() { return this.constructor.heading; }
1892
+ getDescription() { return this.constructor.description; }
1893
+ getColor() { return this.constructor.color; }
1894
+ getMaxHeight() { return this.constructor.maxHeight; }
1895
+ /** Text read by screen readers instead of the chart. */
1896
+ getChartAssistiveContent() { return null; }
1897
+ async getCachedData() { return (this._cachedData ??= await this.getData()); }
1898
+ async generateDataChecksum() { return createHash('md5').update(JSON.stringify(await this.getCachedData())).digest('hex'); }
1899
+ async mount() { this.dataChecksum = await this.generateDataChecksum(); }
1900
+ /** Called by polling and before each render: only emits data when it has changed. */
1901
+ async updateChartData() {
1902
+ const checksum = await this.generateDataChecksum();
1903
+ if (checksum === this.dataChecksum)
1904
+ return;
1905
+ this.dataChecksum = checksum;
1906
+ this.dispatch('updateChartData', { data: await this.getCachedData() });
1907
+ }
1908
+ async render() {
1909
+ await this.updateChartData();
1910
+ const WidgetClass = this.constructor;
1911
+ const heading = this.getHeading(), description = this.getDescription(), filters = this.getFilters(), type = this.getType(), maxHeight = this.getMaxHeight();
1912
+ const hasMaxHeight = maxHeight !== null && maxHeight !== '' && maxHeight !== '100%';
1913
+ const data = await this.getCachedData();
1914
+ const isEmpty = Object.keys(data).length === 0;
1915
+ const assistive = isEmpty ? null : this.getChartAssistiveContent();
1916
+ const label = [heading, description].filter((value) => value !== null && value !== '').join('. ').trim();
1917
+ const collapsible = WidgetClass.isCollapsible;
1918
+ const interval = WidgetClass.pollingInterval;
1919
+ const poll = interval && /^[0-9]+(ms|s)$/.test(interval) ? ` wire:poll.${interval}="updateChartData"` : '';
1920
+ const filter = filters
1921
+ ? `<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>`
1922
+ : '';
1923
+ const hasText = (heading !== null && heading !== '') || (description !== null && description !== '');
1924
+ const hasHeader = hasText || filter !== '';
1925
+ const header = hasHeader
1926
+ ? `<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>`
1927
+ : '';
1928
+ const colors = ['bg', 'border', 'grid', 'text', 'tooltip-bg', 'tooltip-text', 'tooltip-border'];
1929
+ const refs = ['background', 'border', 'grid', 'text', 'tooltipBackground', 'tooltipText', 'tooltipBorder'];
1930
+ 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({
1931
+ cachedData: ${e(js(data))},
1932
+ options: ${e(js(this.getOptions()))},
1933
+ type: ${e(js(type))},
1934
+ })" class="${cls([...this.panel.colors.getComponentClasses('stat-chart', this.getColor()), 'fi-wi-chart-frame', 'fi-wi-chart-canvas-ctn', { 'fi-wi-chart-frame-no-aspect-ratio': hasMaxHeight }])}"><canvas x-ref="canvas" aria-hidden="true" style="width: 100%; ${hasMaxHeight ? `max-height: ${e(maxHeight)}` : 'height: 100%; max-height: 100%'}"></canvas>${colors.map((name, index) => `<span aria-hidden="true" x-ref="${refs[index]}ColorElement" class="fi-wi-chart-${name}-color"></span>`).join('')}</div>`;
1935
+ const empty = isEmpty
1936
+ ? `<div class="${cls(['fi-wi-chart-frame', { 'fi-wi-chart-frame-no-aspect-ratio': hasMaxHeight }])}"${hasMaxHeight ? ` style="min-height: ${e(maxHeight)}"` : ''}>${emptyState({ heading: t('filament-widgets::chart.empty.heading'), icon: 'o-x-mark', iconColor: 'gray', contained: false })}</div>`
1937
+ : '';
1938
+ const body = `<div${poll}${isEmpty ? ' style="display: none"' : ''}><div class="fi-wi-chart-image"${label ? ` role="img" aria-label="${e(label)}"` : ''}>${frame}</div>${assistive ? `<div class="fi-wi-chart-assistive-content fi-sr-only"><p>${e(assistive)}</p></div>` : ''}</div>${empty}`;
1939
+ return this.wrap(`<section x-data="{
1940
+ isCollapsed: false ,
1941
+ }"${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 &amp;&amp; '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']);
1942
+ }
1943
+ }
1944
+ /**
1945
+ * Table in a widget: search, sort, simple pagination (previous / next).
1946
+ * A subclass provides `table(table)` and `getDataAdapterForTable()`. No actions or filters yet.
1947
+ */
1948
+ export class TableWidget extends Widget {
1949
+ static columnSpan = 'full';
1950
+ static callable = ['sortTable', 'gotoPage', 'nextPage', 'previousPage', 'applyTableColumnManager', 'resetTableColumnManager'];
1951
+ /** Table heading; derived from the class name by default (`FlaggedOrders` → "Flagged Orders"). */
1952
+ static heading = null;
1953
+ tableSearch = '';
1954
+ tableSort = null;
1955
+ tableColumnSearches = {};
1956
+ tableRecordsPerPage = 10;
1957
+ paginators = { page: 1 };
1958
+ tableColumns = [];
1959
+ getTableHeading() {
1960
+ const WidgetClass = this.constructor;
1961
+ return WidgetClass.heading ?? WidgetClass.name.replace(/Widget$/, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2');
1962
+ }
1963
+ getTable() { return this.table(new Table().heading(this.getTableHeading()).paginationMode('simple')); }
1964
+ mount() {
1965
+ const table = this.getTable();
1966
+ this.tableRecordsPerPage = table.getDefaultPageOption();
1967
+ this.tableColumns = table.getDefaultColumnState();
1968
+ initializeTableGrouping(table, this);
1969
+ }
1970
+ tableGrouping = null;
1971
+ sortTable(column = null, direction = null) { sortTable(this, column, direction); }
1972
+ gotoPage(page, _pageName = 'page') { this.paginators.page = Math.max(1, Math.floor(Number(page)) || 1); }
1973
+ nextPage(_pageName = 'page') { this.paginators.page = (Number(this.paginators.page) || 1) + 1; }
1974
+ previousPage(_pageName = 'page') { this.paginators.page = Math.max(1, (Number(this.paginators.page) || 1) - 1); }
1975
+ applyTableColumnManager(state = null, _wasReordered = false) { if (state)
1976
+ this.tableColumns = sanitizeColumnState(this.getTable(), state); }
1977
+ resetTableColumnManager() { this.tableColumns = this.getTable().getDefaultColumnState(); }
1978
+ updatedTableSearch() { this.paginators.page = 1; }
1979
+ updatedTableColumnSearches() { this.paginators.page = 1; }
1980
+ updatedTableRecordsPerPage() { this.paginators.page = 1; }
1981
+ updatedTableSort() { this.paginators.page = 1; }
1982
+ async render() {
1983
+ const scopes = [this.constructor];
1984
+ 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({
1985
+ livewireId: ${js(this.id)},
1986
+ })" style="height: 0"></div>` })}${renderHook(WidgetsRenderHook.TABLE_WIDGET_END, scopes)}`, ['fi-wi-table']);
1987
+ }
1988
+ }
1989
+ /** For widgets that receive the dashboard filters: `static reactive = PAGE_FILTERS` plus a `pageFilters` property. */
1990
+ export const PAGE_FILTERS = ['pageFilters'];
1991
+ /**
1992
+ * Stats widget bound to a list page's table: it receives the table state (search, filters, tab…)
1993
+ * and can aggregate exactly the records the table displays.
1994
+ */
1995
+ export class PageTableStatsWidget extends StatsOverviewWidget {
1996
+ /** List page whose table is observed. */
1997
+ static tablePage;
1998
+ static reactive = PAGE_TABLE_PROPERTIES;
1999
+ activeTab = null;
2000
+ paginators = { page: 1 };
2001
+ tableColumnSearches = {};
2002
+ tableFilters = {};
2003
+ tableRecordsPerPage = 10;
2004
+ tableSearch = '';
2005
+ tableSort = null;
2006
+ /** Aggregates over the table's current query. The received state goes through the same validation as on the page. */
2007
+ async aggregatePageTable(columns = {}, modify = null) {
2008
+ const PageClass = this.constructor.tablePage();
2009
+ const page = new PageClass();
2010
+ for (const property of PAGE_TABLE_PROPERTIES)
2011
+ page[property] = this[property];
2012
+ const query = getTableQuery(page.getTable(), page);
2013
+ // Extra restriction for one stat ("among the displayed rows, those that…").
2014
+ if (modify)
2015
+ (query.modifiers ??= []).push(modify);
2016
+ return PageClass.resource.adapter.aggregate(query, columns);
2017
+ }
2018
+ /** Monthly trend over the table's current query (see `ObjectionAdapter.trendPerMonth`). */
2019
+ async trendPageTable(options) {
2020
+ const PageClass = this.constructor.tablePage();
2021
+ const page = new PageClass();
2022
+ for (const property of PAGE_TABLE_PROPERTIES)
2023
+ page[property] = this[property];
2024
+ return PageClass.resource.adapter.trendPerMonth(getTableQuery(page.getTable(), page), options);
2025
+ }
2026
+ }
2027
+ /** Schema component that embeds the host component's table. */
2028
+ export class EmbeddedTable extends SchemaComponent {
2029
+ static make() { return new EmbeddedTable(); }
2030
+ async toHtml() { return this.livewire.renderTable(); }
2031
+ }
2032
+ /** Relation manager tabs of an edit page: only the active tab's manager is mounted, as a lazy child component. */
2033
+ class RelationManagerTabs extends SchemaComponent {
2034
+ constructor() { super(); this._keyName = 'relationManagerTabs'; }
2035
+ getExposedMethods() { return ['getLazyLoadedTabBadges', 'getDeferredTabBadges']; }
2036
+ getLazyLoadedTabBadges() { return {}; }
2037
+ getDeferredTabBadges() { return {}; }
2038
+ async toHtml() {
2039
+ const page = this.livewire;
2040
+ const managers = page.resource.relations;
2041
+ const key = this.getKey();
2042
+ const id = page.id;
2043
+ const active = Math.min(Math.max(Math.floor(Number(page.activeRelationManager)) || 0, 0), managers.length - 1);
2044
+ const tabId = (index) => `fi-sc-tab-${createHash('md5').update(`${key}.${index}`).digest('hex')}`;
2045
+ 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>`);
2046
+ const panels = [];
2047
+ for (const [index, manager] of managers.entries()) {
2048
+ let content = '';
2049
+ if (index === active) {
2050
+ const componentName = page._runtime.nameOf(manager);
2051
+ const childKey = `${key}.${index}.${componentName}`;
2052
+ const child = await page.mountChild(componentName, { ownerRecord: page.recordKey }, `${id}.${childKey}`, { lazy: true });
2053
+ content = `<div class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));"><div wire:partial="schema-component::${e(childKey)}" x-data="filamentSchemaComponent(${e(js({ key: childKey, exposedMethods: [], path: '', containerPath: '' }))})" wire:key="${e(`${id}.${childKey}`)}" class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component">${child}</div></div></div>`;
2054
+ }
2055
+ panels.push(`<div aria-labelledby="${tabId(index)}" id="${key}.${index}" role="tabpanel" tabindex="0" wire:key="${id}.${key}.${index}.container" x-show="true" x-bind:class="{ 'fi-active': isTabSelected('${index}') }" x-on:expand="revealTab('${index}')" x-data="filamentSchemaComponent(${e(js({ key: `${key}.${index}`, exposedMethods: [], path: '', containerPath: '' }))})" class="fi-sc-tabs-tab">${content}</div>`);
2056
+ }
2057
+ return `<div x-data="tabsSchemaComponent({
2058
+ activeTab: ${active + 1},
2059
+ hasTabPanels: true,
2060
+ isScrollable: true,
2061
+ isVertical: false,
2062
+ livewireId: ${e(js(id))},
2063
+ livewireProperty: 'activeRelationManager',
2064
+ tab: '${active}',
2065
+ })" x-load x-load-src="${e(page.panel.assetUrl('js/filament/schemas/components/tabs.js'))}" wire:ignore.self id="${key}" wire:key="${id}.${key}.container" class="fi-sc-tabs"><input type="hidden" x-ref="tabsData" data-active-tab="${active}" /><div x-ref="tabsHeader" class="fi-tabs"><div x-ref="tablist" role="tablist" x-bind:role="hasVisibleTabHeaders ? 'tablist' : 'group'" aria-label="${e(t('filament::components/tabs.label'))}" x-on:keydown="handleKeydown($event)" data-prevent-disabled-clicks wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" wire:target="activeRelationManager" class="fi-sc-tabs-tablist">${tabs.join('')}</div></div>${panels.join('')}</div>`;
2066
+ }
2067
+ }
2068
+ /** List page tab: restricts the table query and can show a badge. */
2069
+ export class Tab {
2070
+ _label;
2071
+ _badge = null;
2072
+ _badgeColor = 'primary';
2073
+ _hasBadgeColor = false;
2074
+ _query = null;
2075
+ constructor(label = null) { this._label = label; }
2076
+ static make(label = null) { return new Tab(label); }
2077
+ /** Badge computed after the page is displayed. */
2078
+ badge(resolver) { this._badge = resolver; return this; }
2079
+ badgeColor(color) { this._badgeColor = color; this._hasBadgeColor = true; return this; }
2080
+ query(modifier) { this._query = modifier; return this; }
2081
+ getLabel(key) { return this._label ?? ucfirst(key.replace(/[_-]+/g, ' ')); }
2082
+ hasBadge() { return this._badge !== null; }
2083
+ async getBadge() { return this._badge ? this._badge() : null; }
2084
+ getBadgeColor() { return this._badgeColor; }
2085
+ /** False until `badgeColor()` is called: a lazy-loaded badge then gets no color class. */
2086
+ hasBadgeColor() { return this._hasBadgeColor; }
2087
+ getQuery() { return this._query; }
2088
+ }
2089
+ /** List page tabs: hidden when the page declares none. */
2090
+ class ResourceTabs extends SchemaComponent {
2091
+ constructor() { super(); this._keyName = 'resourceTabs'; this._hidden = ({ livewire }) => Object.keys(livewire.getTabs()).length === 0; }
2092
+ getExposedMethods() { return ['getLazyLoadedTabBadges', 'getDeferredTabBadges']; }
2093
+ getDeferredTabBadges() { return {}; }
2094
+ /** Tab badges, requested by the browser once the page is displayed. */
2095
+ async getLazyLoadedTabBadges() {
2096
+ const page = this.livewire;
2097
+ const badges = {};
2098
+ for (const [key, tab] of Object.entries(page.getTabs())) {
2099
+ if (!tab.hasBadge())
2100
+ continue;
2101
+ badges[key] = { badge: await tab.getBadge(), badgeColorClasses: tab.hasBadgeColor() ? page.panel.colors.getComponentClasses('badge', tab.getBadgeColor()).join(' ') : '', badgeColorStyles: '', badgeTooltip: null, badgeIconHtml: null, badgeIconPosition: 'before' };
2102
+ }
2103
+ return badges;
2104
+ }
2105
+ async toHtml() {
2106
+ const page = this.livewire;
2107
+ const tabs = Object.entries(page.getTabs());
2108
+ const key = this.getKey();
2109
+ const id = page.id;
2110
+ const active = page.getActiveTabKey();
2111
+ const q = (k) => `'${k.replace(/[^A-Za-z0-9_-]/g, '')}'`;
2112
+ const buttons = tabs.map(([k, tab]) => {
2113
+ const d = `deferredBadges[${q(k)}]`;
2114
+ const badge = tab.hasBadge() ? `<span x-show="isLoadingDeferredBadges" x-cloak class="fi-tabs-item-badge-placeholder">${loadingIndicator(new Attributes(), 'sm')}</span><template x-if="
2115
+ ! isLoadingDeferredBadges &&
2116
+ ${d}?.badge != null
2117
+ "><span x-bind:class="
2118
+ 'fi-badge fi-size-sm ' +
2119
+ (${d}?.badgeColorClasses ?? '')
2120
+ " x-bind:style="${d}?.badgeColorStyles ?? ''" x-init="
2121
+ let tooltip = ${d}?.badgeTooltip
2122
+ if (tooltip) {
2123
+ window.tippy?.($el, {
2124
+ content: tooltip,
2125
+ theme: $store.theme,
2126
+ })
2127
+ }
2128
+ "><template x-if="
2129
+ ${d}?.badgeIconHtml &&
2130
+ ${d}?.badgeIconPosition !== 'after'
2131
+ "><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="
2132
+ ${d}?.badgeIconHtml &&
2133
+ ${d}?.badgeIconPosition === 'after'
2134
+ "><span x-html="${d}.badgeIconHtml"></span></template></span></template>` : '';
2135
+ return `<button id="fi-sc-tab-${createHash('md5').update(`${key}.${k}`).digest('hex')}" aria-pressed="${k === active ? 'true' : 'false'}" x-bind:aria-pressed="isTabSelected(${q(k)})" tabindex="0" x-bind:tabindex="getTabIndex(${q(k)})" data-tab-key="${e(k)}" x-bind:data-tab-available="Boolean(true).toString()" x-show="true" x-on:click.capture="if (! selectTab(${q(k)}, $event)) { $event.preventDefault(); $event.stopImmediatePropagation() }" x-bind:class="{ 'fi-active': isTabSelected(${q(k)}) }" type="button" wire:click="$set('activeTab', ${k === '' ? 'null' : q(k)})" class="${cls(['fi-tabs-item', { 'fi-active': k === active }])}"><span class="fi-tabs-item-label">${e(tab.getLabel(k))}</span>${badge}</button>`;
2136
+ });
2137
+ return `<div x-data="tabsSchemaComponent({
2138
+ activeTab: ${Math.max(tabs.findIndex(([k]) => k === active), 0) + 1},
2139
+ hasTabPanels: false,
2140
+ isScrollable: true,
2141
+ isVertical: false,
2142
+ livewireId: ${e(js(id))},
2143
+ livewireProperty: 'activeTab',
2144
+ tab: ${q(active)},
2145
+ })" 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="{
2146
+ deferredBadges: {},
2147
+ isLoadingDeferredBadges: true,
2148
+ async init() {
2149
+ try {
2150
+ const badges = await this.$getLazyLoadedTabBadges()
2151
+ this.deferredBadges = badges ?? {}
2152
+ } finally {
2153
+ this.isLoadingDeferredBadges = false
2154
+ }
2155
+ },
2156
+ }" x-ref="tabsHeader" class="fi-tabs"><div x-ref="tablist" role="group" aria-label="${e(t('filament-panels::resources/pages/list-records.tabs.label'))}" x-on:keydown="handleKeydown($event)" data-prevent-disabled-clicks wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" wire:target="activeTab" class="fi-sc-tabs-tablist">${buttons.join('')}</div></div></div>`;
2157
+ }
2158
+ }
2159
+ /** Resource list page: table state and methods called by the browser. */
2160
+ export class ListRecords extends Page {
2161
+ static callable = ['updateTableColumnState', 'sortTable', 'gotoPage', 'nextPage', 'previousPage', 'applyTableFilters', 'resetTableFiltersForm', 'removeTableFilters', 'removeTableFilter', 'applyTableColumnManager', 'resetTableColumnManager', 'toggleTableReordering', 'reorderTable'];
2162
+ static resource;
2163
+ tableSearch = '';
2164
+ tableSort = null;
2165
+ tableColumnSearches = {};
2166
+ tableRecordsPerPage = 10;
2167
+ paginators = { page: 1 };
2168
+ // Selection: kept by the browser, sent along with the bulk action call.
2169
+ selectedTableRecords = [];
2170
+ deselectedTableRecords = [];
2171
+ isTrackingDeselectedTableRecords = false;
2172
+ // Filters: `tableDeferredFilters` is the form state, `tableFilters` the applied state (deferred filters by default).
2173
+ tableFilters = {};
2174
+ tableDeferredFilters = {};
2175
+ tableColumns = [];
2176
+ isTableReordering = false;
2177
+ /** Current table grouping (`id:direction`), chosen in the toolbar. */
2178
+ tableGrouping = null;
2179
+ updatedTableGrouping() { this.paginators.page = 1; }
2180
+ /** Toggles manual reordering mode. */
2181
+ async toggleTableReordering() { if (this.getTable().getReorderColumn() !== null && (await canOn(this, 'reorder'))) {
2182
+ this.isTableReordering = !this.isTableReordering;
2183
+ this.deselectAllTableRecords();
2184
+ } }
2185
+ /** Saves the new order: only keys belonging to the table are kept, each gets its position. */
2186
+ async reorderTable(order, _draggedRecordKey = null) {
2187
+ const column = this.getTable().getReorderColumn();
2188
+ if (column === null || !this.isTableReordering || !Array.isArray(order) || order.length > REORDER_LIMIT || !(await canOn(this, 'reorder')))
2189
+ return;
2190
+ const adapter = this.getDataAdapterForTable();
2191
+ const allowed = new Set(await adapter.keysInScope(order.filter((key) => typeof key === 'string' || typeof key === 'number').map(String)));
2192
+ let rank = 0;
2193
+ for (const key of order.map(String))
2194
+ if (allowed.delete(key))
2195
+ await adapter.update(key, { [column]: ++rank });
2196
+ }
2197
+ async mount(_params = {}) {
2198
+ const table = this.getTable();
2199
+ this.tableFilters = table.getDefaultFilterState();
2200
+ this.tableDeferredFilters = table.getDefaultFilterState();
2201
+ this.tableColumns = table.getDefaultColumnState();
2202
+ // Preselect the default group.
2203
+ initializeTableGrouping(table, this);
2204
+ // `?tableAction=view&tableActionRecord=12` (global search result link): the row action opens on arrival, with the usual checks.
2205
+ const query = (this._ctx?.req?.query ?? {});
2206
+ if (typeof query.tableAction === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(query.tableAction) && typeof query.tableActionRecord === 'string' && query.tableActionRecord.length <= 64) {
2207
+ try {
2208
+ await this.mountAction(query.tableAction, {}, { table: true, recordKey: query.tableActionRecord });
2209
+ }
2210
+ catch (error) {
2211
+ if (!(error instanceof WireError))
2212
+ throw error;
2213
+ }
2214
+ }
2215
+ }
2216
+ /** Filters form: one group per filter, bound to `tableDeferredFilters.<filter>`. */
2217
+ tableFiltersFormSchema(schema) {
2218
+ const table = this.getTable();
2219
+ // Above the content, filters spread over several columns; in the dropdown, a single one.
2220
+ const columns = table.getFiltersLayout() === 'above-content-collapsible' ? { sm: 2, lg: 3, xl: 4, '2xl': 5 } : 1;
2221
+ return schema.statePath('tableDeferredFilters').columns(columns).partiallyRendered().components(table.getFilters().map((filter) => {
2222
+ // A filter may provide its own form (query builder); otherwise it is a select.
2223
+ const custom = filter.getFormComponents?.();
2224
+ if (custom)
2225
+ return StatefulGroup.make(filter.name, custom).columnSpan(filter.getColumnSpan?.() ?? { default: 1, lg: 1 });
2226
+ const definition = filter.getFormDefinition();
2227
+ 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 });
2228
+ }));
2229
+ }
2230
+ applyTableFilters() {
2231
+ // Only filters declared by the table reach the applied state: each sanitizes its own state, or else only its known keys are kept.
2232
+ this.tableFilters = Object.fromEntries(this.getTable().getFilters().map((filter) => {
2233
+ const deferred = this.tableDeferredFilters?.[filter.name];
2234
+ if (filter.sanitizeState)
2235
+ return [filter.name, filter.sanitizeState(deferred)];
2236
+ return [filter.name, Object.fromEntries(Object.keys(filter.getDefaultState()).map((key) => [key, deferred?.[key] ?? null]))];
2237
+ }));
2238
+ this.paginators.page = 1;
2239
+ }
2240
+ /** Removes an active filter indicator: the field is cleared from both the applied state and the form. */
2241
+ removeTableFilter(filterName, field = null) {
2242
+ const filter = this.getTable().getFilters().find((candidate) => candidate.name === filterName);
2243
+ if (!filter)
2244
+ return;
2245
+ const current = this.tableFilters?.[filter.name];
2246
+ const next = filter.removeIndicator && typeof field === 'string' && current && typeof current === 'object' ? filter.removeIndicator(current, field) : filter.getDefaultState();
2247
+ this.tableFilters = { ...this.tableFilters, [filter.name]: next };
2248
+ this.tableDeferredFilters = { ...this.tableDeferredFilters, [filter.name]: JSON.parse(JSON.stringify(next)) };
2249
+ this.paginators.page = 1;
2250
+ }
2251
+ /** Live field in the filters form (e.g. a rule's operator changed): runs its `afterStateUpdated`. */
2252
+ async updatedTableDeferredFilters(_value, key) {
2253
+ if (!key)
2254
+ return;
2255
+ const path = `tableDeferredFilters.${key}`;
2256
+ const field = this.getSchema('tableFiltersForm').getFlatComponents().find((c) => c instanceof Field && c.getStatePath() === path);
2257
+ if (!field?.hasAfterStateUpdated())
2258
+ return;
2259
+ const container = path.split('.').slice(0, -1);
2260
+ await field.callAfterStateUpdated((name, value) => {
2261
+ if (!/^[A-Za-z0-9_]+$/.test(name))
2262
+ return;
2263
+ let node = this;
2264
+ for (const segment of container)
2265
+ node = node?.[segment];
2266
+ if (node && typeof node === 'object')
2267
+ node[name] = value;
2268
+ });
2269
+ }
2270
+ resetTableFiltersForm() {
2271
+ this.tableFilters = this.getTable().getDefaultFilterState();
2272
+ this.tableDeferredFilters = this.getTable().getDefaultFilterState();
2273
+ this.paginators.page = 1;
2274
+ this.dispatch('deselectAllTableRecords', [], { self: true });
2275
+ }
2276
+ removeTableFilters() { this.resetTableFiltersForm(); }
2277
+ applyTableColumnManager(state = null, _wasReordered = false) { if (state)
2278
+ this.tableColumns = sanitizeColumnState(this.getTable(), state); }
2279
+ resetTableColumnManager() { this.tableColumns = this.getTable().getDefaultColumnState(); }
2280
+ // --- list tabs: the empty key is the default tab (`null` in the browser)
2281
+ activeTab = null;
2282
+ getTabs() { return this.resource.tabs?.() ?? {}; }
2283
+ /** Active tab, coerced to a declared key (an unknown value from the browser means the default tab). */
2284
+ getActiveTabKey() {
2285
+ const tabs = this.getTabs();
2286
+ const key = this.activeTab ?? '';
2287
+ return Object.prototype.hasOwnProperty.call(tabs, key) ? key : (Object.keys(tabs)[0] ?? '');
2288
+ }
2289
+ updatedActiveTab() { this.paginators.page = 1; this.deselectAllTableRecords(); }
2290
+ modifyTableQuery(query) {
2291
+ const tab = this.getTabs()[this.getActiveTabKey()];
2292
+ const modifier = tab?.getQuery();
2293
+ if (modifier)
2294
+ (query.modifiers ??= []).push(modifier);
2295
+ }
2296
+ get resource() { return this.constructor.resource; }
2297
+ getHeaderWidgets() { return this.resource.headerWidgets ?? []; }
2298
+ /** The table state is passed to widgets, which can rerun the same query. */
2299
+ getWidgetData() { return Object.fromEntries(PAGE_TABLE_PROPERTIES.map((property) => [property, this[property]])); }
2300
+ rendersActionModalsInPage() { return false; }
2301
+ getBulkActions() { return this.getTable().getBulkActions() ?? [DeleteBulkAction.make()]; }
2302
+ /** Row actions declared by the table, with groups flattened. */
2303
+ getRecordActions() { return this.getTable().getRecordActions().flatMap((a) => (a instanceof ActionGroup ? a.getActions() : [a])); }
2304
+ getMountableActions(context) {
2305
+ if (typeof context.schemaComponent === 'string')
2306
+ return this.getSchemaComponentActions(context.schemaComponent);
2307
+ if (context.table === true && context.bulk === true)
2308
+ return this.getBulkActions();
2309
+ if (context.table === true && typeof context.recordKey === 'string')
2310
+ return this.getRecordActions();
2311
+ if (context.table === true)
2312
+ return this.getTable().getHeaderActions();
2313
+ return this.getHeaderActions();
2314
+ }
2315
+ /** Only known context shapes are kept; the record key is checked again in the database on mount. */
2316
+ resolveMountContext(clientContext) {
2317
+ const c = clientContext && typeof clientContext === 'object' ? clientContext : {};
2318
+ if (typeof c.schemaComponent === 'string' && c.schemaComponent.length <= 200)
2319
+ return { schemaComponent: c.schemaComponent };
2320
+ if (c.table === true && c.bulk === true)
2321
+ return { table: true, bulk: true };
2322
+ if (c.table === true && (typeof c.recordKey === 'string' || typeof c.recordKey === 'number'))
2323
+ return { table: true, recordKey: String(c.recordKey) };
2324
+ if (c.table === true)
2325
+ return { table: true };
2326
+ return {};
2327
+ }
2328
+ async resolveActionRecord(context) {
2329
+ return typeof context.recordKey === 'string' ? ((await this.resource.adapter.find(context.recordKey)) ?? null) : null;
2330
+ }
2331
+ /**
2332
+ * Writes an editable cell. Everything comes from the browser: the column must exist and be editable and visible, the record
2333
+ * must be in the table's scope and editable by the user, and the value must be valid.
2334
+ */
2335
+ async updateTableColumnState(columnName, recordKey, input) {
2336
+ // All checks (visible and editable column, scope, disabled state, authorization, validation, hooks) live in the tables package.
2337
+ return updateEditableColumnState(this.getTable(), this, this.getDataAdapterForTable(), columnName, recordKey, input, { authorize: (record) => canOn(this, 'update', record) });
2338
+ }
2339
+ /** Selected keys: the sent list, or, in "select all" mode, every record of the current query except deselected ones. */
2340
+ async getSelectedRecordKeys() {
2341
+ const clean = (list) => (Array.isArray(list) ? list.filter((k) => typeof k === 'string' || typeof k === 'number').map(String) : []);
2342
+ // The selection comes from the browser: it is narrowed to records this table can actually display.
2343
+ if (!this.isTrackingDeselectedTableRecords)
2344
+ return this.resource.adapter.keysInScope(clean(this.selectedTableRecords));
2345
+ const excluded = new Set(clean(this.deselectedTableRecords));
2346
+ return (await this.resource.adapter.listKeys(getTableQuery(this.getTable(), this))).filter((k) => !excluded.has(k));
2347
+ }
2348
+ deselectAllTableRecords() {
2349
+ this.selectedTableRecords = [];
2350
+ this.deselectedTableRecords = [];
2351
+ this.isTrackingDeselectedTableRecords = false;
2352
+ this.dispatch('deselectAllTableRecords', [], { self: true });
2353
+ }
2354
+ getTable() {
2355
+ const resource = this.resource;
2356
+ const table = new Table().pluralModelLabel(resource.getPluralModelLabel());
2357
+ // Simple resource: the row opens the edit modal; otherwise it links to the edit page.
2358
+ return resource.table(resource.isSimple ? table.recordAction('edit') : table.recordUrl((record) => resource.getUrl(resource.hasViewPage ? 'view' : 'edit', resource.adapter.getKey(record))));
2359
+ }
2360
+ async renderTable() {
2361
+ const resource = this.resource;
2362
+ const ctx = { livewire: this, component: null, operation: null, record: null, get: () => undefined, state: undefined };
2363
+ const bulkActions = [];
2364
+ for (const action of this.getBulkActions())
2365
+ if (await action.isVisible(ctx))
2366
+ bulkActions.push(await action.toGroupedHtml(ctx, { table: true, bulk: true }));
2367
+ const table = this.getTable();
2368
+ return renderTable(table, this, resource.adapter, {
2369
+ bulkActions, headerActions: await this.renderTableHeaderActions(ctx), after: await this.renderActionModals(),
2370
+ filtersForm: table.getFilters().length ? await this.getSchema('tableFiltersForm').toHtml() : null,
2371
+ rowActionsHtml: table.getRecordActions().length ? async (record) => {
2372
+ const key = resource.adapter.getKey(record);
2373
+ const recordCtx = { ...ctx, record };
2374
+ const hash = createHash('md5').update(key).digest('hex');
2375
+ const parts = [];
2376
+ for (const action of table.getRecordActions()) {
2377
+ if (action instanceof ActionGroup)
2378
+ parts.push(await action.toHtml(recordCtx, { recordKey: key, table: true }, (a) => `${this.id}.actions.${a.getName()}.${hash}`));
2379
+ else if (await action.isVisible(recordCtx))
2380
+ parts.push(await action.toLinkHtml(recordCtx, { recordKey: key, table: true }, `${this.id}.actions.${action.getName()}.${hash}`));
2381
+ }
2382
+ return parts.join('');
2383
+ } : undefined,
2384
+ canReorder: table.getReorderColumn() !== null ? await canOn(this, 'reorder') : undefined,
2385
+ rowActions: async (record) => resource.isSimple || table.getRecordActions().length || !(await canOn(this, 'update', record)) ? [] : [{ label: t('filament-actions::edit.single.label'), url: resource.getUrl('edit', resource.adapter.getKey(record)), icon: 'c-pencil-square', key: `edit.${createHash('md5').update(resource.adapter.getKey(record)).digest('hex')}` }],
2386
+ });
2387
+ }
2388
+ /** Table header actions (none on a list page: they go in the page header). */
2389
+ async renderTableHeaderActions(ctx) {
2390
+ const html = [];
2391
+ for (const action of this.getTable().getHeaderActions())
2392
+ if (await action.isVisible(ctx))
2393
+ html.push(await action.toHtml(ctx, { table: true }));
2394
+ return html;
2395
+ }
2396
+ sortTable(column = null, direction = null) { sortTable(this, column, direction); }
2397
+ gotoPage(page, _pageName = 'page') { this.paginators.page = Math.max(1, Math.floor(Number(page)) || 1); }
2398
+ nextPage(_pageName = 'page') { this.paginators.page = (Number(this.paginators.page) || 1) + 1; }
2399
+ previousPage(_pageName = 'page') { this.paginators.page = Math.max(1, (Number(this.paginators.page) || 1) - 1); }
2400
+ updatedTableSearch() { this.paginators.page = 1; }
2401
+ getDataAdapterForTable() { return this.resource.adapter; }
2402
+ getDataAdapter() { return this.resource.adapter; }
2403
+ updatedTableColumnSearches() { this.paginators.page = 1; }
2404
+ updatedTableRecordsPerPage() { this.paginators.page = 1; }
2405
+ updatedTableSort() { this.paginators.page = 1; }
2406
+ getBreadcrumbs() {
2407
+ if (this.resource.isSimple)
2408
+ return [{ label: '', url: null }];
2409
+ return [{ label: this.resource.getTitleCasePluralModelLabel(), url: this.resource.getUrl() }, { label: t('filament-panels::resources/pages/list-records.breadcrumb'), url: null }];
2410
+ }
2411
+ getHeaderActions() {
2412
+ // Simple resource: create in a modal; otherwise link to the create page.
2413
+ if (this.resource.isSimple)
2414
+ return [CreateAction.make(this.resource)];
2415
+ return [...(this.resource.listHeaderActions?.() ?? []), Action.make('create').label(t('filament-actions::create.single.label', { label: this.resource.modelLabel })).url(this.resource.getUrl('create')).authorize(({ livewire }) => canOn(livewire, 'create'))];
2416
+ }
2417
+ getPageClasses() { return ['fi-resource-list-records-page', `fi-resource-${this.resource.slug.replace(/\//g, '-')}`]; }
2418
+ contentSchema(schema) {
2419
+ 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)]);
2420
+ }
2421
+ }
2422
+ /**
2423
+ * Table of records related to another one: a child component of the edit page, loaded when it becomes visible.
2424
+ * Subclasses declare `relationship`, `adapterFor()`, `table()` and `form()`.
2425
+ */
2426
+ export class RelationManager extends ListRecords {
2427
+ static relationship = '';
2428
+ static modelLabel = 'record';
2429
+ static pluralModelLabel = null;
2430
+ static recordTitleAttribute = null;
2431
+ /** Data adapter limited to records related to the given owner. */
2432
+ static adapterFor(_ownerKey) { throw new Error('RelationManager.adapterFor() must be defined.'); }
2433
+ /** Policy for related records (same abilities as a resource policy). */
2434
+ static policy = null;
2435
+ isAccessAuthorized(ctx) { return this.resource.can('viewAny', ctx.user); }
2436
+ static table(table) { return table; }
2437
+ static form(schema) { return schema; }
2438
+ static getTitle() { return this.title ?? ucfirst(this.relationship.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase()); }
2439
+ /** Owner record key: set on mount by the page, never writable from the browser. */
2440
+ ownerRecord = '';
2441
+ static locked = ['ownerRecord'];
2442
+ _facade = null;
2443
+ /** What ListRecords expects from a resource, provided here by the relation manager. */
2444
+ get resource() {
2445
+ const manager = this.constructor;
2446
+ return (this._facade ??= {
2447
+ isSimple: true, slug: '', modelLabel: manager.modelLabel, relations: [], adapter: manager.adapterFor(this.ownerRecord),
2448
+ 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 }); },
2449
+ getPluralModelLabel: () => manager.pluralModelLabel ?? plural(manager.modelLabel),
2450
+ getTitleCasePluralModelLabel: () => (manager.pluralModelLabel ?? plural(manager.modelLabel)).split(' ').map(ucfirst).join(' '),
2451
+ getRecordTitle: (record) => (manager.recordTitleAttribute ? String(record?.[manager.recordTitleAttribute] ?? '') : manager.modelLabel),
2452
+ table: (table) => manager.table(table), form: (schema) => manager.form(schema.columns(2)), getUrl: () => '',
2453
+ });
2454
+ }
2455
+ getTable() {
2456
+ const manager = this.constructor;
2457
+ const table = manager.table(new Table().pluralModelLabel(this.resource.getPluralModelLabel()).heading(manager.getTitle()));
2458
+ // If there is an "edit" row action, clicking the row opens it.
2459
+ const hasEdit = table.getRecordActions().flatMap((a) => (a instanceof ActionGroup ? a.getActions() : [a])).some((a) => a.getName() === 'edit');
2460
+ return hasEdit ? table.recordAction('edit') : table;
2461
+ }
2462
+ // --- many-to-many: to be defined by managers offering Attach / Detach
2463
+ /** Attaches an existing record to the owner. */
2464
+ static attach(_ownerKey, _recordKey) { throw new Error('RelationManager.attach() must be defined.'); }
2465
+ /** Detaches records from the owner without deleting them. */
2466
+ static detach(_ownerKey, _recordKeys) { throw new Error('RelationManager.detach() must be defined.'); }
2467
+ /** Attachable records matching the search: [key, label]. */
2468
+ static searchAttachable(_ownerKey, _search) { throw new Error('RelationManager.searchAttachable() must be defined.'); }
2469
+ /** Label of an attachable record, or `null` if it is not attachable (also used as validation). */
2470
+ static attachableLabel(_ownerKey, _recordKey) { throw new Error('RelationManager.attachableLabel() must be defined.'); }
2471
+ getHeaderActions() { return []; }
2472
+ placeholder() {
2473
+ return `<div role="status" aria-busy="true" style="height: 8rem; ;" class="fi-section fi-loading-section fi-grid-col"><span class="fi-sr-only">${e(t('filament::components/loading-section.label'))}</span></div>`;
2474
+ }
2475
+ /** A relation manager has its own render hook locations, not the list page's. */
2476
+ contentSchema(schema) {
2477
+ return schema.components([new ResourceTabs(), RenderHook.make(PanelsRenderHook.RESOURCE_RELATION_MANAGER_BEFORE), EmbeddedTable.make(), RenderHook.make(PanelsRenderHook.RESOURCE_RELATION_MANAGER_AFTER)]);
2478
+ }
2479
+ async render() {
2480
+ return `<div class="fi-resource-relation-manager">${await this.getSchema('content').toHtml()}</div>`;
2481
+ }
2482
+ }
2483
+ /**
2484
+ * Page listing records related to a record: a table, with create, view and edit in modals.
2485
+ * Declared in the owner resource's `static relatedPages`, it is served at `<slug>/{record}/<name>`.
2486
+ */
2487
+ export class ManageRelatedRecords extends ListRecords {
2488
+ /** Owner resource: set when the page is registered. */
2489
+ static parentResource;
2490
+ static relationship = '';
2491
+ static modelLabel = 'record';
2492
+ static pluralModelLabel = null;
2493
+ static recordTitleAttribute = null;
2494
+ static policy = null;
2495
+ /** Data adapter limited to records related to the given owner. */
2496
+ static adapterFor(_ownerKey) { throw new Error('ManageRelatedRecords.adapterFor() must be defined.'); }
2497
+ static table(table) { return table; }
2498
+ static form(schema) { return schema; }
2499
+ static infolist(schema) { return schema; }
2500
+ static getTitle() { return this.title ?? ucfirst(this.relationship.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase()); }
2501
+ static getNavigationLabel() { return this.navigationLabel ?? this.getTitle(); }
2502
+ /** Owner record key: read from the URL on mount, never writable from the browser. */
2503
+ recordKey = '';
2504
+ static locked = ['recordKey'];
2505
+ _owner = null;
2506
+ _facade = null;
2507
+ get related() { return this.constructor; }
2508
+ async mount(params) {
2509
+ this.recordKey = String(params.record ?? '');
2510
+ await this.hydrate();
2511
+ await ListRecords.prototype.mount?.call(this, params);
2512
+ }
2513
+ /** The owner is reloaded and access to it checked on every request. */
2514
+ async hydrate() {
2515
+ const parent = this.related.parentResource;
2516
+ this._owner = await parent.adapter.find(this.recordKey);
2517
+ if (!this._owner)
2518
+ throw new WireError(404, 'Record not found.');
2519
+ if (!(await parent.can('view', this._ctx?.user ?? null, this._owner)))
2520
+ throw new WireError(403, 'Forbidden.');
2521
+ await ListRecords.prototype.hydrate?.call(this);
2522
+ }
2523
+ getRecordKey() { return this.recordKey; }
2524
+ /** What ListRecords expects from a resource, provided here by the page. */
2525
+ get resource() {
2526
+ const page = this.related;
2527
+ return (this._facade ??= {
2528
+ isSimple: true, hasViewPage: false, slug: page.parentResource.slug, modelLabel: page.modelLabel, relations: [], recordTitleAttribute: page.recordTitleAttribute, adapter: page.adapterFor(this.recordKey),
2529
+ 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 }); },
2530
+ getPluralModelLabel: () => page.pluralModelLabel ?? plural(page.modelLabel),
2531
+ getTitleCasePluralModelLabel: () => (page.pluralModelLabel ?? plural(page.modelLabel)).split(' ').map(ucfirst).join(' '),
2532
+ getRecordTitle: (record) => (page.recordTitleAttribute ? String(record?.[page.recordTitleAttribute] ?? '') : page.modelLabel),
2533
+ table: (table) => page.table(table), form: (schema) => page.form(schema), infolist: (schema) => page.infolist(schema), getUrl: () => '',
2534
+ });
2535
+ }
2536
+ getTable() { return this.related.table(new Table().pluralModelLabel(this.resource.getPluralModelLabel())); }
2537
+ getHeaderActions() { return []; }
2538
+ /** The page content is a group (tabs, if any, then the table). */
2539
+ contentSchema(schema) {
2540
+ return schema.components([Group.make([new ResourceTabs(), RenderHook.make(PanelsRenderHook.RESOURCE_RELATION_MANAGER_BEFORE), EmbeddedTable.make(), RenderHook.make(PanelsRenderHook.RESOURCE_RELATION_MANAGER_AFTER)]), new RelationManagerTabs().key(null).hidden()]);
2541
+ }
2542
+ getTitle() { return t('filament-panels::resources/pages/manage-related-records.title', { label: this.related.parentResource.getRecordTitle(this._owner), relationship: this.related.getTitle() }); }
2543
+ getSubNavigation() { return this.related.parentResource.getRecordSubNavigation(this); }
2544
+ getBreadcrumbs() {
2545
+ const parent = this.related.parentResource;
2546
+ return [
2547
+ { label: parent.getTitleCasePluralModelLabel(), url: parent.getUrl() },
2548
+ { label: parent.getRecordTitle(this._owner), url: parent.getUrl(parent.hasViewPage ? 'view' : 'edit', this.recordKey) },
2549
+ { label: this.related.getTitle(), url: null },
2550
+ ];
2551
+ }
2552
+ getPageClasses() { return ['fi-resource-manage-related-records-page', `fi-resource-${this.related.parentResource.slug.replace(/\//g, '-')}`, `fi-resource-record-${this.recordKey}`]; }
2553
+ }
2554
+ /** Previous page URL if it belongs to this site, otherwise the site root (without a trailing slash). */
2555
+ function previousUrlOf(req) {
2556
+ if (!req)
2557
+ return '';
2558
+ const origin = `${req.protocol}://${req.get('host')}`;
2559
+ try {
2560
+ const referer = new URL(String(req.get('referer') ?? ''));
2561
+ if (referer.origin === origin)
2562
+ return `${origin}${referer.pathname === '/' ? '' : referer.pathname}${referer.search}`;
2563
+ }
2564
+ catch { /* no usable Referer */ }
2565
+ return origin;
2566
+ }
2567
+ /** A theme's representative color (shade 500 of its primary color), for the menu swatch. */
2568
+ function swatchOf(theme, panel) {
2569
+ const primary = theme.colors?.primary;
2570
+ return (typeof primary === 'string' ? primary : primary?.[500]) ?? panel.colors.getColor('primary')?.[500] ?? 'currentColor';
2571
+ }
2572
+ /** A theme's font: without a URL, it is served by Bunny Fonts like the panel font. */
2573
+ function fontOf(font) {
2574
+ return font.url ? { family: font.family, url: font.url } : { family: font.family, url: `https://fonts.bunny.net/css?family=${encodeURIComponent(font.family.toLowerCase().replace(/ /g, '-'))}:400,500,600,700&display=swap`, preconnect: 'https://fonts.bunny.net' };
2575
+ }
2576
+ function emptyToNull(data) { return Object.fromEntries(Object.entries(data).map(([k, v]) => [k, v === '' ? null : v])); }
2577
+ /** Base class for a resource's form pages. */
2578
+ class ResourceFormPage extends Page {
2579
+ /** Live form field (`live()` + `afterStateUpdated()`): `set(name, value)` writes next to the updated field. */
2580
+ async updatedData(_value, key) {
2581
+ if (!key)
2582
+ return;
2583
+ const path = `data.${key}`;
2584
+ // The updated field first, then its parent fields (a Repeater recomputes a total when one of its rows changes).
2585
+ const fields = this.getSchema('form').getFlatComponents().filter((c) => c instanceof Field && c.hasAfterStateUpdated() && (c.getStatePath() === path || path.startsWith(`${c.getStatePath()}.`)))
2586
+ .sort((a, b) => b.getStatePath().length - a.getStatePath().length);
2587
+ for (const field of fields) {
2588
+ const container = field.getStatePath().split('.').slice(0, -1);
2589
+ await field.callAfterStateUpdated((name, value) => {
2590
+ if (!/^[A-Za-z0-9_]+$/.test(name))
2591
+ return;
2592
+ let node = this;
2593
+ for (const segment of container)
2594
+ node = node?.[segment];
2595
+ if (node && typeof node === 'object')
2596
+ node[name] = value;
2597
+ });
2598
+ }
2599
+ }
2600
+ static resource;
2601
+ data = {};
2602
+ get resource() { return this.constructor.resource; }
2603
+ getDataAdapter() { return this.resource.adapter; }
2604
+ getRecordKey() { return null; }
2605
+ getRecord() { return null; }
2606
+ /** Two columns from `lg` by default; the resource may decide otherwise. */
2607
+ formSchema(schema) {
2608
+ const form = this.resource.form(schema.columns(2).operation(this.getOperation()).record(this.getRecord()));
2609
+ const steps = this.getSteps();
2610
+ // Wizard: the steps replace the resource form's components.
2611
+ return steps === null ? form : form.columns(null).components([this.getWizardComponent(steps)]);
2612
+ }
2613
+ /**
2614
+ * Wizard steps: as soon as this method returns some, the page shows its form step by step,
2615
+ * with "Back" / "Next" and the submit button on the last step.
2616
+ */
2617
+ getSteps() { return null; }
2618
+ /** `true`: steps can be visited in any order, without validation along the way. */
2619
+ hasSkippableSteps() { return false; }
2620
+ /** Step shown on open (1-based). */
2621
+ getStartStep() { return 1; }
2622
+ getWizardComponent(steps) {
2623
+ const [submit] = this.getFormActions();
2624
+ return Wizard.make(steps).startOnStep(this.getStartStep()).cancelAction(this.cancelAction())
2625
+ .submitAction(submit.submit(null).action(this.getSubmitMethod()))
2626
+ .alpineSubmitHandler(`$wire.${this.getSubmitMethod()}()`).skippable(this.hasSkippableSteps()).contained(false);
2627
+ }
2628
+ contentSchema(schema) {
2629
+ // Wizard: no surrounding <form>; each step has its own and the wizard holds the actions.
2630
+ if (this.getSteps() !== null)
2631
+ return schema.components([EmbeddedSchema.make('form')]);
2632
+ return schema.components([
2633
+ Form.make([EmbeddedSchema.make('form')]).id('form').livewireSubmitHandler(this.getSubmitMethod())
2634
+ .footer([Actions.make(this.getFormActions()).alignment('start').key('form-actions')]),
2635
+ ]);
2636
+ }
2637
+ /** Where "Cancel" goes when the browser has no history: the list page, unless the page decides otherwise. */
2638
+ getCancelUrl() { return this.resource.getUrl(); }
2639
+ cancelAction() {
2640
+ return Action.make('cancel').label(t(`filament-panels::resources/pages/${this.getOperation()}-record.form.actions.cancel.label`)).color('gray')
2641
+ .alpineClickHandler(`document.referrer ? window.history.back() : Livewire.navigate(${js(this.getCancelUrl())})`);
2642
+ }
2643
+ /** Validates the form; on error, sets the errors on the component and notifies the browser. */
2644
+ async validateForm() {
2645
+ this._errors = await validateSchema(this.getSchema('form'));
2646
+ if (!Object.keys(this._errors).length)
2647
+ return true;
2648
+ this.dispatch('form-validation-error', { livewireId: this.id });
2649
+ return false;
2650
+ }
2651
+ }
2652
+ /** Record creation page. */
2653
+ export class CreateRecord extends ResourceFormPage {
2654
+ static callable = ['create', 'createAnother'];
2655
+ mount() { fillSchema(this.getSchema('form'), this.data); }
2656
+ getOperation() { return 'create'; }
2657
+ getSubmitMethod() { return 'create'; }
2658
+ getTitle() { return t('filament-panels::resources/pages/create-record.title', { label: this.resource.modelLabel.split(' ').map(ucfirst).join(' ') }); }
2659
+ getBreadcrumbs() {
2660
+ return [{ label: this.resource.getTitleCasePluralModelLabel(), url: this.resource.getUrl() }, { label: t('filament-panels::resources/pages/create-record.breadcrumb'), url: null }];
2661
+ }
2662
+ getPageClasses() { return ['fi-resource-create-record-page', `fi-resource-${this.resource.slug.replace(/\//g, '-')}`]; }
2663
+ getFormActions() {
2664
+ return [
2665
+ Action.make('create').label(t('filament-panels::resources/pages/create-record.form.actions.create.label')).submit('create').keyBindings(['mod+s']),
2666
+ Action.make('createAnother').label(t('filament-panels::resources/pages/create-record.form.actions.create_another.label')).color('gray').action('createAnother').keyBindings(['mod+shift+s']),
2667
+ this.cancelAction(),
2668
+ ];
2669
+ }
2670
+ // --- lifecycle hooks, to override in a subclass (none can be called from the browser)
2671
+ /** Transforms validated data before creation (add the author, compute a field…). */
2672
+ mutateFormDataBeforeCreate(data) { return data; }
2673
+ /** Right before creation; throwing cancels it. */
2674
+ beforeCreate(_data) { }
2675
+ /** The creation itself; defaults to the resource's data adapter. */
2676
+ handleRecordCreation(data) { return this.getDataAdapter().create(data); }
2677
+ /** After creation (logging, sending a message…). */
2678
+ afterCreate(_record) { }
2679
+ /** Where to go next; defaults to the new record's edit page. */
2680
+ getRedirectUrl(record) { return this.resource.getUrl('edit', this.getDataAdapter().getKey(record)); }
2681
+ /** Success notification title; `null` to show nothing. */
2682
+ getCreatedNotificationTitle() { return t('filament-panels::resources/pages/create-record.notifications.created.title'); }
2683
+ async create(another = false) {
2684
+ if (!(await this.validateForm()))
2685
+ return;
2686
+ const data = await this.mutateFormDataBeforeCreate(await getSchemaState(this.getSchema('form')));
2687
+ await this.beforeCreate(data);
2688
+ const record = await this.handleRecordCreation(data);
2689
+ await this.afterCreate(record);
2690
+ const title = this.getCreatedNotificationTitle();
2691
+ if (title !== null)
2692
+ Notification.make().success().title(title).send();
2693
+ if (another === true) {
2694
+ this.data = {};
2695
+ fillSchema(this.getSchema('form'), this.data);
2696
+ return;
2697
+ }
2698
+ this.redirect(this.getRedirectUrl(record), true);
2699
+ }
2700
+ createAnother() { return this.create(true); }
2701
+ }
2702
+ /** Delete action with confirmation. */
2703
+ export class DeleteAction {
2704
+ /** On an edit page, deletes the page's record and returns to the list. On a table row, deletes the row. */
2705
+ static make() {
2706
+ return Action.make('delete').authorize(({ livewire, record }) => canOn(livewire, 'delete', record))
2707
+ .label(t('filament-actions::delete.single.label')).color('danger').keyBindings(['mod+d']).tableIcon('c-trash').groupedIcon('m-trash')
2708
+ .requiresConfirmation().modalIcon('o-trash')
2709
+ // Hidden for an already soft-deleted record.
2710
+ .hidden(({ livewire, record }) => { const target = record ?? livewire._record; return target ? livewire.getDataAdapter().isTrashed(target) : false; })
2711
+ .modalHeading(({ livewire, record }) => t('filament-actions::delete.single.modal.heading', { label: livewire.resource.getRecordTitle(record ?? livewire._record) }))
2712
+ .modalSubmitActionLabel(t('filament-actions::delete.single.modal.actions.delete.label'))
2713
+ .action(async ({ livewire, record }) => {
2714
+ const adapter = livewire.getDataAdapter();
2715
+ const inTable = typeof livewire.getTable === 'function';
2716
+ await adapter.delete(inTable ? adapter.getKey(record) : livewire.recordKey);
2717
+ Notification.make().success().title(t('filament-actions::delete.single.notifications.deleted.title')).send();
2718
+ if (!inTable)
2719
+ livewire.redirect(livewire.resource.getUrl(), true);
2720
+ });
2721
+ }
2722
+ }
2723
+ /** Duplicates a table row: the copy takes the record's attributes, except its key, timestamps and excluded ones. */
2724
+ export class ReplicateAction {
2725
+ static make() {
2726
+ let excluded = [];
2727
+ const action = Action.make('replicate').authorize(({ livewire, record }) => canOn(livewire, 'replicate', record))
2728
+ .label(t('filament-actions::replicate.single.label')).tableIcon('c-square-2-stack').groupedIcon('m-square-2-stack')
2729
+ .modalHeading(({ livewire, record }) => t('filament-actions::replicate.single.modal.heading', { label: livewire.resource.getRecordTitle(record) }))
2730
+ .modalSubmitActionLabel(t('filament-actions::replicate.single.modal.actions.replicate.label'))
2731
+ .action(async ({ livewire, record }) => {
2732
+ const adapter = livewire.getDataAdapter();
2733
+ const source = typeof record?.toJSON === 'function' ? record.toJSON() : { ...record };
2734
+ // No primary key, no created/updated timestamps.
2735
+ for (const attribute of ['id', 'created_at', 'updated_at', ...excluded])
2736
+ delete source[attribute];
2737
+ await adapter.create(source);
2738
+ Notification.make().success().title(t('filament-actions::replicate.single.notifications.replicated.title')).send();
2739
+ });
2740
+ action.excludeAttributes = (attributes) => { excluded = attributes; return action; };
2741
+ return action;
2742
+ }
2743
+ }
2744
+ /** Create in a modal with the resource form. */
2745
+ export class CreateAction {
2746
+ /** Without an argument, the resource (or relation manager) is the host component's. */
2747
+ static make(resource = null) {
2748
+ const of = (livewire) => resource ?? livewire.resource;
2749
+ return Action.make('create').authorize(({ livewire, record }) => canOn(livewire, 'create', record))
2750
+ .label(({ livewire }) => t('filament-actions::create.single.label', { label: of(livewire).modelLabel }))
2751
+ .modalHeading(({ livewire }) => t('filament-actions::create.single.modal.heading', { label: String(of(livewire).modelLabel).split(' ').map(ucfirst).join(' ') }))
2752
+ .modalSubmitActionLabel(t('filament-actions::create.single.modal.actions.create.label'))
2753
+ .extraModalFooterActions([Action.make('createAnother').label(t('filament-actions::create.single.modal.actions.create_another.label')).color('gray').action(`callMountedAction(${js({ another: true })})`)])
2754
+ .schema((schema) => of(schema.livewire).form(schema))
2755
+ .action(async ({ livewire, data }) => {
2756
+ await of(livewire).adapter.create(data);
2757
+ Notification.make().success().title(t('filament-actions::create.single.notifications.created.title')).send();
2758
+ });
2759
+ }
2760
+ }
2761
+ /** Edit a table row in a modal, with the resource form. */
2762
+ export class EditAction {
2763
+ /** Without an argument, the resource (or relation manager) is the host component's. */
2764
+ static make(resource = null) {
2765
+ const of = (livewire) => resource ?? livewire.resource;
2766
+ return Action.make('edit').authorize(({ livewire, record }) => canOn(livewire, 'update', record))
2767
+ .label(t('filament-actions::edit.single.label')).tableIcon('c-pencil-square').groupedIcon('m-pencil-square')
2768
+ .modalHeading(({ livewire, record }) => t('filament-actions::edit.single.modal.heading', { label: of(livewire).getRecordTitle(record) }))
2769
+ .modalSubmitActionLabel(t('filament-actions::edit.single.modal.actions.save.label'))
2770
+ .schema((schema) => of(schema.livewire).form(schema))
2771
+ .fillForm(({ record }) => ({ ...record }))
2772
+ .action(async ({ livewire, record, data }) => {
2773
+ const adapter = of(livewire).adapter;
2774
+ await adapter.update(adapter.getKey(record), data);
2775
+ Notification.make().success().title(t('filament-actions::edit.single.notifications.saved.title')).send();
2776
+ });
2777
+ }
2778
+ }
2779
+ /** Link to the record's view page (when the resource has one). */
2780
+ export class ViewAction {
2781
+ static make(resource = null) {
2782
+ const of = (livewire) => resource ?? livewire.resource;
2783
+ return Action.make('view').authorize(({ livewire, record }) => canOn(livewire, 'view', record))
2784
+ .label(t('filament-actions::view.single.label')).color('gray').tableIcon('c-eye').groupedIcon('m-eye')
2785
+ // Without a view page (simple resource, related records): the infolist opens in a modal.
2786
+ .url(({ livewire, record }) => (record && of(livewire).hasViewPage ? of(livewire).getUrl('view', of(livewire).adapter.getKey(record)) : null))
2787
+ .modalHeading(({ livewire, record }) => t('filament-actions::view.single.modal.heading', { label: of(livewire).getRecordTitle(record) }))
2788
+ .modalSubmitAction(false).modalCancelActionLabel(t('filament-actions::view.single.modal.actions.close.label'))
2789
+ .schema((schema) => (of(schema.livewire).hasViewPage || !of(schema.livewire).infolist ? null : of(schema.livewire).infolist(schema.operation('view'))));
2790
+ }
2791
+ }
2792
+ const managerOf = (livewire) => livewire.constructor;
2793
+ /** Attaches an existing record to a relation manager's owner. */
2794
+ export class AttachAction {
2795
+ static make() {
2796
+ return Action.make('attach')
2797
+ .label(t('filament-actions::attach.single.label')).color('gray').modalWidth('lg')
2798
+ .modalHeading(({ livewire }) => t('filament-actions::attach.single.modal.heading', { label: String(livewire.resource.modelLabel).split(' ').map(ucfirst).join(' ') }))
2799
+ .modalSubmitActionLabel(t('filament-actions::attach.single.modal.actions.attach.label'))
2800
+ .extraModalFooterActions([Action.make('attachAnother').label(t('filament-actions::attach.single.modal.actions.attach_another.label')).color('gray').action(`callMountedAction(${js({ another: true })})`)])
2801
+ .schema((schema) => {
2802
+ const livewire = schema.livewire;
2803
+ return [Select.make('recordId').label(t('filament-actions::attach.single.modal.fields.record_id.label')).hiddenLabel().required()
2804
+ .getSearchResultsUsing((search) => managerOf(livewire).searchAttachable(livewire.ownerRecord, search))
2805
+ .getOptionLabelUsing((value) => managerOf(livewire).attachableLabel(livewire.ownerRecord, value))];
2806
+ })
2807
+ .action(async ({ livewire, data }) => {
2808
+ await managerOf(livewire).attach(livewire.ownerRecord, String(data.recordId));
2809
+ Notification.make().success().title(t('filament-actions::attach.single.notifications.attached.title')).send();
2810
+ });
2811
+ }
2812
+ }
2813
+ /** Detaches a row from the owner without deleting it. */
2814
+ export class DetachAction {
2815
+ static make() {
2816
+ return Action.make('detach')
2817
+ .label(t('filament-actions::detach.single.label')).color('danger').icon('c-x-mark').groupedIcon('m-x-mark')
2818
+ .requiresConfirmation().modalIcon('o-x-mark')
2819
+ .modalHeading(({ livewire, record }) => t('filament-actions::detach.single.modal.heading', { label: livewire.resource.getRecordTitle(record) }))
2820
+ .modalSubmitActionLabel(t('filament-actions::detach.single.modal.actions.detach.label'))
2821
+ .action(async ({ livewire, record }) => {
2822
+ await managerOf(livewire).detach(livewire.ownerRecord, [livewire.resource.adapter.getKey(record)]);
2823
+ Notification.make().success().title(t('filament-actions::detach.single.notifications.detached.title')).send();
2824
+ });
2825
+ }
2826
+ }
2827
+ /** Detaches the selected rows. */
2828
+ export class DetachBulkAction {
2829
+ static make() {
2830
+ return Action.make('detach')
2831
+ .label(t('filament-actions::detach.multiple.label')).color('danger').icon('m-x-mark')
2832
+ .requiresConfirmation().modalIcon('o-x-mark')
2833
+ .modalHeading(({ livewire }) => t('filament-actions::detach.multiple.modal.heading', { label: livewire.resource.getPluralModelLabel() }))
2834
+ .modalSubmitActionLabel(t('filament-actions::detach.multiple.modal.actions.detach.label'))
2835
+ .action(async ({ livewire }) => {
2836
+ // Selected keys come from the manager's current query, so they belong to the owner.
2837
+ await managerOf(livewire).detach(livewire.ownerRecord, await livewire.getSelectedRecordKeys());
2838
+ Notification.make().success().title(t('filament-actions::detach.multiple.notifications.detached.title')).send();
2839
+ livewire.deselectAllTableRecords();
2840
+ });
2841
+ }
2842
+ }
2843
+ /** Restores a soft-deleted record. Only visible when the record is deleted. */
2844
+ export class RestoreAction {
2845
+ static make() {
2846
+ return Action.make('restore').authorize(({ livewire, record }) => canOn(livewire, 'restore', record))
2847
+ .label(t('filament-actions::restore.single.label')).color('gray')
2848
+ .requiresConfirmation().modalIcon('o-arrow-uturn-left')
2849
+ .hidden(({ livewire }) => !livewire.getDataAdapter().isTrashed(livewire._record))
2850
+ .modalHeading(({ livewire }) => t('filament-actions::restore.single.modal.heading', { label: livewire.resource.getRecordTitle(livewire._record) }))
2851
+ .modalSubmitActionLabel(t('filament-actions::restore.single.modal.actions.restore.label'))
2852
+ .action(async ({ livewire }) => {
2853
+ await livewire.getDataAdapter().restore(livewire.recordKey);
2854
+ await livewire.hydrate();
2855
+ Notification.make().success().title(t('filament-actions::restore.single.notifications.restored.title')).send();
2856
+ });
2857
+ }
2858
+ }
2859
+ /** Permanently deletes a soft-deleted record. Only visible when the record is deleted. */
2860
+ export class ForceDeleteAction {
2861
+ static make() {
2862
+ return Action.make('forceDelete').authorize(({ livewire, record }) => canOn(livewire, 'forceDelete', record))
2863
+ .label(t('filament-actions::force-delete.single.label')).color('danger')
2864
+ .requiresConfirmation().modalIcon('o-trash')
2865
+ .hidden(({ livewire }) => !livewire.getDataAdapter().isTrashed(livewire._record))
2866
+ .modalHeading(({ livewire }) => t('filament-actions::force-delete.single.modal.heading', { label: livewire.resource.getRecordTitle(livewire._record) }))
2867
+ .modalSubmitActionLabel(t('filament-actions::force-delete.single.modal.actions.delete.label'))
2868
+ .action(async ({ livewire }) => {
2869
+ await livewire.getDataAdapter().forceDelete(livewire.recordKey);
2870
+ Notification.make().success().title(t('filament-actions::force-delete.single.notifications.deleted.title')).send();
2871
+ livewire.redirect(livewire.resource.getUrl(), true);
2872
+ });
2873
+ }
2874
+ }
2875
+ /** Deletes the records selected in a table. */
2876
+ export class DeleteBulkAction {
2877
+ static make() {
2878
+ return Action.make('delete').authorize(({ livewire, record }) => canOn(livewire, 'deleteAny', record))
2879
+ .label(t('filament-actions::delete.multiple.label')).color('danger').icon('m-trash')
2880
+ .requiresConfirmation().modalIcon('o-trash')
2881
+ .modalHeading(({ livewire }) => t('filament-actions::delete.multiple.modal.heading', { label: livewire.resource.getTitleCasePluralModelLabel() }))
2882
+ .modalSubmitActionLabel(t('filament-actions::delete.multiple.modal.actions.delete.label'))
2883
+ .action(async ({ livewire }) => {
2884
+ await livewire.getDataAdapterForTable().deleteMany(await livewire.getSelectedRecordKeys());
2885
+ Notification.make().success().title(t('filament-actions::delete.multiple.notifications.deleted.title')).send();
2886
+ livewire.deselectAllTableRecords();
2887
+ });
2888
+ }
2889
+ }
2890
+ /** Record edit page. */
2891
+ export class EditRecord extends ResourceFormPage {
2892
+ static callable = ['save'];
2893
+ recordKey = '';
2894
+ /** Active relation manager tab (index in `Resource.relations`). */
2895
+ activeRelationManager = '0';
2896
+ /**
2897
+ * Previous page, captured on first display from the Referer header: only a same-site URL is accepted,
2898
+ * and it cannot be changed afterwards.
2899
+ */
2900
+ previousUrl = '';
2901
+ getCancelUrl() { return this.previousUrl; }
2902
+ contentSchema(schema) {
2903
+ const content = super.contentSchema(schema);
2904
+ // Always present: hidden (empty column) when the resource has no relation manager.
2905
+ return content.components([...content.getComponents(), this.resource.relations.length ? new RelationManagerTabs() : new RelationManagerTabs().key(null).hidden()]);
2906
+ }
2907
+ static locked = ['recordKey', 'previousUrl'];
2908
+ _record = null;
2909
+ async mount(params) {
2910
+ this.recordKey = String(params.record ?? '');
2911
+ this.previousUrl = previousUrlOf(this._ctx?.req);
2912
+ await this.hydrate();
2913
+ fillSchemaFromRecord(this.getSchema('form'), this.data, await this.mutateFormDataBeforeFill({ ...this._record }));
2914
+ }
2915
+ /** The record is reloaded on every request (no route model binding). */
2916
+ async hydrate() {
2917
+ this._record = await this.getDataAdapter().find(this.recordKey);
2918
+ if (!this._record)
2919
+ throw new WireError(404, 'Record not found.');
2920
+ if (!(await canOn(this, 'update', this._record)))
2921
+ throw new WireError(403, 'Forbidden.');
2922
+ }
2923
+ getRecord() { return this._record; }
2924
+ getRecordKey() { return this.recordKey; }
2925
+ getActionMountContext() { return { recordKey: this.recordKey }; }
2926
+ getActionRecord() { return this._record; }
2927
+ /** Delete, plus restore and force-delete when the model uses soft deletes. */
2928
+ getHeaderActions() {
2929
+ const defaults = this.getDataAdapter().supportsSoftDeletes() ? [DeleteAction.make(), RestoreAction.make(), ForceDeleteAction.make()] : [DeleteAction.make()];
2930
+ // Resource-specific actions come before the default ones.
2931
+ return [...(this.resource.editHeaderActions?.() ?? []), ...defaults];
2932
+ }
2933
+ getOperation() { return 'edit'; }
2934
+ getSubmitMethod() { return 'save'; }
2935
+ getTitle() { return t('filament-panels::resources/pages/edit-record.title', { label: this.resource.getRecordTitle(this._record) }); }
2936
+ getBreadcrumbs() {
2937
+ return [
2938
+ { label: this.resource.getTitleCasePluralModelLabel(), url: this.resource.getUrl() },
2939
+ // The record title only appears if the resource declares `recordTitleAttribute`; it links to the view page when there is one.
2940
+ ...(this.resource.recordTitleAttribute ? [{ label: this.resource.getRecordTitle(this._record), url: this.resource.getUrl(this.resource.hasViewPage ? 'view' : 'edit', this.recordKey) }] : []),
2941
+ { label: t('filament-panels::resources/pages/edit-record.breadcrumb'), url: null },
2942
+ ];
2943
+ }
2944
+ getSubNavigation() { return this.resource.getRecordSubNavigation(this); }
2945
+ getPageClasses() { return ['fi-resource-edit-record-page', `fi-resource-${this.resource.slug.replace(/\//g, '-')}`, `fi-resource-record-${this.recordKey}`]; }
2946
+ getFormActions() {
2947
+ return [Action.make('save').label(t('filament-panels::resources/pages/edit-record.form.actions.save.label')).submit('save').keyBindings(['mod+s']), this.cancelAction()];
2948
+ }
2949
+ // --- lifecycle hooks, to override in a subclass (none can be called from the browser)
2950
+ /** Transforms the record's values before filling the form. */
2951
+ mutateFormDataBeforeFill(data) { return data; }
2952
+ /** Transforms validated data before saving. */
2953
+ mutateFormDataBeforeSave(data) { return data; }
2954
+ /** Right before saving; throwing cancels it. */
2955
+ beforeSave(_data) { }
2956
+ /** The save itself; defaults to the resource's data adapter. */
2957
+ handleRecordUpdate(record, data) { return this.getDataAdapter().update(this.getDataAdapter().getKey(record), data); }
2958
+ /** After saving. */
2959
+ afterSave(_record) { }
2960
+ /** URL to go to after saving; `null` (default) stays on the page. */
2961
+ getRedirectUrl(_record) { return null; }
2962
+ /** Success notification title; `null` to show nothing. */
2963
+ getSavedNotificationTitle() { return t('filament-panels::resources/pages/edit-record.notifications.saved.title'); }
2964
+ async save() {
2965
+ if (!(await this.validateForm()))
2966
+ return;
2967
+ const data = await this.mutateFormDataBeforeSave(await getSchemaState(this.getSchema('form')));
2968
+ await this.beforeSave(data);
2969
+ this._record = await this.handleRecordUpdate(this._record, data);
2970
+ await this.afterSave(this._record);
2971
+ const title = this.getSavedNotificationTitle();
2972
+ if (title !== null)
2973
+ Notification.make().success().title(title).send();
2974
+ const url = this.getRedirectUrl(this._record);
2975
+ if (url !== null)
2976
+ this.redirect(url, true);
2977
+ }
2978
+ }
2979
+ /** Record view page: the resource's read-only `infolist()` schema. */
2980
+ export class ViewRecord extends Page {
2981
+ static resource;
2982
+ static locked = ['recordKey'];
2983
+ recordKey = '';
2984
+ /** Active relation manager tab (index in `Resource.relations`). */
2985
+ activeRelationManager = '0';
2986
+ _record = null;
2987
+ get resource() { return this.constructor.resource; }
2988
+ getDataAdapter() { return this.resource.adapter; }
2989
+ async mount(params) {
2990
+ this.recordKey = String(params.record ?? '');
2991
+ await this.hydrate();
2992
+ }
2993
+ /** The record is reloaded on every request, as on the edit page. */
2994
+ async hydrate() {
2995
+ this._record = await this.getDataAdapter().find(this.recordKey);
2996
+ if (!this._record)
2997
+ throw new WireError(404, 'Record not found.');
2998
+ if (!(await canOn(this, 'view', this._record)))
2999
+ throw new WireError(403, 'Forbidden.');
3000
+ }
3001
+ getRecordKey() { return this.recordKey; }
3002
+ getActionMountContext() { return { recordKey: this.recordKey }; }
3003
+ getActionRecord() { return this._record; }
3004
+ infolistSchema(schema) { return this.resource.infolist(schema.columns(2).operation('view').record(this._record)); }
3005
+ contentSchema(schema) {
3006
+ const content = schema.components([EmbeddedSchema.make('infolist')]);
3007
+ // Always present: hidden (empty column) when the resource has no relation manager.
3008
+ return content.components([...content.getComponents(), this.resource.relations.length ? new RelationManagerTabs() : new RelationManagerTabs().key(null).hidden()]);
3009
+ }
3010
+ /** Defaults to a link to the edit page. */
3011
+ getHeaderActions() { return [this.editAction()]; }
3012
+ editAction() {
3013
+ return Action.make('edit').authorize(({ livewire, record }) => canOn(livewire, 'update', record)).label(t('filament-actions::edit.single.label')).url(() => this.resource.getUrl('edit', this.recordKey));
3014
+ }
3015
+ getTitle() { return t('filament-panels::resources/pages/view-record.title', { label: this.resource.getRecordTitle(this._record) }); }
3016
+ getBreadcrumbs() {
3017
+ return [
3018
+ { label: this.resource.getTitleCasePluralModelLabel(), url: this.resource.getUrl() },
3019
+ // The record title only appears if the resource declares `recordTitleAttribute`.
3020
+ ...(this.resource.recordTitleAttribute ? [{ label: this.resource.getRecordTitle(this._record), url: this.resource.getUrl('view', this.recordKey) }] : []),
3021
+ { label: t('filament-panels::resources/pages/view-record.breadcrumb'), url: null },
3022
+ ];
3023
+ }
3024
+ getSubNavigation() { return this.resource.getRecordSubNavigation(this); }
3025
+ getPageClasses() { return ['fi-resource-view-record-page', `fi-resource-${this.resource.slug.replace(/\//g, '-')}`, `fi-resource-record-${this.recordKey}`]; }
3026
+ }
3027
+ /** Evaluation context for panel menu actions (no record). */
3028
+ const menuContext = (livewire) => ({ livewire: livewire, component: null, operation: null, record: null, get: () => undefined, state: undefined });
3029
+ /** User menu: profile, panel items, themes, logout. */
3030
+ async function renderUserMenu(component, position) {
3031
+ const { req, res, panel, user } = component._ctx;
3032
+ const ctx = menuContext(component);
3033
+ const items = [];
3034
+ const profileUrl = panel.getProfileUrl();
3035
+ const custom = panel.getUserMenuItems();
3036
+ if (profileUrl && !custom.profile)
3037
+ items.push(Action.make('profile').label(panel.getProfile().page.getLabel()).icon('m-user-circle').url(profileUrl).color('gray').sort(-1));
3038
+ items.push(...Object.values(custom));
3039
+ const visible = [];
3040
+ for (const item of items)
3041
+ if (await item.isVisible(ctx))
3042
+ visible.push(item);
3043
+ visible.sort((a, b) => a.getSort() - b.getSort());
3044
+ const html = async (list) => (await Promise.all(list.map((item) => item.toMenuItemHtml(ctx, { navigate: panel.hasSpaMode() })))).filter(Boolean);
3045
+ return views.render('filament-panels::components.user-menu', {
3046
+ position, isSidebarCollapsibleOnDesktop: panel.isSidebarCollapsibleOnDesktop(), user: user, avatarUrl: panel.getUserAvatarUrl(user), logoutUrl: panel.getLogoutUrl(), csrf: csrfToken(req, res),
3047
+ profileUrl: profileUrl && !custom.profile ? profileUrl : null,
3048
+ itemsBefore: await html(visible.filter((item) => item.getSort() < 0)), itemsAfter: await html(visible.filter((item) => item.getSort() >= 0)),
3049
+ themes: panel.getThemes().map((theme) => ({ id: theme.id, label: theme.label, swatch: swatchOf(theme, panel) })), activeTheme: panel.resolveTheme(req)?.id ?? null,
3050
+ });
3051
+ }
3052
+ /** Tenant menu: current tenant, other tenants, registration, profile. */
3053
+ async function renderTenantMenu(component, options) {
3054
+ const { panel, user } = component._ctx;
3055
+ const tenant = panel.getTenant();
3056
+ if (!panel.hasTenancy() || !panel.hasTenantMenu() || !tenant || !user)
3057
+ return '';
3058
+ const provider = panel.getTenantProvider();
3059
+ const ctx = menuContext(component);
3060
+ const items = [];
3061
+ const custom = panel.getTenantMenuItems();
3062
+ const ProfilePage = panel.getTenantProfilePage();
3063
+ if (ProfilePage && provider.update && !custom.profile)
3064
+ items.push(Action.make('profile').label(ProfilePage.getLabel()).icon('m-cog-6-tooth').url(panel.getUrl(ProfilePage.slug)).color('gray').sort(-1));
3065
+ const RegistrationPage = panel.getTenantRegistrationPage();
3066
+ if (RegistrationPage && !custom.register && (await RegistrationPage.canView(panel, user)))
3067
+ items.push(Action.make('register').label(RegistrationPage.getLabel()).icon('m-plus').url(panel.getBaseUrl(RegistrationPage.slug)).color('gray').sort(Number.MAX_SAFE_INTEGER));
3068
+ items.push(...Object.values(custom));
3069
+ const visible = [];
3070
+ for (const item of items)
3071
+ if (await item.isVisible(ctx))
3072
+ visible.push(item);
3073
+ visible.sort((a, b) => a.getSort() - b.getSort());
3074
+ const others = (await provider.getTenants(user)).filter((candidate) => provider.getKey(candidate) !== provider.getKey(tenant));
3075
+ const canSwitch = others.length > 0;
3076
+ const html = async (list) => (await Promise.all(list.map((item) => item.toMenuItemHtml(ctx, { navigate: panel.hasSpaMode() })))).filter(Boolean);
3077
+ return views.render('filament-panels::components.tenant-menu', {
3078
+ teleport: options.teleport, isSidebarCollapsibleOnDesktop: panel.isSidebarCollapsibleOnDesktop(),
3079
+ tenant: { name: provider.getName(tenant), label: provider.getCurrentTenantLabel?.(tenant) ?? null, avatarUrl: panel.getTenantAvatarUrl(tenant) },
3080
+ itemsBefore: canSwitch ? await html(visible.filter((item) => item.getSort() < 0)) : [],
3081
+ itemsAfter: await html(canSwitch ? visible.filter((item) => item.getSort() >= 0) : visible),
3082
+ switchable: others.map((other) => ({ name: provider.getName(other), url: panel.getUrl('', other), avatarUrl: panel.getTenantAvatarUrl(other) })),
3083
+ });
3084
+ }
3085
+ /** Sidebar: logo, tenant menu, search, navigation and, without a topbar, the bell and user menu. */
3086
+ export class Sidebar extends Component {
3087
+ static callable = ['setTheme'];
3088
+ currentPath = '/';
3089
+ static locked = ['currentPath'];
3090
+ mount(params) { this.currentPath = String(params.path ?? '/'); }
3091
+ /** A resource item stays active on its create and edit pages. */
3092
+ async render() {
3093
+ const { panel, user } = this._ctx;
3094
+ if (!user)
3095
+ return '<div></div>';
3096
+ const inSidebar = (position) => position === 'sidebar';
3097
+ return views.render('filament-panels::livewire.sidebar', {
3098
+ hasTopbar: panel.hasTopbar(), hasTopNavigation: panel.hasTopNavigation(), spa: panel.hasSpaMode(),
3099
+ isSidebarCollapsibleOnDesktop: panel.isSidebarCollapsibleOnDesktop(), isSidebarFullyCollapsibleOnDesktop: panel.isSidebarFullyCollapsibleOnDesktop(),
3100
+ tenantMenu: (await renderTenantMenu(this, { teleport: false })) || null,
3101
+ globalSearch: panel.hasGlobalSearch() && inSidebar(panel.getGlobalSearchPosition()) ? await this.mountChild(`navette.${panel.getId()}.global-search`, {}, `${this.id}.global-search`) : null,
3102
+ databaseNotifications: panel.getDatabaseNotifications() && inSidebar(panel.getDatabaseNotificationsPosition()) ? await this.mountChild(`navette.${panel.getId()}.database-notifications`, {}, `${this.id}.database-notifications`, { lazy: true }) : null,
3103
+ userMenu: inSidebar(panel.getUserMenuPosition()) ? await renderUserMenu(this, 'sidebar') : null,
3104
+ homeUrl: panel.getUrl(), brand: panel.getBrand(), groups: panel.getNavigation(this.currentPath, await panel.resolveNavigationBadges(), await panel.resolveInaccessiblePages(user)),
3105
+ livewireId: this.id,
3106
+ });
3107
+ }
3108
+ /** Theme choice by the user: only a theme declared by the panel is accepted; the page reloads to apply it. */
3109
+ setTheme(id) { applyTheme(this, id); this.skipRender(); }
3110
+ }
3111
+ /** Theme choice (sidebar or topbar): only a theme declared by the panel is accepted; the page reloads. */
3112
+ function applyTheme(component, id) {
3113
+ const { req, res, panel, user } = component._ctx;
3114
+ if (!user)
3115
+ throw new WireError(401, 'Unauthenticated.');
3116
+ const theme = panel.getThemes().find((candidate) => candidate.id === id);
3117
+ if (!theme)
3118
+ throw new WireError(404, 'Unknown theme.');
3119
+ panel.rememberTheme(res, theme);
3120
+ const referer = previousUrlOf(req);
3121
+ component.redirect(referer.replace(/^https?:\/\/[^/]+/, '') || '/');
3122
+ }
3123
+ /** Topbar: logo, optional navigation, search, bell, user menu. */
3124
+ export class Topbar extends Component {
3125
+ static callable = ['setTheme'];
3126
+ currentPath = '/';
3127
+ static locked = ['currentPath'];
3128
+ mount(params) { this.currentPath = String(params.path ?? '/'); }
3129
+ async render() {
3130
+ const { panel, user } = this._ctx;
3131
+ if (!user)
3132
+ return '<div></div>';
3133
+ const inTopbar = (position) => position === 'topbar';
3134
+ const hasNavigation = panel.hasNavigation();
3135
+ const showsNavigationHere = panel.hasTopNavigation() || !hasNavigation;
3136
+ const tenant = panel.getTenant();
3137
+ const groups = showsNavigationHere && hasNavigation ? panel.getNavigation(this.currentPath, await panel.resolveNavigationBadges(), await panel.resolveInaccessiblePages(user)) : null;
3138
+ return views.render('filament-panels::livewire.topbar', {
3139
+ hasNavigation, spa: panel.hasSpaMode(), isSidebarCollapsibleOnDesktop: panel.isSidebarCollapsibleOnDesktop(), isSidebarFullyCollapsibleOnDesktop: panel.isSidebarFullyCollapsibleOnDesktop(),
3140
+ homeUrl: panel.getUrl(), brand: panel.getBrand(), livewireId: this.id,
3141
+ tenantMenu: showsNavigationHere ? (await renderTenantMenu(this, { teleport: true })) || null : null,
3142
+ topNavigation: groups?.map((group) => ({ ...group, items: group.items.map((item) => ({ ...item, colorClasses: item.isActive ? sharedColors.getComponentClasses('dropdown-item', 'primary') : [] })) })) ?? null,
3143
+ persistKey: panel.hasTenancy() ? `topbar.end.panel-${panel.getId()}.tenant-${tenant ? panel.getTenantUrlKey(tenant) : ''}` : `topbar.end.panel-${panel.getId()}`,
3144
+ globalSearch: panel.hasGlobalSearch() && inTopbar(panel.getGlobalSearchPosition()) ? await this.mountChild(`navette.${panel.getId()}.global-search`, {}, `${this.id}.global-search`) : null,
3145
+ databaseNotifications: panel.getDatabaseNotifications() && inTopbar(panel.getDatabaseNotificationsPosition()) ? await this.mountChild(`navette.${panel.getId()}.database-notifications`, {}, `${this.id}.database-notifications`, { lazy: true }) : null,
3146
+ userMenu: inTopbar(panel.getUserMenuPosition()) ? await renderUserMenu(this, 'topbar') : null,
3147
+ });
3148
+ }
3149
+ setTheme(id) { applyTheme(this, id); this.skipRender(); }
3150
+ }
3151
+ /** User menu on simple pages for a signed-in user. */
3152
+ export class SimpleUserMenu extends Component {
3153
+ static callable = ['setTheme'];
3154
+ async render() {
3155
+ if (!this._ctx.user)
3156
+ return '<div></div>';
3157
+ return `<div><div class="fi-simple-user-menu-ctn">${await renderUserMenu(this, 'topbar')}</div><div wire:partial="action-modals" x-data="filamentActionModals({
3158
+ livewireId: ${js(this.id)},
3159
+ })" style="height: 0"></div></div>`;
3160
+ }
3161
+ setTheme(id) { applyTheme(this, id); this.skipRender(); }
3162
+ }
3163
+ /** `.ts`, `.mts`, `.js`, `.mjs` modules of a folder and its subfolders, in alphabetical path order. */
3164
+ function moduleFiles(directory) {
3165
+ const root = directory instanceof URL ? fileURLToPath(directory) : directory;
3166
+ if (!existsSync(root))
3167
+ return [];
3168
+ const files = [];
3169
+ const walk = (dir) => {
3170
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
3171
+ const full = join(dir, entry.name);
3172
+ if (entry.isDirectory())
3173
+ walk(full);
3174
+ else if (/\.(m?ts|m?js)$/.test(entry.name) && !/\.(d|test|spec)\.m?[tj]s$/.test(entry.name))
3175
+ files.push(full);
3176
+ }
3177
+ };
3178
+ walk(root);
3179
+ return files;
3180
+ }
3181
+ /** Classes exported by a folder's modules that extend `base` (the base class itself, abstract or imported, is skipped). */
3182
+ async function discoverClasses(directory, base) {
3183
+ const found = [];
3184
+ for (const file of moduleFiles(directory)) {
3185
+ const module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
3186
+ for (const value of Object.values(module))
3187
+ if (typeof value === 'function' && value.prototype instanceof base && !found.includes(value))
3188
+ found.push(value);
3189
+ }
3190
+ return found;
3191
+ }
3192
+ /** Resources defined in a folder: `.resources(await discoverResources(new URL('./resources/', import.meta.url)))`. New files are picked up on the next start. */
3193
+ export const discoverResources = (directory) => discoverClasses(directory, Resource);
3194
+ /** Custom pages (and dashboards) defined in a folder. Resource and authentication pages are excluded. */
3195
+ export const discoverPages = async (directory) => (await discoverClasses(directory, Page)).filter((page) => !page.resource && !(page.prototype instanceof EditTenantProfile));
3196
+ /** Widgets defined in a folder. */
3197
+ export const discoverWidgets = (directory) => discoverClasses(directory, Widget);
3198
+ /** Panels default-exported (`export default Panel.make()…`) by a folder's modules. */
3199
+ export async function discoverPanels(directory) {
3200
+ const panels = [];
3201
+ for (const file of moduleFiles(directory)) {
3202
+ const module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
3203
+ if (module.default instanceof Panel)
3204
+ panels.push(module.default);
3205
+ }
3206
+ return panels;
3207
+ }
3208
+ /** A runtime serves a single panel: each panel has its own Livewire endpoint and components. */
3209
+ const runtimePanels = new WeakMap();
3210
+ /** Reserved segments right after a multi-tenant panel's path (they are not tenant keys). */
3211
+ const RESERVED_SEGMENTS = new Set(['login', 'logout', 'register', 'password-reset', 'email-verification', 'profile', 'livewire']);
3212
+ /** Express router for a panel: assets, runtime endpoint, authentication, tenants, protected pages. */
3213
+ export function panelRouter(panel, runtime) {
3214
+ const owner = runtimePanels.get(runtime);
3215
+ if (owner && owner !== panel)
3216
+ throw new Error(`This WireRuntime already serves panel "${owner.getId()}": create one WireRuntime per panel.`);
3217
+ runtimePanels.set(runtime, panel);
3218
+ // File uploads go through this panel's endpoint (panel session and user).
3219
+ const runtimeOptions = runtime.options;
3220
+ runtimeOptions.uploadPrefix ??= panel.getLivewirePrefix();
3221
+ // One color manager for all panels: each request sets its own panel's colors on it.
3222
+ useColorManager(sharedColors);
3223
+ useFormsColorManager(sharedColors);
3224
+ useTableColorManager(sharedColors);
3225
+ useInfolistsColorManager(sharedColors);
3226
+ // HTML rendered by an infolist entry is sanitized through the rich editor schema, which only keeps its own tags.
3227
+ useHtmlSanitizer((html) => richDocumentToHtml(richHtmlToDocument(html)) ?? '');
3228
+ views.share({ assetUrl: (file) => panel.assetUrl(file) });
3229
+ for (const plugin of panel.getPlugins())
3230
+ plugin.boot?.(panel);
3231
+ assertAuthCapabilities(panel);
3232
+ // Tenancy: scoped resources only see the records of the request's tenant.
3233
+ const ownership = panel.getTenantOwnershipColumn();
3234
+ if (panel.hasTenancy() && ownership) {
3235
+ for (const resource of panel.getResources()) {
3236
+ if (!resource.isScopedToTenant)
3237
+ continue;
3238
+ const adapter = resource.adapter;
3239
+ if (typeof adapter?.scopeToTenant !== 'function')
3240
+ throw new Error(`Resource ${resource.name}: its adapter does not support tenant scoping (scopeToTenant).`);
3241
+ adapter.scopeToTenant(resource.tenantOwnershipColumn ?? ownership, () => { const tenant = panel.getTenant(); return tenant ? panel.getTenantUrlKey(tenant) : null; });
3242
+ }
3243
+ }
3244
+ const router = express.Router();
3245
+ const name = (suffix) => `navette.${panel.getId()}.${suffix}`;
3246
+ const livewire = panel.getLivewirePrefix();
3247
+ // The notifications mailbox is per browser: it is keyed by the CSRF token.
3248
+ const context = async (req, res) => ({ req, res, panel, user: await panel.user(req), mailbox: csrfToken(req, res), tenant: null });
3249
+ /** Tenant designated by a panel URL, if the user is a member. */
3250
+ const tenantFromPath = async (path, user) => {
3251
+ if (!panel.hasTenancy() || !user)
3252
+ return null;
3253
+ const base = panel.getBaseUrl();
3254
+ const prefix = base === '/' ? '/' : `${base}/`;
3255
+ if (!path.startsWith(prefix))
3256
+ return null;
3257
+ let key;
3258
+ try {
3259
+ key = decodeURIComponent(path.slice(prefix.length).split('/')[0] ?? '');
3260
+ }
3261
+ catch {
3262
+ return null;
3263
+ }
3264
+ const registration = panel.getTenantRegistrationPage();
3265
+ if (!key || RESERVED_SEGMENTS.has(key) || key === registration?.slug || key.length > 200)
3266
+ return null;
3267
+ const provider = panel.getTenantProvider();
3268
+ const tenant = await provider.find(key);
3269
+ return tenant && (await provider.canAccess(user, tenant)) ? tenant : null;
3270
+ };
3271
+ /** User's home: the panel root or, in a multi-tenant panel, their first tenant's root (otherwise tenant registration). */
3272
+ const homeUrl = async (user) => {
3273
+ if (!panel.hasTenancy())
3274
+ return panel.getUrl();
3275
+ const [first] = await panel.getTenantProvider().getTenants(user);
3276
+ if (first)
3277
+ return panel.getUrl('', first);
3278
+ const registration = panel.getTenantRegistrationPage();
3279
+ return registration && (await registration.canView(panel, user)) ? panel.getBaseUrl(registration.slug) : null;
3280
+ };
3281
+ const LoginPage = panel.getLoginPage();
3282
+ if (LoginPage)
3283
+ runtime.register(name('auth.login'), LoginPage);
3284
+ const RegistrationPage = panel.getRegistrationPage();
3285
+ if (RegistrationPage)
3286
+ runtime.register(name('auth.register'), RegistrationPage);
3287
+ const passwordReset = panel.getPasswordResetPages();
3288
+ if (passwordReset) {
3289
+ runtime.register(name('auth.password-reset.request'), passwordReset.request);
3290
+ runtime.register(name('auth.password-reset.reset'), passwordReset.reset);
3291
+ }
3292
+ const VerificationPrompt = panel.getEmailVerificationPrompt();
3293
+ if (VerificationPrompt)
3294
+ runtime.register(name('auth.email-verification.prompt'), VerificationPrompt);
3295
+ const profile = panel.getProfile();
3296
+ if (profile)
3297
+ runtime.register(name('auth.profile'), profile.page);
3298
+ const TenantRegistration = panel.getTenantRegistrationPage();
3299
+ if (TenantRegistration)
3300
+ runtime.register(name('tenancy.registration'), TenantRegistration);
3301
+ const TenantProfile = panel.getTenantProfilePage();
3302
+ if (TenantProfile && !panel.getPages().includes(TenantProfile))
3303
+ panel.pages([TenantProfile]);
3304
+ runtime.register(name('sidebar'), Sidebar);
3305
+ runtime.register(name('topbar'), Topbar);
3306
+ runtime.register(name('simple-user-menu'), SimpleUserMenu);
3307
+ runtime.register(name('global-search'), GlobalSearch);
3308
+ runtime.register(name('database-notifications'), DatabaseNotifications);
3309
+ runtime.register('navette.notifications', Notifications);
3310
+ for (const [index, widget] of panel.getWidgets().entries())
3311
+ runtime.register(name(`widgets.panel.${index}`), widget);
3312
+ for (const resource of panel.getResources())
3313
+ for (const [index, widget] of resource.widgets.entries())
3314
+ runtime.register(name(`widgets.${resource.slug.replace(/\//g, '.')}.${index}`), widget);
3315
+ for (const resource of panel.getResources())
3316
+ for (const manager of resource.relations)
3317
+ runtime.register(name(`relation-managers.${resource.slug.replace(/\//g, '.')}.${manager.relationship}`), manager);
3318
+ router.use(express.static(dir('../dist/filament'), { maxAge: '1y' }));
3319
+ // Navette's own front-end files.
3320
+ router.use(express.static(dir('../dist/navette')));
3321
+ // An update's tenant is the one of the page sending it (from the signed snapshot URL), if the user is a member.
3322
+ const wireContext = async (req, res) => {
3323
+ const ctx = await context(req, res);
3324
+ let path = '';
3325
+ try {
3326
+ path = String(JSON.parse(req.body?.components?.[0]?.snapshot ?? '{}')?.memo?.path ?? '');
3327
+ }
3328
+ catch {
3329
+ path = '';
3330
+ }
3331
+ ctx.tenant = await tenantFromPath(path, ctx.user);
3332
+ return ctx;
3333
+ };
3334
+ // Notifications sent during the request go into a batch announced to the browser.
3335
+ router.use(wireRouter(runtime, livewire, wireContext, async (ctx, _req, res, handle) => {
3336
+ const { result, pending } = await runInRequest(ctx.mailbox, () => panel.withTheme(ctx.req, () => panel.withTenant(ctx.tenant ?? null, handle)));
3337
+ if (pending.length) {
3338
+ const batch = publish(ctx.mailbox, pending);
3339
+ result.filamentNotificationBatch = batch;
3340
+ result.filamentNotifications = true;
3341
+ res.setHeader('X-Filament-Notifications', batch);
3342
+ if (result.components.some((c) => c.effects?.redirect))
3343
+ res.setHeader('X-Filament-Notifications-Redirect', 'true');
3344
+ }
3345
+ return result;
3346
+ // Only a signed-in user can upload files.
3347
+ }, (ctx) => !!ctx.user));
3348
+ // A page renders entirely with the request theme's colors, for the request's tenant.
3349
+ const document = (req, res, ctx, options) => panel.withTheme(req, () => panel.withTenant(ctx.tenant ?? null, () => renderDocument(req, res, ctx, options)));
3350
+ const renderDocument = async (req, res, ctx, options) => {
3351
+ const request = { path: req.path, method: req.method, ctx };
3352
+ const mounted = await runtime.mountWithInstance(options.component, options.params ?? {}, request);
3353
+ // A page that redirects on mount (already logged in, email already verified…): HTTP redirect.
3354
+ const redirect = mounted.component._effects?.redirect;
3355
+ if (typeof redirect === 'string')
3356
+ return void res.redirect(redirect);
3357
+ const slot = mounted.html;
3358
+ // The document title comes from the instance (it may depend on the record).
3359
+ const instanceTitle = mounted.component.getTitle?.();
3360
+ if (typeof instanceTitle === 'string')
3361
+ options.title = instanceTitle;
3362
+ // Layout render hooks use the scopes of the displayed page.
3363
+ const renderHookScopes = mounted.component.getRenderHookScopes?.() ?? [];
3364
+ const hasNavigation = panel.hasNavigation();
3365
+ const body = options.layout === 'simple'
3366
+ ? views.render('filament-panels::layout.simple', {
3367
+ slot, renderHookScopes,
3368
+ header: panel.hasTopbar() && ctx.user ? (await runtime.mount(name('simple-user-menu'), {}, request)).replace(/^s*<div /, '<div wire:key="lw-1-1-0" ') : null,
3369
+ })
3370
+ : views.render('filament-panels::layout.index', {
3371
+ slot, maxContentWidth: panel.getMaxContentWidth(), renderHookScopes, hasNavigation, hasTopNavigation: panel.hasTopNavigation(),
3372
+ isSidebarCollapsibleOnDesktop: panel.isSidebarCollapsibleOnDesktop(), isSidebarFullyCollapsibleOnDesktop: panel.isSidebarFullyCollapsibleOnDesktop(),
3373
+ topbar: panel.hasTopbar() ? (await runtime.mount(name('topbar'), { path: req.path }, request)).replace(/^s*<div /, '<div wire:key="lw-1-0-1" ') : null,
3374
+ sidebar: hasNavigation ? (await runtime.mount(name('sidebar'), { path: req.path }, request)).replace(/^s*<div /, '<div wire:key="lw-1-1-1" ') : '',
3375
+ });
3376
+ const bodyClass = options.layout === 'index' ? cls([{ 'fi-body-has-navigation': hasNavigation, 'fi-body-has-sidebar-collapsible-on-desktop': panel.isSidebarCollapsibleOnDesktop(), 'fi-body-has-sidebar-fully-collapsible-on-desktop': panel.isSidebarFullyCollapsibleOnDesktop(), 'fi-body-has-topbar': panel.hasTopbar(), 'fi-body-has-top-navigation': panel.hasTopNavigation() }]) || null : null;
3377
+ res.send(views.render('filament-panels::layout.base', {
3378
+ renderHookScopes, title: options.title, brandName: panel.getBrandName(), favicon: panel.getFavicon(), theme: panel.getTheme(), motion: panel.hasMotion(), panelId: panel.getId(), font: panel.resolveTheme(req)?.font ? { ...panel.getFont(), ...fontOf(panel.resolveTheme(req).font) } : panel.getFont(), themeCss: panel.resolveTheme(req)?.css ?? '',
3379
+ bodyClass, body, spa: panel.hasSpaMode(),
3380
+ csrf: csrfToken(req, res), colorVariables: panel.colors.renderCssVariables(), wireStyles: wireStyles(), wireScripts: wireScripts(req, res, livewire),
3381
+ notifications: (await runtime.mount('navette.notifications', {}, request)).replace(/^<div /, '<div wire:key="lw-1-0-0" '),
3382
+ }));
3383
+ };
3384
+ /** Page access errors: 404 and 403 are answered directly, anything else is rethrown. */
3385
+ const guarded = async (res, render) => {
3386
+ try {
3387
+ await render();
3388
+ }
3389
+ catch (error) {
3390
+ if (error instanceof WireError && error.status === 404)
3391
+ return void res.status(404).send('Not Found');
3392
+ if (error instanceof WireError && error.status === 403)
3393
+ return void res.status(403).send('Forbidden');
3394
+ throw error;
3395
+ }
3396
+ };
3397
+ /** Guest-only simple page (login, registration, password reset). */
3398
+ const guestPage = (path, component, title, params = () => ({})) => {
3399
+ router.get(panel.getBaseUrl(path), async (req, res) => {
3400
+ const ctx = await context(req, res);
3401
+ if (ctx.user)
3402
+ return res.redirect((await homeUrl(ctx.user)) ?? panel.getBaseUrl());
3403
+ await guarded(res, () => document(req, res, ctx, { title, component, layout: 'simple', params: params(req) }));
3404
+ });
3405
+ };
3406
+ /** Simple page for a signed-in user, outside any tenant (profile, email verification, tenant registration). */
3407
+ const userPage = (path, component, title, layout = 'simple') => {
3408
+ router.get(panel.getBaseUrl(path), async (req, res) => {
3409
+ const ctx = await context(req, res);
3410
+ if (!ctx.user)
3411
+ return res.redirect(panel.getLoginUrl());
3412
+ await guarded(res, () => document(req, res, ctx, { title, component, layout }));
3413
+ });
3414
+ };
3415
+ if (LoginPage) {
3416
+ guestPage('login', name('auth.login'), t('filament-panels::auth/pages/login.title'));
3417
+ router.post(panel.getLogoutUrl(), express.urlencoded({ extended: false }), (req, res) => {
3418
+ if (!verifyCsrf(req, req.body?._token))
3419
+ return res.status(419).send('Invalid CSRF token.');
3420
+ panel.logOut(res);
3421
+ res.redirect(panel.getLoginUrl());
3422
+ });
3423
+ }
3424
+ if (RegistrationPage)
3425
+ guestPage('register', name('auth.register'), t('filament-panels::auth/pages/register.title'));
3426
+ if (passwordReset) {
3427
+ guestPage('password-reset/request', name('auth.password-reset.request'), t('filament-panels::auth/pages/password-reset/request-password-reset.title'));
3428
+ 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 }));
3429
+ }
3430
+ if (VerificationPrompt) {
3431
+ userPage('email-verification/prompt', name('auth.email-verification.prompt'), t('filament-panels::auth/pages/email-verification/email-verification-prompt.title'));
3432
+ // Emailed link: signed, time-limited, and only valid for the signed-in user it designates.
3433
+ router.get(panel.getBaseUrl('email-verification/verify/:id/:hash'), async (req, res) => {
3434
+ const ctx = await context(req, res);
3435
+ if (!ctx.user)
3436
+ return res.redirect(panel.getLoginUrl());
3437
+ const id = String(req.params.id), hash = String(req.params.hash);
3438
+ const expected = createHash('sha1').update(ctx.user.email).digest('hex');
3439
+ if (String(ctx.user.id) !== id || hash !== expected || !panel.checkVerifyEmailUrl(id, hash, req.query.expires, req.query.signature))
3440
+ return void res.status(403).send('Invalid signature.');
3441
+ await panel.getAuth().markEmailAsVerified?.(ctx.user);
3442
+ res.redirect((await homeUrl(ctx.user)) ?? panel.getBaseUrl());
3443
+ });
3444
+ }
3445
+ if (profile)
3446
+ userPage(profile.page.slug, name('auth.profile'), profile.page.getLabel(), profile.isSimple || panel.hasTenancy() ? 'simple' : 'index');
3447
+ if (TenantRegistration)
3448
+ userPage(TenantRegistration.slug, name('tenancy.registration'), TenantRegistration.getLabel());
3449
+ /** Context of a protected page: login, email verification, tenant. Returns `null` after responding. */
3450
+ const pageContext = async (req, res) => {
3451
+ const ctx = await context(req, res);
3452
+ if (!ctx.user) {
3453
+ res.redirect(panel.getLoginUrl());
3454
+ return null;
3455
+ }
3456
+ if (await panel.mustVerifyEmail(ctx.user)) {
3457
+ res.redirect(panel.getEmailVerificationPromptUrl());
3458
+ return null;
3459
+ }
3460
+ if (panel.hasTenancy()) {
3461
+ ctx.tenant = await tenantFromPath(req.path, ctx.user);
3462
+ if (!ctx.tenant) {
3463
+ res.status(404).send('Not Found');
3464
+ return null;
3465
+ }
3466
+ }
3467
+ return ctx;
3468
+ };
3469
+ const tenantSegment = panel.hasTenancy() ? ':tenant/' : '';
3470
+ if (panel.hasTenancy()) {
3471
+ // Panel root: the user's first tenant, otherwise tenant registration.
3472
+ router.get(panel.getBaseUrl(), async (req, res) => {
3473
+ const ctx = await context(req, res);
3474
+ if (!ctx.user)
3475
+ return res.redirect(panel.getLoginUrl());
3476
+ if (await panel.mustVerifyEmail(ctx.user))
3477
+ return res.redirect(panel.getEmailVerificationPromptUrl());
3478
+ const url = await homeUrl(ctx.user);
3479
+ if (!url)
3480
+ return void res.status(404).send('Not Found');
3481
+ res.redirect(url);
3482
+ });
3483
+ // Tenant root without a dashboard: the first menu page.
3484
+ if (!panel.getPages().some((page) => page.slug === '')) {
3485
+ router.get(panel.getBaseUrl(':tenant'), async (req, res, next) => {
3486
+ if (RESERVED_SEGMENTS.has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug)
3487
+ return next();
3488
+ const ctx = await pageContext(req, res);
3489
+ if (!ctx)
3490
+ return;
3491
+ const url = await panel.withTenant(ctx.tenant, async () => panel.getNavigation(req.path, new Map(), await panel.resolveInaccessiblePages(ctx.user))[0]?.items[0]?.url ?? null);
3492
+ if (!url)
3493
+ return void res.status(404).send('Not Found');
3494
+ res.redirect(url);
3495
+ });
3496
+ }
3497
+ }
3498
+ for (const PageClass of panel.getPages()) {
3499
+ const component = name(`pages.${(PageClass.slug || 'dashboard').replace(/[{}]/g, '')}`);
3500
+ runtime.register(component, PageClass);
3501
+ // `{record}` in the slug becomes a route parameter, passed to mount().
3502
+ router.get(panel.getBaseUrl(tenantSegment + PageClass.slug).replace(/\{(\w+)\}/g, ':$1'), async (req, res, next) => {
3503
+ if (panel.hasTenancy() && (RESERVED_SEGMENTS.has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug))
3504
+ return next();
3505
+ const ctx = await pageContext(req, res);
3506
+ if (!ctx)
3507
+ return;
3508
+ const { tenant: _tenant, ...params } = req.params;
3509
+ await guarded(res, () => document(req, res, ctx, { title: PageClass.getTitle(), component, layout: 'index', params }));
3510
+ });
3511
+ }
3512
+ return router;
3513
+ }
3514
+ /** Each auth feature requires some auth provider methods: fail at startup rather than on first click. */
3515
+ function assertAuthCapabilities(panel) {
3516
+ if (!panel.hasRegistration() && !panel.hasPasswordReset() && !panel.hasEmailVerification() && !panel.hasProfile())
3517
+ return;
3518
+ const auth = panel.getAuth();
3519
+ const missing = (feature, methods) => {
3520
+ const absent = methods.filter((method) => typeof auth[method] !== 'function');
3521
+ if (absent.length)
3522
+ throw new Error(`${feature}: the auth provider must implement ${absent.join(', ')} (knexAuthProvider does).`);
3523
+ };
3524
+ if (panel.hasRegistration())
3525
+ missing('Registration', ['create', 'findByEmail']);
3526
+ if (panel.hasPasswordReset())
3527
+ missing('Password reset', ['findByEmail', 'update', 'getPasswordFingerprint']);
3528
+ if (panel.hasEmailVerification())
3529
+ missing('Email verification', ['hasVerifiedEmail', 'markEmailAsVerified']);
3530
+ if (panel.hasProfile())
3531
+ missing('Profile', ['update']);
3532
+ }